"""
Tests for BaseSearchEngine.
"""

import json
from pathlib import Path
from unittest.mock import Mock

from local_deep_research.config.constants import DEFAULT_MAX_FILTERED_RESULTS
from local_deep_research.web_search_engines.search_engine_base import (
    BaseSearchEngine,
    AdaptiveWait,
)
from local_deep_research.web_search_engines.rate_limiting import (
    RateLimitError,
)


def test_default_max_filtered_results_matches_default_settings_json():
    """``DEFAULT_MAX_FILTERED_RESULTS`` (the Python fallback used when no
    DB is available) must match the shipping value in
    ``defaults/default_settings.json`` — otherwise a user with a seeded
    DB and a programmatic caller without one see different filter caps."""
    defaults_path = (
        Path(__file__).resolve().parents[2]
        / "src"
        / "local_deep_research"
        / "defaults"
        / "default_settings.json"
    )
    settings = json.loads(defaults_path.read_text())
    assert (
        settings["search.max_filtered_results"]["value"]
        == DEFAULT_MAX_FILTERED_RESULTS
    )


def _make_structured_llm(indices):
    """Create a mock LLM whose ``invoke()`` returns the given indices as
    a comma-separated text response (matches the relevance_filter contract)."""
    mock_llm = Mock()
    mock_llm.invoke.return_value = ", ".join(str(i) for i in indices)
    return mock_llm


def _make_error_llm(exc):
    """Create a mock LLM where ``invoke()`` raises."""
    mock_llm = Mock()
    mock_llm.invoke.side_effect = exc
    return mock_llm


class TestBaseSearchEngineClassAttributes:
    """Tests for BaseSearchEngine class attributes."""

    def test_is_public_default_false(self):
        """is_public defaults to False for safety."""
        assert BaseSearchEngine.is_public is False

    def test_is_generic_default_false(self):
        """is_generic defaults to False."""
        assert BaseSearchEngine.is_generic is False

    def test_is_scientific_default_false(self):
        """is_scientific defaults to False."""
        assert BaseSearchEngine.is_scientific is False

    def test_is_local_default_false(self):
        """is_local defaults to False."""
        assert BaseSearchEngine.is_local is False

    def test_is_news_default_false(self):
        """is_news defaults to False."""
        assert BaseSearchEngine.is_news is False

    def test_is_code_default_false(self):
        """is_code defaults to False."""
        assert BaseSearchEngine.is_code is False


class TestLoadEngineClass:
    """Tests for _load_engine_class method."""

    def test_load_engine_class_missing_module_path(self):
        """Returns error when module_path is missing."""
        config = {"class_name": "TestEngine"}
        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "test", config
        )
        assert success is False
        assert engine_class is None
        assert "module_path" in error

    def test_load_engine_class_missing_class_name(self):
        """Returns error when class_name is missing."""
        config = {"module_path": ".engines.test"}
        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "test", config
        )
        assert success is False
        assert engine_class is None
        assert "class_name" in error

    def test_load_engine_class_import_error(self):
        """Returns error when import fails."""
        config = {
            "module_path": ".engines.nonexistent_module",
            "class_name": "NonexistentEngine",
        }
        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "test", config
        )
        assert success is False
        assert engine_class is None
        assert "Could not load" in error or "Security error" in error

    def test_load_engine_class_success(self):
        """Successfully loads engine class."""
        config = {
            "module_path": ".engines.search_engine_wikipedia",
            "class_name": "WikipediaSearchEngine",
        }
        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "wikipedia", config
        )
        assert success is True
        assert engine_class is not None
        assert error is None


