"""
Comprehensive tests for GuardianSearchEngine focusing on pure logic coverage.

Covers edge cases and deeper logic in:
- _optimize_query_for_guardian() - LLM query optimization with various LLM responses
- _adapt_dates_for_query_type() - Date range adaptation for historical/current/unclear
- _get_previews() - Preview formatting and metadata storage
- _get_full_content() - Cache hit/miss paths
- _get_all_data() - API parameter construction, truncation, tag extraction
- Initialization edge cases
"""

from datetime import datetime, timedelta, UTC
from unittest.mock import Mock, patch

import pytest

from local_deep_research.web_search_engines.engines.search_engine_guardian import (
    GuardianSearchEngine,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_engine(**kwargs):
    """Create a GuardianSearchEngine with sensible defaults for testing."""
    defaults = {"api_key": "test-key"}
    defaults.update(kwargs)
    return GuardianSearchEngine(**defaults)


def _mock_llm(content: str):
    """Return a mock LLM whose invoke() returns a response with given content."""
    llm = Mock()
    llm.invoke.return_value = Mock(content=content)
    return llm


def _guardian_api_response(results):
    """Build a mock response object mimicking the Guardian API JSON shape."""
    resp = Mock()
    resp.json.return_value = {"response": {"results": results}}
    resp.raise_for_status = Mock()
    return resp


def _sample_article(
    article_id="art-1",
    headline="Headline One",
    trail="Trail text",
    byline="Author A",
    body="<p>Body</p>",
    url="https://theguardian.com/art-1",
    pub_date="2025-03-01T12:00:00Z",
    section="World news",
    tags=None,
):
    """Return a single raw Guardian API article dict."""
    return {
        "id": article_id,
        "webTitle": headline,
        "webUrl": url,
        "webPublicationDate": pub_date,
        "sectionName": section,
        "fields": {
            "headline": headline,
            "trailText": trail,
            "byline": byline,
            "body": body,
        },
        "tags": tags or [],
    }


# ===================================================================
# _optimize_query_for_guardian
# ===================================================================


class TestOptimizeQueryEdgeCases:
    def test_long_query_truncated_before_llm(self):
        """Queries >150 chars are truncated to first 10 words before LLM call."""
        llm = _mock_llm("short result")
        engine = _make_engine(llm=llm)

        long_query = " ".join(f"word{i}" for i in range(30))
        assert len(long_query) > 150

        result = engine._optimize_query_for_guardian(long_query)
        # LLM was called with the truncated form (10 words)
        prompt_arg = llm.invoke.call_args[0][0]
        # The prompt should contain only the first 10 words of the original query
        truncated = " ".join(long_query.split()[:10])
        assert truncated in prompt_arg
        assert result == "short result"

    def test_llm_returns_think_tags_stripped(self):
        """<think> tags in LLM output are removed."""
        llm = _mock_llm("<think>reasoning here</think>UK economy")
        engine = _make_engine(llm=llm)

        result = engine._optimize_query_for_guardian("UK economy outlook")
        assert result == "UK economy"

    def test_llm_returns_multiline_picks_first_good_line(self):
        """If LLM returns multiple lines, first non-preamble line is chosen."""
        llm = _mock_llm("Here is the query:\nclimate change policy")
        engine = _make_engine(llm=llm)

        result = engine._optimize_query_for_guardian("climate change")
        assert result == "climate change policy"

    def test_llm_returns_multiline_all_preamble_uses_last(self):
        """If every line is a preamble, the first line is still kept (loop finds nothing better)."""
        llm = _mock_llm("Here is the best query\nI would suggest this")
        engine = _make_engine(llm=llm)

        result = engine._optimize_query_for_guardian("test")
        # The loop doesn't find a good line, so optimized_query remains the full text
        # (the loop doesn't update optimized_query if no good line found)
        assert (
            "Here is the best query" in result
            or "I would suggest this" in result
        )

    def test_llm_returns_quoted_string_unquoted(self):
        """Quotes wrapping the entire response are stripped."""
        llm = _mock_llm('"UK housing rates"')
        engine = _make_engine(llm=llm)

        result = engine._optimize_query_for_guardian("housing in UK")
        assert result == "UK housing rates"

    def test_llm_returns_partial_quotes_not_stripped(self):
        """Quotes that don't wrap the full string are kept."""
        llm = _mock_llm('"UK" housing "rates"')
        engine = _make_engine(llm=llm)

        result = engine._optimize_query_for_guardian("housing in UK")
        # count of quotes != 2, so no stripping
        assert result == '"UK" housing "rates"'

    def test_llm_exception_falls_back_to_original(self):
        """If LLM raises an exception, original query is returned."""
        llm = Mock()
        llm.invoke.side_effect = RuntimeError("LLM down")
        engine = _make_engine(llm=llm)

        result = engine._optimize_query_for_guardian("original query")
        assert result == "original query"

    def test_no_llm_returns_original(self):
        """Without LLM, query returned as-is."""
        engine = _make_engine()
        assert engine._optimize_query_for_guardian("my query") == "my query"

    def test_optimize_disabled_returns_original(self):
        """With optimize_queries=False, query returned as-is even with LLM."""
        llm = _mock_llm("optimized")
        engine = _make_engine(llm=llm, optimize_queries=False)
        assert engine._optimize_query_for_guardian("my query") == "my query"
        llm.invoke.assert_not_called()

    def test_long_query_no_llm_still_truncated(self):
        """Long query is truncated even without LLM."""
        engine = _make_engine()
        long_query = " ".join(["longword"] * 30)
        result = engine._optimize_query_for_guardian(long_query)
        assert len(result.split()) == 10

    def test_llm_returns_empty_lines_then_content(self):
        """Empty lines before content are skipped."""
        llm = _mock_llm("\n\n\nclimate action\n")
        engine = _make_engine(llm=llm)
        result = engine._optimize_query_for_guardian("climate")
        assert result == "climate action"

    def test_llm_returns_line_starting_with_this_query(self):
        """Lines starting with 'this query' are skipped as preamble."""
        llm = _mock_llm("This query should be:\nrenewable energy")
        engine = _make_engine(llm=llm)
        result = engine._optimize_query_for_guardian("green energy")
        assert result == "renewable energy"

    def test_llm_returns_line_starting_with_the_best(self):
        """Lines starting with 'the best' are skipped."""
        llm = _mock_llm("The best search term is:\nAI regulation")
        engine = _make_engine(llm=llm)
        result = engine._optimize_query_for_guardian("AI laws")
        assert result == "AI regulation"


# ===================================================================
# _adapt_dates_for_query_type
# ===================================================================


class TestAdaptDatesForQueryType:
    def test_short_query_sets_60_days_and_newest(self):
        """Queries with <= 4 words get 60-day window and newest ordering."""
        engine = _make_engine()
        engine.order_by = "relevance"

        engine._adapt_dates_for_query_type("UK politics")

        expected_from = (datetime.now(UTC) - timedelta(days=60)).strftime(
            "%Y-%m-%d"
        )
        assert engine.from_date == expected_from
        assert engine.order_by == "newest"

    def test_short_query_four_words_is_short(self):
        """Exactly 4 words triggers the short-query fast path."""
        engine = _make_engine()
        engine._adapt_dates_for_query_type("one two three four")
        assert engine.order_by == "newest"

    def test_five_words_not_short_path(self):
        """5 words does NOT trigger the short-query fast path (no LLM, no change)."""
        engine = _make_engine()
        original_order = engine.order_by
        original_from = engine.from_date
        engine._adapt_dates_for_query_type("one two three four five")
        # No LLM and not short => nothing changes
        assert engine.order_by == original_order
        assert engine.from_date == original_from

    def test_historical_query_extends_to_10_years(self):
        """LLM saying HISTORICAL extends the search to ~10 years back."""
        llm = _mock_llm("HISTORICAL")
        engine = _make_engine(
            llm=llm, from_date="2025-01-01", to_date="2025-03-01"
        )

        engine._adapt_dates_for_query_type(
            "what happened during the 2008 financial crisis in detail"
        )

        expected_from = (datetime.now(UTC) - timedelta(days=3650)).strftime(
            "%Y-%m-%d"
        )
        assert engine.from_date == expected_from
        # order_by should stay as default (relevance) for historical
        assert engine.order_by == "relevance"

    def test_current_query_sets_60_days_and_newest(self):
        """LLM saying CURRENT focuses on recent 60 days with newest ordering."""
        llm = _mock_llm("CURRENT")
        engine = _make_engine(
            llm=llm, from_date="2024-01-01", to_date="2025-03-01"
        )

        engine._adapt_dates_for_query_type(
            "latest developments in AI regulation across countries"
        )

        expected_from = (datetime.now(UTC) - timedelta(days=60)).strftime(
            "%Y-%m-%d"
        )
        assert engine.from_date == expected_from
        assert engine.order_by == "newest"

    def test_unclear_query_resets_to_original(self):
        """LLM saying UNCLEAR resets dates to original parameters."""
        llm = _mock_llm("UNCLEAR")
        engine = _make_engine(
            llm=llm, from_date="2024-06-01", to_date="2025-03-01"
        )
        original_from = engine.from_date

        engine._adapt_dates_for_query_type(
            "some ambiguous longer query about something or other"
        )

        # Neither HISTORICAL nor CURRENT matched, so dates reset to original
        assert engine.from_date == original_from

    def test_llm_exception_keeps_original_dates(self):
        """LLM exception leaves dates unchanged."""
        llm = Mock()
        llm.invoke.side_effect = RuntimeError("boom")
        engine = _make_engine(
            llm=llm, from_date="2024-06-01", to_date="2025-03-01"
        )

        engine._adapt_dates_for_query_type(
            "a longer query about a very specific topic area"
        )

        assert engine.from_date == "2024-06-01"
        assert engine.to_date == "2025-03-01"

    def test_adaptive_search_disabled_no_llm_call(self):
        """With adaptive_search=False and a long query, LLM is not called."""
        llm = _mock_llm("CURRENT")
        engine = _make_engine(llm=llm, adaptive_search=False)
        original_from = engine.from_date

        engine._adapt_dates_for_query_type(
            "a longer query about some specific topic area"
        )

        llm.invoke.assert_not_called()
        assert engine.from_date == original_from

    def test_historical_in_mixed_response(self):
        """LLM response containing 'HISTORICAL' somewhere triggers historical path."""
        llm = _mock_llm("<think>thinking</think>It is HISTORICAL event")
        engine = _make_engine(
            llm=llm, from_date="2025-01-01", to_date="2025-03-01"
        )

        engine._adapt_dates_for_query_type(
            "what happened during the fall of the Berlin wall exactly"
        )

        expected_from = (datetime.now(UTC) - timedelta(days=3650)).strftime(
            "%Y-%m-%d"
        )
        assert engine.from_date == expected_from


# ===================================================================
# _get_all_data - API parameter construction and formatting
# ===================================================================


class TestGetAllDataLogic:
    def test_query_over_100_chars_truncated(self):
        """Queries > 100 chars are truncated for the API."""
        long_q = "a" * 120
        resp = _guardian_api_response([])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ) as mock_get:
            engine = _make_engine()
            engine._get_all_data(long_q)
            sent_q = mock_get.call_args[1]["params"]["q"]
            assert len(sent_q) == 100

    def test_section_param_included_when_set(self):
        """Section filter is added to API params when configured."""
        resp = _guardian_api_response([])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ) as mock_get:
            engine = _make_engine(section="technology")
            engine._get_all_data("AI")
            params = mock_get.call_args[1]["params"]
            assert params["section"] == "technology"

    def test_section_param_absent_when_none(self):
        """Section filter is NOT in API params when not configured."""
        resp = _guardian_api_response([])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ) as mock_get:
            engine = _make_engine()
            engine._get_all_data("AI")
            params = mock_get.call_args[1]["params"]
            assert "section" not in params

    def test_page_size_capped_at_50(self):
        """Even if max_results > 50, page-size param is capped at 50."""
        resp = _guardian_api_response([])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ) as mock_get:
            engine = _make_engine(max_results=100)
            engine._get_all_data("test")
            params = mock_get.call_args[1]["params"]
            assert params["page-size"] == 50

    def test_max_results_none_defaults_to_10(self):
        """When max_results is None, page-size defaults to 10."""
        resp = _guardian_api_response([])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ) as mock_get:
            engine = _make_engine()
            engine.max_results = None
            engine._get_all_data("test")
            params = mock_get.call_args[1]["params"]
            assert params["page-size"] == 10

    def test_keyword_tags_extracted(self):
        """Keyword tags are extracted from article tags list."""
        article = _sample_article(
            tags=[
                {"type": "keyword", "webTitle": "Climate"},
                {"type": "keyword", "webTitle": "Policy"},
                {"type": "contributor", "webTitle": "John"},  # not keyword
            ]
        )
        resp = _guardian_api_response([article])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ):
            engine = _make_engine()
            articles = engine._get_all_data("climate")
            assert articles[0]["keywords"] == ["Climate", "Policy"]

    def test_fields_fallback_to_webtitle(self):
        """If fields.headline is missing, webTitle is used as title."""
        article = {
            "id": "x",
            "webTitle": "Fallback Title",
            "webUrl": "https://example.com",
            "webPublicationDate": "2025-01-01",
            "sectionName": "News",
            "fields": {},
            "tags": [],
        }
        resp = _guardian_api_response([article])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ):
            engine = _make_engine()
            articles = engine._get_all_data("test")
            assert articles[0]["title"] == "Fallback Title"

    def test_results_limited_to_max_results(self):
        """Only max_results articles are returned even if API gives more."""
        articles = [_sample_article(article_id=f"art-{i}") for i in range(10)]
        resp = _guardian_api_response(articles)

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ):
            engine = _make_engine(max_results=3)
            result = engine._get_all_data("test")
            assert len(result) == 3

    def test_empty_query_replaced_with_news(self):
        """Empty or whitespace query is replaced with 'news'."""
        resp = _guardian_api_response([])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ) as mock_get:
            engine = _make_engine()
            engine._get_all_data("   ")
            params = mock_get.call_args[1]["params"]
            assert params["q"] == "news"

    def test_article_all_fields_mapped(self):
        """Verify all expected fields are present in formatted output."""
        article = _sample_article()
        resp = _guardian_api_response([article])

        with patch(
            "local_deep_research.web_search_engines.engines.search_engine_guardian.safe_get",
            return_value=resp,
        ):
            engine = _make_engine()
            results = engine._get_all_data("test")
            r = results[0]
            assert set(r.keys()) == {
                "id",
                "title",
                "link",
                "snippet",
                "publication_date",
                "section",
                "author",
                "content",
                "full_content",
                "keywords",
            }


