"""
Behavioral tests for the rate limiting subsystem.

Covers untested logic in:
- cli.py: format_stats_table() pure formatting function
- detection.py: provider-specific detection paths, extract_retry_after edge cases
- wrapper.py: _check_if_local_model URL detection, _get_rate_limit_key edge cases,
              _do_invoke rate limit wrapping, string representations
- exceptions.py: exception hierarchy
"""

from unittest.mock import Mock

import pytest


# ---------------------------------------------------------------------------
# cli.py — format_stats_table()
# ---------------------------------------------------------------------------


class TestFormatStatsTableEmptyInput:
    """Tests for format_stats_table with empty/no data."""

    def test_empty_list_returns_no_data_message(self):
        """Empty stats list → specific 'no data' message."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        assert format_stats_table([]) == "No rate limit data available."

    def test_none_like_empty_returns_no_data_message(self):
        """Any falsy input returns the no-data message."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        assert format_stats_table([]) == "No rate limit data available."


class TestFormatStatsTableStructure:
    """Tests for format_stats_table output structure."""

    @pytest.fixture
    def single_row(self):
        """A single stats row tuple: (engine, base_wait, min, max, timestamp, attempts, rate)."""
        return [("DuckDuckGo", 2.50, 1.0, 5.0, 1700000000.0, 42, 0.95)]

    def test_header_line_present(self, single_row):
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        output = format_stats_table(single_row)
        assert "Rate Limit Statistics:" in output

    def test_separator_lines_present(self, single_row):
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        output = format_stats_table(single_row)
        assert "-" * 80 in output

    def test_column_headers_present(self, single_row):
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        output = format_stats_table(single_row)
        for header in [
            "Engine",
            "Base Wait",
            "Range",
            "Success",
            "Attempts",
            "Updated",
        ]:
            assert header in output

    def test_engine_name_appears_in_output(self, single_row):
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        output = format_stats_table(single_row)
        assert "DuckDuckGo" in output