class TestCheckApiKeyAvailability:
    """Tests for _check_api_key_availability method."""

    def test_api_key_not_required(self):
        """Returns True when API key not required."""
        config = {"requires_api_key": False}
        result = BaseSearchEngine._check_api_key_availability("test", config)
        assert result is True

    def test_api_key_required_and_provided(self):
        """Returns True when API key required and provided."""
        config = {
            "requires_api_key": True,
            "api_key": "valid-api-key-12345",
        }
        result = BaseSearchEngine._check_api_key_availability("test", config)
        assert result is True

    def test_api_key_required_but_empty(self):
        """Returns False when API key required but empty."""
        config = {
            "requires_api_key": True,
            "api_key": "",
        }
        result = BaseSearchEngine._check_api_key_availability("test", config)
        assert result is False

    def test_api_key_required_but_none(self):
        """Returns False when API key required but None."""
        config = {
            "requires_api_key": True,
            "api_key": "None",
        }
        result = BaseSearchEngine._check_api_key_availability("test", config)
        assert result is False

    def test_api_key_placeholder_rejected(self):
        """Returns False for placeholder API keys."""
        placeholders = [
            "PLACEHOLDER",
            "YOUR_API_KEY_HERE",
            "BRAVE_API_KEY",
            "YOUR_GOOGLE_API_KEY",
            "null",
        ]

        for placeholder in placeholders:
            config = {
                "requires_api_key": True,
                "api_key": placeholder,
            }
            result = BaseSearchEngine._check_api_key_availability(
                "test", config
            )
            assert result is False, f"Should reject placeholder: {placeholder}"


class TestBaseSearchEngineSubclassing:
    """Tests for subclassing BaseSearchEngine."""

    def test_subclass_can_override_attributes(self):
        """Subclass can override class attributes."""

        class PublicSearchEngine(BaseSearchEngine):
            is_public = True
            is_generic = True

            def run(self, query):
                return []

        assert PublicSearchEngine.is_public is True
        assert PublicSearchEngine.is_generic is True

    def test_subclass_scientific_engine(self):
        """Subclass can be marked as scientific."""

        class ScientificEngine(BaseSearchEngine):
            is_scientific = True
            is_public = True

            def run(self, query):
                return []

        assert ScientificEngine.is_scientific is True
        assert ScientificEngine.is_public is True

    def test_subclass_local_engine(self):
        """Subclass can be marked as local."""

        class LocalEngine(BaseSearchEngine):
            is_local = True
            is_public = False

            def run(self, query):
                return []

        assert LocalEngine.is_local is True
        assert LocalEngine.is_public is False

    def test_subclass_news_engine(self):
        """Subclass can be marked as news engine."""

        class NewsEngine(BaseSearchEngine):
            is_news = True
            is_public = True

            def run(self, query):
                return []

        assert NewsEngine.is_news is True

    def test_subclass_code_engine(self):
        """Subclass can be marked as code engine."""

        class CodeEngine(BaseSearchEngine):
            is_code = True
            is_public = True

            def run(self, query):
                return []

        assert CodeEngine.is_code is True


class TestSearchResultValidation:
    """Tests for search result validation."""

    def test_result_structure(self, mock_search_results):
        """Search results have expected structure."""
        for result in mock_search_results:
            assert "title" in result
            assert "link" in result
            assert "snippet" in result
            assert "source" in result

    def test_result_link_is_url(self, mock_search_results):
        """Result links are valid URLs."""
        for result in mock_search_results:
            link = result["link"]
            assert link.startswith("http://") or link.startswith("https://")

    def test_result_title_not_empty(self, mock_search_results):
        """Result titles are not empty."""
        for result in mock_search_results:
            assert len(result["title"]) > 0


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

    is_public = True
    is_generic = True

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._mock_previews = []
        self._mock_full_content = []

    def _get_previews(self, query):
        return self._mock_previews

    def _get_full_content(self, relevant_items):
        return self._mock_full_content or relevant_items