# ===================================================================
# _get_previews - integration of optimize + adapt + search
# ===================================================================


class TestGetPreviewsLogic:
    def _run_previews(self, engine, articles, query="test query"):
        """Helper to run _get_previews with mocked sub-methods."""
        with (
            patch.object(
                engine, "_optimize_query_for_guardian", return_value="opt"
            ) as mock_opt,
            patch.object(engine, "_adapt_dates_for_query_type") as mock_adapt,
            patch.object(
                engine, "_adaptive_search", return_value=(articles, "initial")
            ),
        ):
            previews = engine._get_previews(query)
            return previews, mock_opt, mock_adapt

    def test_previews_exclude_content_fields(self):
        """Previews should NOT include 'content' or 'full_content'."""
        engine = _make_engine()
        articles = [
            {
                "id": "a1",
                "title": "T",
                "link": "http://x",
                "snippet": "S",
                "publication_date": "2025-01-01",
                "section": "News",
                "author": "A",
                "content": "<p>body</p>",
                "full_content": "<p>body</p>",
                "keywords": ["k"],
            }
        ]
        previews, _, _ = self._run_previews(engine, articles)
        assert "content" not in previews[0]
        assert "full_content" not in previews[0]

    def test_previews_contain_expected_keys(self):
        """Preview dicts have exactly the expected keys."""
        engine = _make_engine()
        articles = [
            {
                "id": "a1",
                "title": "T",
                "link": "http://x",
                "snippet": "S",
                "publication_date": "2025-01-01",
                "section": "News",
                "author": "A",
                "content": "c",
                "full_content": "c",
                "keywords": [],
            }
        ]
        previews, _, _ = self._run_previews(engine, articles)
        assert set(previews[0].keys()) == {
            "id",
            "title",
            "link",
            "snippet",
            "publication_date",
            "section",
            "author",
            "keywords",
        }

    def test_search_metadata_stored(self):
        """_get_previews stores _search_metadata on the engine instance."""
        engine = _make_engine()
        articles = [
            {
                "id": "a1",
                "title": "T",
                "link": "http://x",
                "snippet": "S",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "content": "c",
                "full_content": "c",
                "keywords": [],
            }
        ]
        self._run_previews(engine, articles, query="my original")
        assert hasattr(engine, "_search_metadata")
        assert engine._search_metadata["original_query"] == "my original"
        assert engine._search_metadata["optimized_query"] == "opt"
        assert engine._search_metadata["strategy"] == "initial"

    def test_full_articles_cache_populated(self):
        """_get_previews populates _full_articles cache keyed by article id."""
        engine = _make_engine()
        articles = [
            {
                "id": "a1",
                "title": "T1",
                "link": "l",
                "snippet": "s",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "content": "body1",
                "full_content": "body1",
                "keywords": [],
            },
            {
                "id": "a2",
                "title": "T2",
                "link": "l",
                "snippet": "s",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "content": "body2",
                "full_content": "body2",
                "keywords": [],
            },
        ]
        self._run_previews(engine, articles)
        assert "a1" in engine._full_articles
        assert "a2" in engine._full_articles
        assert engine._full_articles["a1"]["content"] == "body1"

    def test_empty_articles_returns_empty_previews(self):
        """No articles produces empty previews list."""
        engine = _make_engine()
        previews, _, _ = self._run_previews(engine, [])
        assert previews == []

    def test_optimize_called_with_original_query(self):
        """_optimize_query_for_guardian is called with the raw query."""
        engine = _make_engine()
        _, mock_opt, _ = self._run_previews(engine, [], query="raw question?")
        mock_opt.assert_called_once_with("raw question?")

    def test_adapt_called_with_optimized_query(self):
        """_adapt_dates_for_query_type is called with the optimized query."""
        engine = _make_engine()
        _, _, mock_adapt = self._run_previews(engine, [], query="raw")
        mock_adapt.assert_called_once_with("opt")


