"""
Tests for BaseSearchEngine helper methods.

Tests cover:
- _resolve_api_key() - API key resolution from multiple sources
- _is_rate_limit_error() - Rate limit error detection
- _get_full_content() - Default full content extraction
- _sanitize_error_message() - Error message sanitization
- _mask_api_key() - API key masking for safe logging
"""

import copy
from unittest.mock import Mock, patch

import pytest

from src.local_deep_research.web_search_engines.search_engine_base import (
    BaseSearchEngine,
)
from src.local_deep_research.web_search_engines.rate_limiting import (
    RateLimitError,
)


class ConcreteSearchEngine(BaseSearchEngine):
    """Concrete implementation for testing."""

    is_public = True
    is_generic = True

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def _get_previews(self, query):
        return []

    def _get_full_content(self, relevant_items):
        return super()._get_full_content(relevant_items)


class TestIsValidApiKey:
    """Tests for _is_valid_api_key static method."""

    def test_valid_api_key_returns_true(self):
        """Valid API key returns True."""
        assert BaseSearchEngine._is_valid_api_key("sk-abc123def456") is True

    def test_empty_string_returns_false(self):
        """Empty string returns False."""
        assert BaseSearchEngine._is_valid_api_key("") is False

    def test_none_returns_false(self):
        """None returns False."""
        assert BaseSearchEngine._is_valid_api_key(None) is False

    def test_placeholder_your_api_key_here(self):
        """YOUR_API_KEY_HERE placeholder returns False."""
        assert BaseSearchEngine._is_valid_api_key("YOUR_API_KEY_HERE") is False

    def test_placeholder_none_string(self):
        """'None' string returns False."""
        assert BaseSearchEngine._is_valid_api_key("None") is False

    def test_placeholder_null_string(self):
        """'null' string returns False."""
        assert BaseSearchEngine._is_valid_api_key("null") is False

    def test_placeholder_ends_with_api_key(self):
        """Keys ending with _API_KEY are rejected."""
        assert BaseSearchEngine._is_valid_api_key("BRAVE_API_KEY") is False

    def test_placeholder_starts_with_your(self):
        """Keys starting with YOUR_ are rejected."""
        assert BaseSearchEngine._is_valid_api_key("YOUR_SECRET_KEY") is False

    def test_whitespace_only_returns_false(self):
        """Whitespace-only string returns False."""
        assert BaseSearchEngine._is_valid_api_key("   ") is False

    def test_strips_whitespace(self):
        """Valid key with whitespace is accepted after stripping."""
        assert BaseSearchEngine._is_valid_api_key("  sk-abc123  ") is True

    def test_angle_bracket_placeholder(self):
        """Angle bracket placeholders are rejected."""
        assert BaseSearchEngine._is_valid_api_key("<API_KEY>") is False

    def test_env_var_placeholder(self):
        """Environment variable placeholders are rejected."""
        assert BaseSearchEngine._is_valid_api_key("${API_KEY}") is False