class TestAdaptiveWait:
    """Tests for AdaptiveWait class."""

    def test_adaptive_wait_calls_function(self):
        """AdaptiveWait calls the provided function."""
        mock_func = Mock(return_value=2.5)
        wait = AdaptiveWait(mock_func)
        mock_retry_state = Mock()

        result = wait(mock_retry_state)

        assert result == 2.5
        mock_func.assert_called_once()

    def test_adaptive_wait_with_different_values(self):
        """AdaptiveWait returns different values based on function."""
        call_count = [0]

        def variable_wait():
            call_count[0] += 1
            return call_count[0] * 1.0

        wait = AdaptiveWait(variable_wait)
        mock_retry_state = Mock()

        assert wait(mock_retry_state) == 1.0
        assert wait(mock_retry_state) == 2.0
        assert wait(mock_retry_state) == 3.0


class TestBaseSearchEngineInit:
    """Tests for BaseSearchEngine initialization."""

    def test_init_default_values(self):
        """Test initialization with default values."""
        engine = ConcreteSearchEngine(programmatic_mode=True)

        assert engine.max_results == 10
        assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS
        assert engine.llm is None
        assert engine.search_snippets_only is True
        assert engine.programmatic_mode is True
        assert engine._preview_filters == []
        assert engine._content_filters == []

    def test_init_custom_values(self):
        """Test initialization with custom values."""
        mock_llm = Mock()
        engine = ConcreteSearchEngine(
            llm=mock_llm,
            max_results=20,
            max_filtered_results=10,
            search_snippets_only=False,
            programmatic_mode=True,
        )

        assert engine.max_results == 20
        assert engine.max_filtered_results == 10
        assert engine.llm is mock_llm
        assert engine.search_snippets_only is False

    def test_init_none_values_use_defaults(self):
        """Test that None values use defaults."""
        engine = ConcreteSearchEngine(
            max_results=None, max_filtered_results=None, programmatic_mode=True
        )

        assert engine.max_results == 10
        assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS

    def test_init_with_filters(self):
        """Test initialization with custom filters."""
        preview_filter = Mock()
        content_filter = Mock()
        engine = ConcreteSearchEngine(
            preview_filters=[preview_filter],
            content_filters=[content_filter],
            programmatic_mode=True,
        )

        assert len(engine._preview_filters) == 1
        assert len(engine._content_filters) == 1

    def test_init_with_settings_snapshot(self):
        """Test initialization with settings snapshot."""
        snapshot = {"key": "value"}
        engine = ConcreteSearchEngine(
            settings_snapshot=snapshot, programmatic_mode=True
        )

        assert engine.settings_snapshot == snapshot

    def test_init_engine_type_set(self):
        """Test that engine_type is set from class name."""
        engine = ConcreteSearchEngine(programmatic_mode=True)

        assert engine.engine_type == "ConcreteSearchEngine"


class TestMaxResultsProperty:
    """Tests for max_results property."""

    def test_max_results_getter(self):
        """Test max_results getter."""
        engine = ConcreteSearchEngine(max_results=15, programmatic_mode=True)
        assert engine.max_results == 15

    def test_max_results_setter(self):
        """Test max_results setter."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.max_results = 25
        assert engine.max_results == 25

    def test_max_results_setter_none_uses_default(self):
        """Test max_results setter with None uses default."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.max_results = None
        assert engine.max_results == 10

    def test_max_results_minimum_is_one(self):
        """Test max_results has minimum of 1."""
        engine = ConcreteSearchEngine(max_results=0, programmatic_mode=True)
        assert engine.max_results >= 1

        engine.max_results = -5
        assert engine.max_results >= 1


class TestMaxFilteredResultsProperty:
    """Tests for max_filtered_results property."""

    def test_max_filtered_results_getter(self):
        """Test max_filtered_results getter."""
        engine = ConcreteSearchEngine(
            max_filtered_results=8, programmatic_mode=True
        )
        assert engine.max_filtered_results == 8

    def test_max_filtered_results_setter(self):
        """Test max_filtered_results setter."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.max_filtered_results = 12
        assert engine.max_filtered_results == 12

    def test_max_filtered_results_setter_none_uses_default(self):
        """Test max_filtered_results setter with None uses default."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.max_filtered_results = None
        assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS


class TestRunMethod:
    """Tests for BaseSearchEngine.run method."""

    def test_run_returns_empty_list_on_no_previews(self):
        """Test run returns empty list when no previews found."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine._mock_previews = []

        results = engine.run("test query")

        assert results == []

    def test_run_returns_previews_with_snippets_only(self):
        """Test run returns previews when search_snippets_only is True."""
        engine = ConcreteSearchEngine(
            search_snippets_only=True, programmatic_mode=True
        )
        engine._mock_previews = [
            {
                "title": "Test",
                "snippet": "Content",
                "link": "https://example.com",
            }
        ]

        results = engine.run("test query")

        assert len(results) == 1
        assert results[0]["title"] == "Test"

    def test_run_applies_preview_filters(self):
        """Test run applies preview filters."""
        mock_filter = Mock()
        mock_filter.filter_results.return_value = [
            {"title": "Filtered", "snippet": "Content"}
        ]

        engine = ConcreteSearchEngine(
            preview_filters=[mock_filter], programmatic_mode=True
        )
        engine._mock_previews = [
            {"title": "Original", "snippet": "Content"},
            {"title": "Also Original", "snippet": "More content"},
        ]

        results = engine.run("test query")

        mock_filter.filter_results.assert_called_once()
        assert len(results) == 1
        assert results[0]["title"] == "Filtered"

    def test_run_applies_content_filters(self):
        """Test run applies content filters."""
        mock_filter = Mock()
        mock_filter.filter_results.return_value = [
            {"title": "Content Filtered", "content": "Full content"}
        ]

        engine = ConcreteSearchEngine(
            content_filters=[mock_filter],
            search_snippets_only=False,
            programmatic_mode=True,
        )
        engine._mock_previews = [{"title": "Original", "snippet": "Content"}]
        engine._mock_full_content = [
            {"title": "Original", "content": "Full content"}
        ]

        results = engine.run("test query")

        mock_filter.filter_results.assert_called_once()
        assert len(results) == 1
        assert results[0]["title"] == "Content Filtered"

    def test_run_gets_full_content_when_not_snippets_only(self):
        """Test run gets full content when search_snippets_only is False."""
        engine = ConcreteSearchEngine(
            search_snippets_only=False, programmatic_mode=True
        )
        engine._mock_previews = [{"title": "Preview", "snippet": "Short"}]
        engine._mock_full_content = [
            {"title": "Preview", "content": "Full content"}
        ]

        results = engine.run("test query")

        assert len(results) == 1
        assert results[0]["content"] == "Full content"

    def test_run_handles_exception_gracefully(self):
        """Test run handles exceptions gracefully."""

        class FailingEngine(ConcreteSearchEngine):
            def _get_previews(self, query):
                raise ValueError("Test error")

        engine = FailingEngine(programmatic_mode=True)

        results = engine.run("test query")

        assert results == []

    def test_run_with_research_context(self):
        """Test run with research context parameter."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine._mock_previews = [{"title": "Test", "snippet": "Content"}]

        context = {"research_id": 123}
        results = engine.run("test query", research_context=context)

        assert len(results) == 1