# ===================================================================
# _get_full_content - cache logic
# ===================================================================


class TestGetFullContentLogic:
    def test_multiple_articles_from_cache(self):
        """Multiple articles are looked up from the cache correctly."""
        engine = _make_engine()
        engine._full_articles = {
            "a1": {"id": "a1", "content": "body1"},
            "a2": {"id": "a2", "content": "body2"},
        }
        items = [{"id": "a1"}, {"id": "a2"}]
        results = engine._get_full_content(items)
        assert results[0]["content"] == "body1"
        assert results[1]["content"] == "body2"

    def test_missing_cache_returns_preview(self):
        """If _full_articles does not exist, preview item returned."""
        engine = _make_engine()
        # No _full_articles attribute at all
        items = [{"id": "a1", "title": "Preview"}]
        results = engine._get_full_content(items)
        assert results[0]["title"] == "Preview"

    def test_partial_cache_miss(self):
        """Mix of cached and uncached items handled correctly."""
        engine = _make_engine()
        engine._full_articles = {
            "a1": {"id": "a1", "content": "cached"},
        }
        items = [{"id": "a1"}, {"id": "a2", "title": "Uncached"}]
        results = engine._get_full_content(items)
        assert results[0]["content"] == "cached"
        assert results[1]["title"] == "Uncached"

    def test_empty_items_returns_empty(self):
        """Empty input returns empty output."""
        engine = _make_engine()
        engine._full_articles = {}
        assert engine._get_full_content([]) == []

    def test_item_without_id_key(self):
        """Item missing 'id' key still returns the preview item."""
        engine = _make_engine()
        engine._full_articles = {"a1": {"id": "a1"}}
        items = [{"title": "No ID"}]
        results = engine._get_full_content(items)
        assert results[0]["title"] == "No ID"