class TestResolveApiKey:
    """Tests for _resolve_api_key method."""

    def test_returns_provided_key(self):
        """Returns the provided API key when valid."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._resolve_api_key(
            api_key="sk-direct-key",
            setting_key="search.api_key",
            engine_name="Test Engine",
        )
        assert result == "sk-direct-key"

    def test_falls_back_to_settings(self):
        """Falls back to settings snapshot when no direct key."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "sk-settings-key"},
            programmatic_mode=True,
        )
        result = engine._resolve_api_key(
            api_key=None,
            setting_key="search.api_key",
            engine_name="Test Engine",
        )
        assert result == "sk-settings-key"

    def test_priority_order(self):
        """Direct parameter takes priority over settings."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "sk-settings-key"},
            programmatic_mode=True,
        )
        result = engine._resolve_api_key(
            api_key="sk-direct-key",
            setting_key="search.api_key",
            engine_name="Test Engine",
        )
        assert result == "sk-direct-key"

    def test_rejects_placeholder_in_direct_key(self):
        """Rejects placeholder values in direct parameter."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "sk-settings-key"},
            programmatic_mode=True,
        )
        result = engine._resolve_api_key(
            api_key="YOUR_API_KEY_HERE",
            setting_key="search.api_key",
            engine_name="Test Engine",
        )
        assert result == "sk-settings-key"

    def test_raises_value_error_when_not_found(self):
        """Raises ValueError when no valid key found."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        with pytest.raises(ValueError) as exc_info:
            engine._resolve_api_key(
                api_key=None,
                setting_key="search.nonexistent_key",
                engine_name="Test Engine",
            )
        assert "No valid API key found" in str(exc_info.value)
        assert "Test Engine" in str(exc_info.value)

    def test_handles_empty_string(self):
        """Empty string is treated as no key provided."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "sk-settings-key"},
            programmatic_mode=True,
        )
        result = engine._resolve_api_key(
            api_key="",
            setting_key="search.api_key",
            engine_name="Test Engine",
        )
        assert result == "sk-settings-key"

    def test_strips_whitespace_from_key(self):
        """Strips whitespace from resolved key."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._resolve_api_key(
            api_key="  sk-whitespace-key  ",
            setting_key="search.api_key",
            engine_name="Test Engine",
        )
        assert result == "sk-whitespace-key"

    def test_uses_instance_settings_snapshot(self):
        """Uses self.settings_snapshot when snapshot not provided."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "sk-instance-key"},
            programmatic_mode=True,
        )
        result = engine._resolve_api_key(
            api_key=None,
            setting_key="search.api_key",
            engine_name="Test Engine",
            settings_snapshot=None,  # Should use self.settings_snapshot
        )
        assert result == "sk-instance-key"


class TestIsRateLimitError:
    """Tests for _is_rate_limit_error method."""

    def test_detects_429_status_code(self):
        """Detects 429 HTTP status code."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error(429) is True

    def test_429_alone_does_not_match(self):
        """Bare '429' in a string does not trigger false positive."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error("Error code 429") is False
        # But '429' combined with a real phrase still matches
        assert (
            engine._is_rate_limit_error("Error 429: Too Many Requests") is True
        )

    def test_detects_rate_limit_phrases(self):
        """Detects common rate limit phrases."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error("Rate limit exceeded") is True
        assert engine._is_rate_limit_error("rate_limit hit") is True
        assert engine._is_rate_limit_error("You hit the ratelimit") is True

    def test_case_insensitive(self):
        """Detection is case insensitive."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error("RATE LIMIT EXCEEDED") is True
        assert engine._is_rate_limit_error("Rate Limit Exceeded") is True

    def test_detects_httperror_with_429(self):
        """Detects HTTPError with 429 status code."""
        engine = ConcreteSearchEngine(programmatic_mode=True)

        class MockHTTPError(Exception):
            def __init__(self):
                super().__init__("HTTP Error")
                self.response = Mock(status_code=429)

        error = MockHTTPError()
        assert engine._is_rate_limit_error(error) is True

    def test_additional_patterns(self):
        """Additional patterns are matched."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert (
            engine._is_rate_limit_error(
                "Custom error", additional_patterns={"custom error"}
            )
            is True
        )

    def test_detects_throttling(self):
        """Detects throttling messages."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error("Request throttled") is True
        assert engine._is_rate_limit_error("Throttling active") is True

    def test_non_rate_limit_error(self):
        """Non-rate-limit errors return False."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error(ValueError("Invalid input")) is False
        assert engine._is_rate_limit_error("Connection refused") is False
        assert engine._is_rate_limit_error(500) is False

    def test_detects_quota_exceeded(self):
        """Detects quota exceeded messages."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert engine._is_rate_limit_error("Quota exceeded for today") is True
        assert engine._is_rate_limit_error("API quota_exceeded") is True

    def test_detects_too_many_requests(self):
        """Detects 'too many requests' phrase."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        assert (
            engine._is_rate_limit_error("Too many requests, please slow down")
            is True
        )


class TestRaiseIfRateLimit:
    """Tests for _raise_if_rate_limit method."""

    def test_raises_rate_limit_error_when_detected(self):
        """Raises RateLimitError when rate limit detected."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        with pytest.raises(RateLimitError):
            engine._raise_if_rate_limit(429)

    def test_does_not_raise_for_non_rate_limit(self):
        """Does not raise for non-rate-limit errors."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        # Should not raise
        engine._raise_if_rate_limit("Connection refused")
        engine._raise_if_rate_limit(ValueError("Invalid input"))

    def test_sanitizes_error_message(self):
        """Error message is sanitized before raising."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        with pytest.raises(RateLimitError) as exc_info:
            engine._raise_if_rate_limit(
                "Rate limit with key sk-abcd1234efgh5678ijkl"
            )
        # The API key should be redacted
        assert "sk-abcd1234" not in str(exc_info.value)