class TestInvokeMethod:
    """Tests for invoke method (LangChain compatibility)."""

    def test_invoke_calls_run(self):
        """Test invoke calls run method."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine._mock_previews = [{"title": "Test", "snippet": "Content"}]

        results = engine.invoke("test query")

        assert len(results) == 1
        assert results[0]["title"] == "Test"


class TestFilterForRelevance:
    """Tests for _filter_for_relevance method."""

    def test_filter_no_llm_returns_all(self):
        """Test that no LLM returns all previews."""
        engine = ConcreteSearchEngine(llm=None, programmatic_mode=True)
        previews = [
            {"title": "A", "snippet": "Content A"},
            {"title": "B", "snippet": "Content B"},
        ]

        result = engine._filter_for_relevance(previews, "query")

        assert len(result) == 2

    def test_filter_single_preview_returns_all(self):
        """Test that single preview returns as-is."""
        mock_llm = Mock()
        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        previews = [{"title": "A", "snippet": "Content A"}]

        result = engine._filter_for_relevance(previews, "query")

        assert len(result) == 1
        mock_llm.invoke.assert_not_called()

    def test_filter_with_valid_llm_response(self):
        """Test filtering with valid LLM response."""
        mock_llm = _make_structured_llm([0, 2])

        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        previews = [
            {"title": "A", "snippet": "Content A", "url": "https://a.com"},
            {"title": "B", "snippet": "Content B", "url": "https://b.com"},
            {"title": "C", "snippet": "Content C", "url": "https://c.com"},
        ]

        result = engine._filter_for_relevance(previews, "query")

        assert len(result) == 2
        assert result[0]["title"] == "A"
        assert result[1]["title"] == "C"

    def test_filter_with_out_of_range_indices(self):
        """Test filtering skips out-of-range indices."""
        mock_llm = _make_structured_llm([0, 10, 1])  # 10 is out of range

        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        previews = [
            {"title": "A", "snippet": "Content A", "url": "https://a.com"},
            {"title": "B", "snippet": "Content B", "url": "https://b.com"},
        ]

        result = engine._filter_for_relevance(previews, "query")

        assert len(result) == 2  # Only valid indices 0 and 1

    def test_filter_with_parse_error_returns_capped_fallback(self):
        """Parse errors return a capped slice of the previews so the
        downstream pipeline isn't fed empty results when the filter is
        unavailable. Cap is
        ``max_filtered_results or DEFAULT_MAX_FILTERED_RESULTS``."""
        mock_llm = _make_error_llm(
            ValueError("Failed to parse structured output")
        )

        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        previews = [
            {"title": "A", "snippet": "Content A", "url": "https://a.com"},
            {"title": "B", "snippet": "Content B", "url": "https://b.com"},
            {"title": "C", "snippet": "Content C", "url": "https://c.com"},
        ]

        result = engine._filter_for_relevance(previews, "query")

        # All 3 previews fit under DEFAULT_MAX_FILTERED_RESULTS
        assert result == previews

    def test_filter_limits_to_max_filtered_results(self):
        """Test filtering limits results to max_filtered_results."""
        mock_llm = _make_structured_llm([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

        engine = ConcreteSearchEngine(
            llm=mock_llm, max_filtered_results=3, programmatic_mode=True
        )
        previews = [
            {
                "title": str(i),
                "snippet": f"Content {i}",
                "url": f"https://{i}.com",
            }
            for i in range(10)
        ]

        result = engine._filter_for_relevance(previews, "query")

        assert len(result) == 3

    def test_filter_handles_llm_exception(self):
        """LLM exceptions return a capped slice of the previews so the
        downstream pipeline isn't fed empty results when the filter is
        unavailable. Cap is
        ``max_filtered_results or DEFAULT_MAX_FILTERED_RESULTS``."""
        mock_llm = _make_error_llm(Exception("LLM error"))

        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        previews = [
            {"title": "A", "snippet": "Content A", "url": "https://a.com"},
            {"title": "B", "snippet": "Content B", "url": "https://b.com"},
        ]

        result = engine._filter_for_relevance(previews, "query")

        # Both previews fit under DEFAULT_MAX_FILTERED_RESULTS
        assert result == previews

    def test_filter_truncates_long_snippets(self):
        """Filter must not fail on snippets longer than the internal
        snippet cap (``_SNIPPET_CHAR_CAP`` in ``relevance_filter.py``).
        The truncation happens inside the prompt builder, not in the
        returned preview, so we just assert the call succeeds and the
        cap doesn't bleed into the output."""
        mock_llm = _make_structured_llm([0])

        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        long_snippet = "x" * 2000  # Longer than the 800-char cap
        previews = [
            {"title": "A", "snippet": long_snippet, "url": "https://a.com"},
            {"title": "B", "snippet": "Short", "url": "https://b.com"},
        ]

        result = engine._filter_for_relevance(previews, "query")

        # Single index returned → one kept. Snippet in the returned
        # preview is NOT truncated — the cap only applies inside the
        # filter's prompt text.
        assert len(result) == 1
        assert result[0]["snippet"] == long_snippet