# ===================================================================
# Initialization edge cases
# ===================================================================


class TestInitEdgeCases:
    def test_is_public_class_attribute(self):
        assert GuardianSearchEngine.is_public is True

    def test_api_url_set(self):
        engine = _make_engine()
        assert engine.api_url == "https://content.guardianapis.com/search"

    def test_default_section_is_none(self):
        engine = _make_engine()
        assert engine.section is None

    def test_optimize_queries_default_true(self):
        engine = _make_engine()
        assert engine.optimize_queries is True

    def test_adaptive_search_default_true(self):
        engine = _make_engine()
        assert engine.adaptive_search is True

    def test_original_date_params_match_instance(self):
        engine = _make_engine(from_date="2020-01-01", to_date="2020-12-31")
        assert engine._original_date_params == {
            "from_date": "2020-01-01",
            "to_date": "2020-12-31",
        }

    def test_no_api_key_raises_value_error(self):
        with pytest.raises(
            ValueError, match="No valid API key found for Guardian"
        ):
            GuardianSearchEngine()

    def test_settings_snapshot_provides_api_key(self):
        engine = GuardianSearchEngine(
            settings_snapshot={
                "search.engine.web.guardian.api_key": "from-settings"
            }
        )
        assert engine.api_key == "from-settings"

    def test_kwargs_forwarded_without_error(self):
        """Extra kwargs don't cause errors."""
        engine = _make_engine(some_extra_param="value")
        assert engine.api_key == "test-key"