class TestGetFullContentDefault:
    """Tests for default _get_full_content implementation."""

    def test_empty_input(self):
        """Empty input returns empty list."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._get_full_content([])
        assert result == []

    def test_extracts_full_result(self):
        """Extracts data from _full_result key."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [
            {
                "title": "Preview",
                "_full_result": {"title": "Full", "content": "Content"},
            }
        ]
        result = engine._get_full_content(items)
        assert len(result) == 1
        assert result[0]["title"] == "Full"
        assert result[0]["content"] == "Content"

    def test_uses_item_when_no_full_result(self):
        """Uses item directly when no _full_result key."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [{"title": "Preview", "snippet": "Snippet"}]
        result = engine._get_full_content(items)
        assert len(result) == 1
        assert result[0]["title"] == "Preview"
        assert result[0]["snippet"] == "Snippet"

    def test_removes_internal_key(self):
        """Removes _full_result key from output."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [
            {
                "title": "Preview",
                "_full_result": {"title": "Full", "content": "Content"},
            }
        ]
        result = engine._get_full_content(items)
        assert "_full_result" not in result[0]

    def test_handles_nested_data(self):
        """Handles nested data structures."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [
            {
                "title": "Preview",
                "_full_result": {
                    "title": "Full",
                    "metadata": {"author": "Test", "date": "2024-01-01"},
                },
            }
        ]
        result = engine._get_full_content(items)
        assert result[0]["metadata"]["author"] == "Test"

    def test_multiple_items(self):
        """Handles multiple items correctly."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [
            {"title": "A", "_full_result": {"title": "A Full"}},
            {"title": "B", "_full_result": {"title": "B Full"}},
            {"title": "C"},  # No _full_result
        ]
        result = engine._get_full_content(items)
        assert len(result) == 3
        assert result[0]["title"] == "A Full"
        assert result[1]["title"] == "B Full"
        assert result[2]["title"] == "C"


class TestSanitizeErrorMessage:
    """Tests for _sanitize_error_message method."""

    def test_masks_bearer_tokens(self):
        """Masks Bearer tokens."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Error with Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
        )
        assert "Bearer [REDACTED]" in result
        assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result

    def test_masks_api_keys_in_urls(self):
        """Masks API keys in URL parameters."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Failed to call https://api.example.com?api_key=secret123"
        )
        assert "api_key=[REDACTED]" in result
        assert "secret123" not in result

    def test_masks_url_credentials(self):
        """Masks credentials in URLs."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Error connecting to https://user:password@api.example.com"
        )
        assert "[REDACTED]:[REDACTED]@" in result
        assert "user" not in result or result.count("user") == 0
        assert "password" not in result

    def test_preserves_safe_content(self):
        """Preserves non-sensitive content."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Connection timeout after 30 seconds"
        )
        assert result == "Connection timeout after 30 seconds"

    def test_masks_sk_prefixed_keys(self):
        """Masks sk- prefixed API keys."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Invalid API key: sk-abcdefghijklmnop1234567890"
        )
        assert "[REDACTED_KEY]" in result
        assert "sk-abcdefghijklmnop1234567890" not in result

    def test_masks_pk_prefixed_keys(self):
        """Masks pk- prefixed API keys."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Invalid key: pk-test123456789012345678"
        )
        assert "[REDACTED_KEY]" in result

    def test_empty_message(self):
        """Empty message returns empty."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message("")
        assert result == ""

    def test_none_message(self):
        """None message returns None."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(None)
        assert result is None