class TestFormatStatsTableFormatting:
    """Tests for format_stats_table value formatting rules."""

    def test_base_wait_two_decimal_places(self):
        """base_wait uses .2f format: 2.5 → '2.50'."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.5, 1.0, 5.0, 1700000000.0, 10, 0.9)]
        output = format_stats_table(row)
        assert "2.50" in output

    def test_range_formatted_with_units(self):
        """Range shows 'min - max' with one decimal and 's' suffix."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.9)]
        output = format_stats_table(row)
        assert "1.0s - 5.0s" in output

    def test_success_rate_formatted_as_percentage(self):
        """Success rate 0.95 → '95.0%'."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.95)]
        output = format_stats_table(row)
        assert "95.0%" in output

    def test_zero_success_rate_formatted(self):
        """Success rate 0.0 → '0.0%'."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.0)]
        output = format_stats_table(row)
        assert "0.0%" in output

    def test_full_success_rate_formatted(self):
        """Success rate 1.0 → '100.0%'."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 1.0)]
        output = format_stats_table(row)
        assert "100.0%" in output

    def test_timestamp_formatted_as_date_time(self):
        """Timestamp uses MM-DD HH:MM format."""
        from datetime import datetime

        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        ts = 1700000000.0
        expected = datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
        row = [("Engine", 2.0, 1.0, 5.0, ts, 10, 0.9)]
        output = format_stats_table(row)
        assert expected in output

    def test_attempts_count_appears(self):
        """Attempts count appears in output as-is."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 12345, 0.9)]
        output = format_stats_table(row)
        assert "12345" in output

    def test_multiple_rows_all_appear(self):
        """Multiple data rows all appear in output."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        rows = [
            ("EngineA", 1.0, 0.5, 2.0, 1700000000.0, 10, 0.8),
            ("EngineB", 3.0, 1.5, 6.0, 1700000000.0, 20, 0.9),
            ("EngineC", 5.0, 2.5, 10.0, 1700000000.0, 30, 0.7),
        ]
        output = format_stats_table(rows)
        assert "EngineA" in output
        assert "EngineB" in output
        assert "EngineC" in output

    def test_output_is_newline_joined(self):
        """Output is lines joined by newlines (not a list)."""
        from local_deep_research.web_search_engines.rate_limiting.cli import (
            format_stats_table,
        )

        row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.9)]
        output = format_stats_table(row)
        lines = output.split("\n")
        # Header + separator + column headers + separator + data row = 5 lines
        assert len(lines) == 5


# ---------------------------------------------------------------------------
# detection.py — provider-specific detection paths
# ---------------------------------------------------------------------------


class TestDetectionProviderSpecificPaths:
    """Tests for provider-specific error detection paths in is_llm_rate_limit_error.

    The function checks (in order): HTTP 429, message patterns, error type name,
    OpenAI module, Anthropic module. The Anthropic path is reachable when the
    message contains 'too many' without 'requests'.
    """

    def test_error_type_ratelimiterror_detected(self):
        """Error class named 'RateLimitError' detected via type-name check."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        class RateLimitError(Exception):
            pass

        error = RateLimitError("Some message without keywords")
        # type name check: "ratelimiterror" in error_type
        assert is_llm_rate_limit_error(error) is True

    def test_error_type_quotaexceeded_detected(self):
        """Error class named 'QuotaExceeded' detected via type-name check."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        class QuotaExceeded(Exception):
            pass

        error = QuotaExceeded("Generic error")
        assert is_llm_rate_limit_error(error) is True

    def test_anthropic_too_many_without_requests_detected(self):
        """Anthropic module error with 'too many' (no 'requests') uses Anthropic-specific path.

        The generic check looks for 'too many requests' but Anthropic check
        looks for just 'too many' — so this is only caught by the Anthropic path.
        """
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        # Create error class whose module contains "anthropic"
        AnthropicError = type(
            "SomeError", (Exception,), {"__module__": "anthropic.errors"}
        )
        error = AnthropicError("too many connections")
        assert is_llm_rate_limit_error(error) is True

    def test_anthropic_module_clean_message_not_detected(self):
        """Anthropic module error without any rate-limit indicator → False."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        AnthropicAuthError = type(
            "AuthError", (Exception,), {"__module__": "anthropic.errors"}
        )
        error = AnthropicAuthError("Invalid API key provided")
        assert is_llm_rate_limit_error(error) is False

    def test_error_without_response_attribute_uses_message(self):
        """Plain Exception without .response → falls through to message check."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        error = Exception("rate limit exceeded")
        assert not hasattr(error, "response")
        assert is_llm_rate_limit_error(error) is True

    def test_error_with_non_429_status_code_uses_message(self):
        """Error with response.status_code != 429 falls through to message check."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        error = Mock()
        error.response = Mock()
        error.response.status_code = 500
        error.__str__ = lambda self: "Internal server error"
        error.__class__.__name__ = "HTTPError"
        error.__class__.__module__ = "requests"
        assert is_llm_rate_limit_error(error) is False

    def test_completely_clean_error_returns_false(self):
        """Error with no rate-limit signals at all → False."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        error = Exception("Connection timed out after 30 seconds")
        assert is_llm_rate_limit_error(error) is False

    def test_slow_down_message_detected(self):
        """'slow down' message pattern is detected."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        error = Exception("Please slow down your requests")
        assert is_llm_rate_limit_error(error) is True

    def test_threshold_message_detected(self):
        """'threshold' message pattern is detected."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        error = Exception("Request threshold reached")
        assert is_llm_rate_limit_error(error) is True

    def test_case_insensitive_detection(self):
        """Detection is case-insensitive (message lowercased before check)."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            is_llm_rate_limit_error,
        )

        error = Exception("RATE LIMIT EXCEEDED")
        assert is_llm_rate_limit_error(error) is True


# ---------------------------------------------------------------------------
# detection.py — extract_retry_after edge cases
# ---------------------------------------------------------------------------