# ===================================================================
# _adaptive_search - strategy escalation
# ===================================================================


class TestAdaptiveSearch:
    def test_initial_strategy_when_enough_results(self):
        """When initial search returns >= 3, no further strategies tried."""
        engine = _make_engine()

        with patch.object(
            engine,
            "_get_all_data",
            return_value=[{"id": f"a{i}"} for i in range(5)],
        ) as mock_get:
            result, strategy = engine._adaptive_search("test")
            assert strategy == "initial"
            assert mock_get.call_count == 1

    def test_expands_to_6_months_on_few_results(self):
        """With < 3 initial results and adaptive on, tries 6-month expansion."""
        engine = _make_engine()

        call_count = [0]

        def side_effect(query):
            call_count[0] += 1
            if call_count[0] == 1:
                return [{"id": "a1"}]  # initial: only 1
            if call_count[0] == 2:
                return [{"id": f"a{i}"} for i in range(5)]  # 6-month: 5
            return []

        with patch.object(engine, "_get_all_data", side_effect=side_effect):
            result, strategy = engine._adaptive_search("test")
            assert strategy == "expanded_6mo"
            assert len(result) == 5

    def test_all_time_relevance_strategy(self):
        """Falls through to all-time relevance when 6-month also < 3."""
        engine = _make_engine()

        call_count = [0]

        def side_effect(query):
            call_count[0] += 1
            if call_count[0] <= 2:
                return [{"id": "a1"}]  # initial and 6mo: only 1
            if call_count[0] == 3:
                return [{"id": f"a{i}"} for i in range(4)]  # all-time: 4
            return []

        with patch.object(engine, "_get_all_data", side_effect=side_effect):
            result, strategy = engine._adaptive_search("test")
            assert strategy == "all_time_relevance"

    def test_section_removal_strategy(self):
        """Falls through to removing section constraint when all else < 3."""
        engine = _make_engine(section="politics")

        call_count = [0]

        def side_effect(query):
            call_count[0] += 1
            if call_count[0] <= 3:
                return [{"id": "a1"}]  # all prior strategies: 1
            if call_count[0] == 4:
                return [{"id": f"a{i}"} for i in range(5)]  # no section: 5
            return []

        with patch.object(engine, "_get_all_data", side_effect=side_effect):
            result, strategy = engine._adaptive_search("test")
            assert strategy == "no_section"
            # Section should be restored
            assert engine.section == "politics"

    def test_adaptive_disabled_no_escalation(self):
        """With adaptive_search=False, only initial call made."""
        engine = _make_engine(adaptive_search=False)

        with patch.object(
            engine, "_get_all_data", return_value=[{"id": "a1"}]
        ) as mock:
            result, strategy = engine._adaptive_search("test")
            assert strategy == "initial"
            assert mock.call_count == 1

    def test_original_settings_restored_after_adaptation(self):
        """from_date and order_by are restored after adaptive search."""
        engine = _make_engine(from_date="2025-01-01")
        original_from = engine.from_date
        original_order = engine.order_by

        with patch.object(engine, "_get_all_data", return_value=[{"id": "a1"}]):
            engine._adaptive_search("test")

        assert engine.from_date == original_from
        assert engine.order_by == original_order