class TestMaskApiKey:
    """Tests for _mask_api_key method."""

    def test_masks_long_key(self):
        """Masks long API key with visible chars at start and end."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._mask_api_key("sk-abcdefghijklmnop123456")
        assert result == "sk-a...3456"
        assert "bcdefghijklmnop12" not in result

    def test_short_key_returns_stars(self):
        """Short key returns ***."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._mask_api_key("short")
        assert result == "***"

    def test_empty_key_returns_stars(self):
        """Empty key returns ***."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._mask_api_key("")
        assert result == "***"

    def test_none_key_returns_stars(self):
        """None key returns ***."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._mask_api_key(None)
        assert result == "***"

    def test_custom_visible_chars(self):
        """Custom visible_chars parameter works."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._mask_api_key(
            "sk-abcdefghijklmnop123456", visible_chars=6
        )
        assert result == "sk-abc...123456"

    def test_exact_boundary_length(self):
        """Key at exactly visible_chars * 2 boundary."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        # 8 chars total, visible_chars=4 means 8 chars shown, so boundary case
        result = engine._mask_api_key("12345678", visible_chars=4)
        assert result == "***"

    def test_just_over_boundary(self):
        """Key just over boundary gets masked."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._mask_api_key("123456789", visible_chars=4)
        assert result == "1234...6789"


class TestRateLimitPatterns:
    """Tests for rate_limit_patterns class attribute."""

    def test_default_patterns_exist(self):
        """Default patterns are defined."""
        assert "rate limit" in BaseSearchEngine.rate_limit_patterns
        assert "throttl" in BaseSearchEngine.rate_limit_patterns
        assert "too many requests" in BaseSearchEngine.rate_limit_patterns

    def test_subclass_can_extend_patterns(self):
        """Subclass can extend patterns."""

        class CustomEngine(BaseSearchEngine):
            rate_limit_patterns = BaseSearchEngine.rate_limit_patterns | {
                "custom limit",
                "engine specific",
            }

            def _get_previews(self, query):
                return []

        assert "custom limit" in CustomEngine.rate_limit_patterns
        assert "engine specific" in CustomEngine.rate_limit_patterns
        # Original patterns still present
        assert "rate limit" in CustomEngine.rate_limit_patterns

    def test_patterns_are_case_insensitive_in_matching(self):
        """Pattern matching is case insensitive."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        # All these should match due to case-insensitive comparison
        assert engine._is_rate_limit_error("RATE LIMIT") is True
        assert engine._is_rate_limit_error("Rate Limit") is True
        assert engine._is_rate_limit_error("rate limit") is True


class TestExtractFullResult:
    """Tests for _extract_full_result helper method."""

    def test_extracts_from_full_result(self):
        """Extracts data from _full_result key."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        item = {
            "title": "Preview",
            "_full_result": {"title": "Full Title", "content": "Full Content"},
        }
        result = engine._extract_full_result(item)
        assert result["title"] == "Full Title"
        assert result["content"] == "Full Content"

    def test_uses_item_when_no_full_result(self):
        """Uses item directly when no _full_result."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        item = {"title": "Preview", "snippet": "Snippet"}
        result = engine._extract_full_result(item)
        assert result["title"] == "Preview"
        assert result["snippet"] == "Snippet"

    def test_removes_full_result_key(self):
        """Removes _full_result from output."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        item = {
            "title": "Preview",
            "_full_result": {"title": "Full", "content": "Content"},
        }
        result = engine._extract_full_result(item)
        assert "_full_result" not in result

    def test_preserves_all_other_keys(self):
        """Preserves all keys from source."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        item = {
            "title": "Preview",
            "_full_result": {
                "title": "Full",
                "content": "Content",
                "url": "https://example.com",
                "metadata": {"key": "value"},
            },
        }
        result = engine._extract_full_result(item)
        assert "url" in result
        assert "metadata" in result
        assert result["metadata"]["key"] == "value"


# ── Additional coverage tests ──────────────────────────────────────────