class TestExtractRetryAfterEdgeCases:
    """Edge case tests for extract_retry_after."""

    def test_decimal_retry_after_header(self):
        """Retry-After header with decimal value: '30.5' → 30.5."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Mock()
        error.response = Mock()
        error.response.headers = {"Retry-After": "30.5"}
        error.__str__ = lambda self: "rate limit"
        assert extract_retry_after(error) == 30.5

    def test_non_numeric_retry_after_header_falls_through(self):
        """Non-numeric Retry-After header → falls through to message parsing."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Mock()
        error.response = Mock()
        error.response.headers = {
            "Retry-After": "Wed, 21 Oct 2023 07:28:00 GMT"
        }
        error.__str__ = lambda self: "Please try again in 60 seconds"
        assert extract_retry_after(error) == 60.0

    def test_no_response_attribute_uses_message(self):
        """Error without .response → falls through to message regex."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Exception("Retry after 15 seconds")
        assert extract_retry_after(error) == 15.0

    def test_retry_after_pattern_in_message(self):
        """'retry after N seconds' pattern matched (case insensitive)."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Exception("Retry after 25 seconds please")
        assert extract_retry_after(error) == 25.0

    def test_wait_pattern_in_message(self):
        """'wait N seconds' pattern matched."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Exception("Please wait 10 seconds before retrying")
        assert extract_retry_after(error) == 10.0

    def test_decimal_seconds_in_message(self):
        """Decimal seconds in message: 'try again in 1.5 seconds'."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Exception("Please try again in 1.5 seconds")
        assert extract_retry_after(error) == 1.5

    def test_no_time_info_returns_zero(self):
        """Error message with no time information → 0."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Exception("Rate limit exceeded, come back later")
        assert extract_retry_after(error) == 0

    def test_response_without_headers_attribute(self):
        """Error with response but no headers attribute → 0 if no message match."""
        from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
            extract_retry_after,
        )

        error = Mock()
        error.response = Mock(spec=["status_code"])  # No .headers
        error.__str__ = lambda self: "Some error"
        assert extract_retry_after(error) == 0


# ---------------------------------------------------------------------------
# wrapper.py — _check_if_local_model
# ---------------------------------------------------------------------------


class TestCheckIfLocalModel:
    """Tests for _check_if_local_model URL-based and provider-based detection."""

    def _make_wrapper(self, provider=None, base_url=None):
        """Create a wrapper with given provider and base_url."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        if base_url is not None:
            mock_llm.base_url = base_url
        else:
            # Remove base_url attribute
            del mock_llm.base_url
        mock_llm.model_name = "test-model"

        return create_rate_limited_llm_wrapper(mock_llm, provider=provider)

    def test_localhost_url_detected_as_local(self):
        """base_url containing 'localhost' → local model."""
        wrapper = self._make_wrapper(
            provider="custom", base_url="http://localhost:11434"
        )
        assert wrapper._check_if_local_model() is True

    def test_127_0_0_1_url_detected_as_local(self):
        """base_url containing '127.0.0.1' → local model."""
        wrapper = self._make_wrapper(
            provider="custom", base_url="http://127.0.0.1:8080"
        )
        assert wrapper._check_if_local_model() is True

    def test_0_0_0_0_url_detected_as_local(self):
        """base_url containing '0.0.0.0' → local model."""
        wrapper = self._make_wrapper(
            provider="custom", base_url="http://0.0.0.0:5000"
        )
        assert wrapper._check_if_local_model() is True

    def test_remote_url_not_local(self):
        """Remote URL like api.openai.com → not local."""
        wrapper = self._make_wrapper(
            provider="custom", base_url="https://api.openai.com/v1"
        )
        assert wrapper._check_if_local_model() is False

    def test_no_base_url_not_local(self):
        """LLM without base_url attribute → not local (by URL check)."""
        wrapper = self._make_wrapper(provider="custom", base_url=None)
        assert wrapper._check_if_local_model() is False

    def test_ollama_provider_is_local(self):
        """'ollama' provider → local model."""
        wrapper = self._make_wrapper(
            provider="ollama", base_url="https://api.example.com"
        )
        assert wrapper._check_if_local_model() is True

    def test_lmstudio_provider_is_local(self):
        """'lmstudio' provider → local model."""
        wrapper = self._make_wrapper(provider="lmstudio")
        assert wrapper._check_if_local_model() is True

    def test_llamacpp_provider_is_local(self):
        """'llamacpp' provider → local model."""
        wrapper = self._make_wrapper(provider="llamacpp")
        assert wrapper._check_if_local_model() is True

    def test_local_provider_is_local(self):
        """'local' provider → local model."""
        wrapper = self._make_wrapper(provider="local")
        assert wrapper._check_if_local_model() is True

    def test_none_provider_string_is_local(self):
        """'none' provider → local model (in local_providers list)."""
        wrapper = self._make_wrapper(provider="none")
        assert wrapper._check_if_local_model() is True

    def test_provider_case_insensitive(self):
        """Provider check is case-insensitive: 'OLLAMA' → local."""
        wrapper = self._make_wrapper(provider="OLLAMA")
        assert wrapper._check_if_local_model() is True

    def test_none_provider_value_not_local(self):
        """None (Python None) provider → not local by provider check."""
        wrapper = self._make_wrapper(provider=None)
        assert wrapper._check_if_local_model() is False

    def test_openai_provider_not_local(self):
        """'openai' provider with remote URL → not local."""
        wrapper = self._make_wrapper(
            provider="openai", base_url="https://api.openai.com"
        )
        assert wrapper._check_if_local_model() is False