# ===================================================================
# run() method
# ===================================================================


class TestRunMethod:
    def test_none_query_converted_to_news(self):
        """None query is converted to 'news'."""
        engine = _make_engine()
        with patch.object(
            engine, "_get_previews", return_value=[]
        ) as mock_prev:
            engine.run(None)
            # First call uses "news" (the converted query)
            first_call_arg = mock_prev.call_args_list[0][0][0]
            assert first_call_arg == "news"

    def test_source_field_added(self):
        """Results get 'source': 'The Guardian' added."""
        engine = _make_engine()
        previews = [
            {
                "id": "a1",
                "title": "T",
                "link": "l",
                "snippet": "s",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "keywords": [],
            }
        ]
        full = [{"id": "a1", "title": "T", "content": "c"}]

        with (
            patch.object(engine, "_get_previews", return_value=previews),
            patch.object(engine, "_get_full_content", return_value=full),
        ):
            results = engine.run("test")
            assert results[0]["source"] == "The Guardian"

    def test_existing_source_not_overwritten(self):
        """If result already has 'source', it's not overwritten."""
        engine = _make_engine()
        previews = [
            {
                "id": "a1",
                "title": "T",
                "link": "l",
                "snippet": "s",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "keywords": [],
            }
        ]
        full = [{"id": "a1", "title": "T", "source": "Custom Source"}]

        with (
            patch.object(engine, "_get_previews", return_value=previews),
            patch.object(engine, "_get_full_content", return_value=full),
        ):
            results = engine.run("test")
            assert results[0]["source"] == "Custom Source"

    def test_dates_restored_after_run(self):
        """Original date params restored after a successful run."""
        engine = _make_engine(from_date="2024-01-01", to_date="2024-12-31")
        previews = [
            {
                "id": "a1",
                "title": "T",
                "link": "l",
                "snippet": "s",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "keywords": [],
            }
        ]
        with (
            patch.object(engine, "_get_previews", return_value=previews),
            patch.object(
                engine, "_get_full_content", return_value=[{"id": "a1"}]
            ),
        ):
            engine.run("test")

        assert engine.from_date == "2024-01-01"
        assert engine.to_date == "2024-12-31"

    def test_dates_restored_after_exception(self):
        """Original date params restored even after an exception."""
        engine = _make_engine(from_date="2024-01-01", to_date="2024-12-31")

        with patch.object(
            engine, "_get_previews", side_effect=RuntimeError("fail")
        ):
            results = engine.run("test")

        assert results == []
        assert engine.from_date == "2024-01-01"
        assert engine.to_date == "2024-12-31"

    def test_cache_cleaned_up_after_run(self):
        """_full_articles and _search_metadata are cleaned up."""
        engine = _make_engine()
        previews = [
            {
                "id": "a1",
                "title": "T",
                "link": "l",
                "snippet": "s",
                "publication_date": "d",
                "section": "s",
                "author": "a",
                "keywords": [],
            }
        ]
        with (
            patch.object(engine, "_get_previews", return_value=previews),
            patch.object(
                engine, "_get_full_content", return_value=[{"id": "a1"}]
            ),
        ):
            # Simulate _get_previews having set these
            engine._full_articles = {"a1": {"id": "a1"}}
            engine._search_metadata = {"original_query": "test"}
            engine.run("test")

        assert not hasattr(engine, "_full_articles")
        assert not hasattr(engine, "_search_metadata")

    def test_rate_limit_error_reraised(self):
        """RateLimitError from _get_previews is propagated."""
        from local_deep_research.web_search_engines.rate_limiting import (
            RateLimitError,
        )

        engine = _make_engine()
        with patch.object(
            engine, "_get_previews", side_effect=RateLimitError("limit")
        ):
            with pytest.raises(RateLimitError):
                engine.run("test")