class TestRaiseIfRateLimitExtended:
    """Extended tests for _raise_if_rate_limit — sanitization and edge cases."""

    def test_sanitizes_api_key_in_error_message(self):
        """API keys in error messages are redacted before raising."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        api_key = "sk-abcdefghijklmnop12345678"
        with pytest.raises(RateLimitError) as exc_info:
            engine._raise_if_rate_limit(f"Rate limit with key {api_key}")
        assert api_key not in str(exc_info.value)
        assert "[REDACTED_KEY]" in str(exc_info.value)

    def test_passes_additional_patterns(self):
        """Additional patterns are forwarded to detection."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        with pytest.raises(RateLimitError):
            engine._raise_if_rate_limit(
                "custom throttle triggered",
                additional_patterns={"custom throttle"},
            )

    def test_does_not_raise_for_non_rate_limit(self):
        """Non-rate-limit errors pass through without raising."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        # Should return None, not raise
        result = engine._raise_if_rate_limit("Connection refused")
        assert result is None

    def test_handles_none_error(self):
        """None error doesn't crash."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        # _is_rate_limit_error converts to string, "None" doesn't match any pattern
        result = engine._raise_if_rate_limit(None)
        assert result is None


class TestSanitizeErrorMessageExtended:
    """Extended tests for _sanitize_error_message — regex gaps."""

    def test_preserves_long_alphanumeric_string(self):
        """Non-secret 32+ char strings (UUIDs, request IDs) are preserved."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        request_id = "a" * 32
        result = engine._sanitize_error_message(f"Request {request_id} failed")
        assert request_id in result

    def test_redacts_multiple_url_params(self):
        """Multiple sensitive URL parameters are all redacted."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        result = engine._sanitize_error_message(
            "Failed: https://api.example.com?api_key=secret1&token=secret2"
        )
        assert "secret1" not in result
        assert "secret2" not in result
        assert "api_key=[REDACTED]" in result
        assert "token=[REDACTED]" in result

    def test_sk_key_boundary_20_chars(self):
        """sk- key with exactly 20 chars is redacted, 19 chars is not."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        key_20 = "sk-" + "a" * 20
        key_19 = "sk-" + "a" * 19
        result_20 = engine._sanitize_error_message(f"key: {key_20}")
        result_19 = engine._sanitize_error_message(f"key: {key_19}")
        assert key_20 not in result_20
        assert "[REDACTED_KEY]" in result_20
        # 19-char key doesn't match the sk- pattern (requires 20+)
        assert key_19 in result_19

    def test_multiple_patterns_in_one_message(self):
        """Bearer token, sk- key, and URL credentials all redacted together."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        message = (
            "Bearer eyJhbGciOiJIUzI1NiJ9 failed for "
            "https://user:pass@api.com with key "
            "sk-abcdefghijklmnop123456"
        )
        result = engine._sanitize_error_message(message)
        assert "Bearer [REDACTED]" in result
        assert "[REDACTED]:[REDACTED]@" in result
        assert "sk-abcdefghijklmnop123456" not in result


class TestIsRateLimitErrorExtended:
    """Extended tests for _is_rate_limit_error — direct status_code path."""

    def test_direct_status_code_attribute_429(self):
        """Exception with status_code=429 directly (not on .response)."""
        engine = ConcreteSearchEngine(programmatic_mode=True)

        class DirectStatusError(Exception):
            status_code = 429

        assert engine._is_rate_limit_error(DirectStatusError("error")) is True

    def test_direct_status_code_attribute_500(self):
        """Exception with status_code=500 returns False."""
        engine = ConcreteSearchEngine(programmatic_mode=True)

        class DirectStatusError(Exception):
            status_code = 500

        assert engine._is_rate_limit_error(DirectStatusError("error")) is False

    def test_status_code_none(self):
        """Exception with status_code=None doesn't crash."""
        engine = ConcreteSearchEngine(programmatic_mode=True)

        class NoneStatusError(Exception):
            status_code = None

        assert engine._is_rate_limit_error(NoneStatusError("error")) is False