# ---------------------------------------------------------------------------
# wrapper.py — _get_rate_limit_key edge cases
# ---------------------------------------------------------------------------


class TestGetRateLimitKey:
    """Tests for _get_rate_limit_key composite key building."""

    def _make_wrapper(
        self, provider=None, base_url=None, model_name=None, model=None
    ):
        """Create a wrapper with specified attributes on the mock LLM."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock(spec=[])  # Empty spec so we control attributes
        if base_url is not None:
            mock_llm.base_url = base_url
        if model_name is not None:
            mock_llm.model_name = model_name
        if model is not None:
            mock_llm.model = model

        return create_rate_limited_llm_wrapper(mock_llm, provider=provider)

    def test_basic_key_structure(self):
        """Key format: provider-url-model."""
        wrapper = self._make_wrapper(
            provider="openai",
            base_url="https://api.openai.com/v1",
            model_name="gpt-4",
        )
        key = wrapper._get_rate_limit_key()
        assert key == "openai-api.openai.com-gpt-4"

    def test_no_provider_uses_unknown(self):
        """None provider → 'unknown' in key."""
        wrapper = self._make_wrapper(
            provider=None,
            base_url="https://api.example.com",
            model_name="test",
        )
        key = wrapper._get_rate_limit_key()
        assert key.startswith("unknown-")

    def test_no_base_url_uses_unknown(self):
        """LLM without base_url or _client → 'unknown' for URL part."""
        wrapper = self._make_wrapper(
            provider="openai",
            model_name="gpt-4",
        )
        key = wrapper._get_rate_limit_key()
        assert "unknown" in key

    def test_fallback_to_client_base_url(self):
        """Falls back to _client.base_url when base_url not present."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock(spec=[])
        mock_llm._client = Mock()
        mock_llm._client.base_url = "https://api.fallback.com/v1"
        mock_llm.model_name = "test-model"

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        key = wrapper._get_rate_limit_key()
        assert "api.fallback.com" in key

    def test_model_name_slash_replaced(self):
        """'/' in model name replaced with '-'."""
        wrapper = self._make_wrapper(
            provider="openai",
            base_url="https://api.example.com",
            model_name="org/model-name",
        )
        key = wrapper._get_rate_limit_key()
        assert "org-model-name" in key
        assert "/" not in key

    def test_model_name_colon_replaced(self):
        """':' in model name replaced with '-': 'llama3:8b' → 'llama3-8b'."""
        wrapper = self._make_wrapper(
            provider="ollama",
            base_url="http://localhost:11434",
            model_name="llama3:8b",
        )
        key = wrapper._get_rate_limit_key()
        assert "llama3-8b" in key
        assert "llama3:8b" not in key  # Original colon form should not appear

    def test_model_attribute_fallback(self):
        """Falls back to .model when .model_name not present."""
        wrapper = self._make_wrapper(
            provider="anthropic",
            base_url="https://api.anthropic.com",
            model="claude-3-opus",
        )
        key = wrapper._get_rate_limit_key()
        assert "claude-3-opus" in key

    def test_no_model_attribute_uses_unknown(self):
        """LLM without model_name or model → 'unknown' for model part."""
        wrapper = self._make_wrapper(
            provider="openai",
            base_url="https://api.example.com",
        )
        key = wrapper._get_rate_limit_key()
        assert key.endswith("-unknown")

    def test_url_trailing_slashes_stripped(self):
        """Trailing slashes in URL are stripped."""
        wrapper = self._make_wrapper(
            provider="openai",
            base_url="https://api.example.com/",
            model_name="gpt-4",
        )
        key = wrapper._get_rate_limit_key()
        assert not key.endswith("/")
        assert "api.example.com-gpt-4" in key

    def test_url_protocol_stripped(self):
        """URL protocol (https://) is stripped — only netloc kept."""
        wrapper = self._make_wrapper(
            provider="openai",
            base_url="https://api.example.com/v1",
            model_name="gpt-4",
        )
        key = wrapper._get_rate_limit_key()
        assert "https://" not in key
        assert "api.example.com" in key