class TestRateLimiting:
    """Tests for rate limiting functionality."""

    def test_get_adaptive_wait(self):
        """Test _get_adaptive_wait returns tracker wait time."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.rate_tracker = Mock()
        engine.rate_tracker.get_wait_time.return_value = 1.5

        wait_time = engine._get_adaptive_wait()

        assert wait_time == 1.5
        assert engine._last_wait_time == 1.5
        engine.rate_tracker.get_wait_time.assert_called_once_with(
            "ConcreteSearchEngine"
        )

    def test_record_retry_outcome_success(self):
        """Test _record_retry_outcome records successful outcome."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.rate_tracker = Mock()
        engine._last_wait_time = 1.0
        engine._last_results_count = 5

        mock_outcome = Mock()
        mock_outcome.failed = False
        mock_retry_state = Mock()
        mock_retry_state.outcome = mock_outcome
        mock_retry_state.attempt_number = 1

        engine._record_retry_outcome(mock_retry_state)

        engine.rate_tracker.record_outcome.assert_called_once()
        # Check call was made with expected arguments
        call_args = engine.rate_tracker.record_outcome.call_args
        # Engine type is first positional arg
        assert call_args[0][0] == "ConcreteSearchEngine"
        # Wait time is second positional arg
        assert call_args[0][1] == 1.0

    def test_record_retry_outcome_failure(self):
        """Test _record_retry_outcome records failed outcome."""
        engine = ConcreteSearchEngine(programmatic_mode=True)
        engine.rate_tracker = Mock()
        engine._last_wait_time = 2.0

        mock_outcome = Mock()
        mock_outcome.failed = True
        mock_retry_state = Mock()
        mock_retry_state.outcome = mock_outcome
        mock_retry_state.attempt_number = 2

        engine._record_retry_outcome(mock_retry_state)

        engine.rate_tracker.record_outcome.assert_called_once()
        # Check call was made with expected arguments
        call_args = engine.rate_tracker.record_outcome.call_args
        # Engine type is first positional arg
        assert call_args[0][0] == "ConcreteSearchEngine"
        # Wait time is second positional arg
        assert call_args[0][1] == 2.0

    def test_run_with_rate_limit_error_disabled(self):
        """Test run handles RateLimitError when rate limiting disabled."""

        class RateLimitedEngine(ConcreteSearchEngine):
            def _get_previews(self, query):
                raise RateLimitError("Rate limited")

        engine = RateLimitedEngine(programmatic_mode=True)
        engine.rate_tracker.enabled = False

        results = engine.run("test query")

        assert results == []


class TestLLMRelevanceFilter:
    """Tests for LLM relevance filtering behavior."""

    def test_llm_filter_disabled_by_default(self):
        """Test LLM filter is disabled by default."""
        mock_llm = Mock()
        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        engine._mock_previews = [
            {"title": "A", "snippet": "Content"},
            {"title": "B", "snippet": "Content"},
        ]

        # Without enable_llm_relevance_filter set, should return all previews
        results = engine.run("query")

        mock_llm.invoke.assert_not_called()
        assert len(results) == 2

    def test_llm_filter_enabled(self):
        """Test LLM filter when enabled."""
        mock_llm = _make_structured_llm([0])

        engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True)
        engine.enable_llm_relevance_filter = True
        engine._mock_previews = [
            {"title": "A", "snippet": "Content A", "url": "https://a.com"},
            {"title": "B", "snippet": "Content B", "url": "https://b.com"},
        ]

        results = engine.run("query")

        mock_llm.invoke.assert_called_once()
        assert len(results) == 1