class TestResolveApiKeyExtended:
    """Extended tests for _resolve_api_key — placeholder cascade."""

    def test_settings_returns_placeholder_raises_error(self):
        """Settings returning a placeholder falls through to ValueError."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "YOUR_API_KEY_HERE"},
            programmatic_mode=True,
        )
        with pytest.raises(ValueError) as exc_info:
            engine._resolve_api_key(
                api_key=None,
                setting_key="search.api_key",
                engine_name="Test Engine",
            )
        assert "No valid API key found" in str(exc_info.value)

    def test_both_direct_and_settings_are_placeholders(self):
        """Both sources having placeholders raises ValueError."""
        engine = ConcreteSearchEngine(
            settings_snapshot={"search.api_key": "YOUR_API_KEY_HERE"},
            programmatic_mode=True,
        )
        with pytest.raises(ValueError):
            engine._resolve_api_key(
                api_key="PLACEHOLDER",
                setting_key="search.api_key",
                engine_name="Test Engine",
            )


class TestGetFullContentExtended:
    """Extended tests for _get_full_content — mutation safety and edge cases."""

    def test_does_not_mutate_input_dicts(self):
        """Input dicts remain unchanged after _get_full_content."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [
            {
                "title": "Preview",
                "_full_result": {"title": "Full", "content": "Content"},
            }
        ]
        original = copy.deepcopy(items)
        engine._get_full_content(items)
        assert items == original

    def test_full_result_is_none(self):
        """Item with _full_result=None falls back to the item itself."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [{"_full_result": None, "title": "test"}]
        result = engine._get_full_content(items)
        assert len(result) == 1
        assert result[0]["title"] == "test"
        assert "_full_result" not in result[0]

    def test_full_result_is_empty_dict(self):
        """Item with _full_result={} returns empty dict, does not fall back to item."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [{"_full_result": {}, "title": "test"}]
        result = engine._get_full_content(items)
        assert len(result) == 1
        assert result[0] == {}

    def test_conflicting_keys_full_result_wins(self):
        """When _full_result has same key as item, _full_result's value is used."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        items = [
            {
                "title": "outer",
                "_full_result": {
                    "title": "inner",
                    "url": "https://example.com",
                },
            }
        ]
        result = engine._get_full_content(items)
        assert result[0]["title"] == "inner"
        assert result[0]["url"] == "https://example.com"


class TestRunIntegration:
    """Integration tests for BaseSearchEngine.run() method."""

    def test_search_snippets_only_skips_get_full_content(self):
        """When search_snippets_only=True, _get_full_content is not called."""
        engine = ConcreteSearchEngine(
            search_snippets_only=True,
            programmatic_mode=True,
        )
        previews = [{"title": "Result 1", "snippet": "..."}]
        with patch.object(engine, "_get_previews", return_value=previews):
            with patch.object(engine, "_get_full_content") as mock_full:
                result = engine.run("test query")
                mock_full.assert_not_called()
        assert result == previews

    def test_search_snippets_only_false_calls_get_full_content(self):
        """When search_snippets_only=False, _get_full_content IS called."""
        engine = ConcreteSearchEngine(
            search_snippets_only=False,
            programmatic_mode=True,
        )
        previews = [{"title": "Result 1", "snippet": "..."}]
        full_content = [{"title": "Result 1", "content": "Full text"}]
        with patch.object(engine, "_get_previews", return_value=previews):
            with patch.object(
                engine, "_get_full_content", return_value=full_content
            ) as mock_full:
                result = engine.run("test query")
                mock_full.assert_called_once_with(previews)
        assert result == full_content

    def test_rate_limit_in_get_previews_returns_empty(self):
        """RateLimitError in _get_previews returns empty list."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        with patch.object(
            engine, "_get_previews", side_effect=RateLimitError("rate limited")
        ):
            result = engine.run("test query")
        assert result == []

    def test_non_rate_limit_exception_returns_empty(self):
        """Non-rate-limit exception in _get_previews returns empty list."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        with patch.object(
            engine, "_get_previews", side_effect=ConnectionError("failed")
        ):
            result = engine.run("test query")
        assert result == []