# ---------------------------------------------------------------------------
# wrapper.py — string representations
# ---------------------------------------------------------------------------


class TestWrapperStringRepresentations:
    """Tests for __str__ and __repr__ of RateLimitedLLMWrapper."""

    def test_str_format(self):
        """__str__ wraps base LLM string: 'RateLimited(...)'."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        mock_llm.__str__ = lambda self: "MockLLM(gpt-4)"
        mock_llm.model_name = "gpt-4"

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        assert str(wrapper) == "RateLimited(MockLLM(gpt-4))"

    def test_repr_format(self):
        """__repr__ wraps base LLM repr: 'RateLimitedLLMWrapper(...)'."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        mock_llm.__repr__ = lambda self: "MockLLM(model='gpt-4')"
        mock_llm.model_name = "gpt-4"

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        assert repr(wrapper) == "RateLimitedLLMWrapper(MockLLM(model='gpt-4'))"


# ---------------------------------------------------------------------------
# wrapper.py — _do_invoke rate limit error wrapping
# ---------------------------------------------------------------------------


class TestDoInvokeRateLimitWrapping:
    """Tests for _do_invoke wrapping rate limit errors as RateLimitError."""

    def test_rate_limit_error_wrapped(self):
        """Rate limit error from base LLM → wrapped as RateLimitError."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            RateLimitError,
        )
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        mock_llm.model_name = "gpt-4"
        mock_llm.invoke.side_effect = Exception("429 Too many requests")

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        with pytest.raises(RateLimitError, match="LLM rate limit"):
            wrapper._do_invoke("test prompt")

    def test_non_rate_limit_error_not_wrapped(self):
        """Non-rate-limit error from base LLM → re-raised as-is."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        mock_llm.model_name = "gpt-4"
        mock_llm.invoke.side_effect = ValueError("Invalid input format")

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        with pytest.raises(ValueError, match="Invalid input format"):
            wrapper._do_invoke("test prompt")

    def test_successful_invoke_returns_result(self):
        """Successful invoke returns the base LLM result."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        mock_llm.model_name = "gpt-4"
        mock_llm.invoke.return_value = "response text"

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        result = wrapper._do_invoke("test prompt")
        assert result == "response text"


# ---------------------------------------------------------------------------
# wrapper.py — attribute passthrough via __getattr__
# ---------------------------------------------------------------------------


class TestWrapperAttributePassthrough:
    """Tests for __getattr__ passing attributes through to base LLM."""

    def test_passes_through_custom_attributes(self):
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock()
        mock_llm.model_name = "gpt-4"
        mock_llm.temperature = 0.7
        mock_llm.custom_setting = "value"

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        assert wrapper.temperature == 0.7
        assert wrapper.custom_setting == "value"

    def test_nonexistent_attribute_raises(self):
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            create_rate_limited_llm_wrapper,
        )

        mock_llm = Mock(spec=["invoke", "model_name"])
        mock_llm.model_name = "gpt-4"

        wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
        with pytest.raises(AttributeError):
            _ = wrapper.nonexistent_attribute


# ---------------------------------------------------------------------------
# exceptions.py — exception hierarchy
# ---------------------------------------------------------------------------


class TestRateLimitExceptions:
    """Tests for rate limiting exception classes."""

    def test_rate_limit_error_is_exception(self):
        """RateLimitError is a subclass of Exception."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            RateLimitError,
        )

        assert issubclass(RateLimitError, Exception)

    def test_adaptive_retry_error_is_exception(self):
        """AdaptiveRetryError is a subclass of Exception."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            AdaptiveRetryError,
        )

        assert issubclass(AdaptiveRetryError, Exception)

    def test_rate_limit_config_error_is_exception(self):
        """RateLimitConfigError is a subclass of Exception."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            RateLimitConfigError,
        )

        assert issubclass(RateLimitConfigError, Exception)

    def test_rate_limit_error_carries_message(self):
        """RateLimitError preserves its message."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            RateLimitError,
        )

        error = RateLimitError("too many requests to API")
        assert str(error) == "too many requests to API"

    def test_exceptions_are_catchable_separately(self):
        """Each exception type is distinct and catchable independently."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            AdaptiveRetryError,
            RateLimitConfigError,
            RateLimitError,
        )

        with pytest.raises(RateLimitError):
            raise RateLimitError("test")

        with pytest.raises(AdaptiveRetryError):
            raise AdaptiveRetryError("test")

        with pytest.raises(RateLimitConfigError):
            raise RateLimitConfigError("test")

    def test_rate_limit_error_not_caught_as_config_error(self):
        """RateLimitError is not caught by RateLimitConfigError handler."""
        from local_deep_research.web_search_engines.rate_limiting.exceptions import (
            RateLimitConfigError,
            RateLimitError,
        )

        with pytest.raises(RateLimitError):
            try:
                raise RateLimitError("rate limited")
            except RateLimitConfigError:
                pytest.fail(
                    "RateLimitError incorrectly caught as RateLimitConfigError"
                )


# ---------------------------------------------------------------------------
# wrapper.py — AdaptiveLLMWait strategy
# ---------------------------------------------------------------------------


class TestAdaptiveLLMWait:
    """Tests for AdaptiveLLMWait tenacity wait strategy."""

    def test_uses_tracker_wait_time(self):
        """Wait time comes from tracker.get_wait_time."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            AdaptiveLLMWait,
        )

        mock_tracker = Mock()
        mock_tracker.get_wait_time.return_value = 5.0

        wait = AdaptiveLLMWait(mock_tracker, "test-engine")
        retry_state = Mock()
        retry_state.outcome = None

        result = wait(retry_state)
        assert result == 5.0
        mock_tracker.get_wait_time.assert_called_once_with("test-engine")

    def test_retry_after_overrides_tracker_when_larger(self):
        """retry-after from error overrides tracker wait when it's larger."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            AdaptiveLLMWait,
        )

        mock_tracker = Mock()
        mock_tracker.get_wait_time.return_value = 2.0

        wait = AdaptiveLLMWait(mock_tracker, "test-engine")

        # Simulate a failed attempt with retry-after info
        error = Exception("Please try again in 30 seconds")
        retry_state = Mock()
        retry_state.outcome = Mock()
        retry_state.outcome.failed = True
        retry_state.outcome.exception.return_value = error

        result = wait(retry_state)
        assert result == 30.0  # retry-after (30) > tracker (2)

    def test_tracker_time_used_when_larger_than_retry_after(self):
        """Tracker wait time used when it's larger than retry-after."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            AdaptiveLLMWait,
        )

        mock_tracker = Mock()
        mock_tracker.get_wait_time.return_value = 60.0

        wait = AdaptiveLLMWait(mock_tracker, "test-engine")

        error = Exception("Please try again in 5 seconds")
        retry_state = Mock()
        retry_state.outcome = Mock()
        retry_state.outcome.failed = True
        retry_state.outcome.exception.return_value = error

        result = wait(retry_state)
        assert result == 60.0  # tracker (60) > retry-after (5)

    def test_no_failure_uses_tracker_time(self):
        """When outcome is not failed, only tracker time is used."""
        from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
            AdaptiveLLMWait,
        )

        mock_tracker = Mock()
        mock_tracker.get_wait_time.return_value = 3.0

        wait = AdaptiveLLMWait(mock_tracker, "test-engine")

        retry_state = Mock()
        retry_state.outcome = Mock()
        retry_state.outcome.failed = False

        result = wait(retry_state)
        assert result == 3.0
