"""
Coverage tests for SemanticScholarSearchEngine.

Targets uncovered lines: 91-92, 166-172, 180, 184-185, 234-235, 280-283,
318-340, 356-358, 391-471, 485-522, 603-604, 653-680.
"""

from unittest.mock import Mock, patch

import pytest
import requests

from local_deep_research.web_search_engines.engines.search_engine_semantic_scholar import (
    SemanticScholarSearchEngine,
)


@pytest.fixture
def engine():
    """Create engine with mocked session."""
    with patch.object(SemanticScholarSearchEngine, "_create_session"):
        eng = SemanticScholarSearchEngine()
        eng.session = Mock()
        yield eng


@pytest.fixture
def engine_with_llm():
    """Create engine with mocked LLM."""
    mock_llm = Mock()
    with patch.object(SemanticScholarSearchEngine, "_create_session"):
        eng = SemanticScholarSearchEngine(llm=mock_llm)
        eng.session = Mock()
        yield eng


# ─── Init: api_key from settings_snapshot exception path (lines 91-92) ───


class TestInitSettingsSnapshotException:
    def test_api_key_settings_exception_is_swallowed(self):
        """When get_setting_from_snapshot raises, api_key stays None."""
        with patch.object(SemanticScholarSearchEngine, "_create_session"):
            with patch(
                "local_deep_research.config.search_config.get_setting_from_snapshot",
                side_effect=Exception("boom"),
            ):
                eng = SemanticScholarSearchEngine(settings_snapshot={"k": "v"})
                assert eng.api_key is None


# ─── close / __del__ / context-manager (lines 166-172, 180, 184-185) ───


class TestCloseAndContextManager:
    def test_close_closes_session(self, engine):
        """close() calls session.close() and sets session to None."""
        engine.close()
        engine.session is None  # attribute cleared

    def test_close_when_no_session(self, engine):
        """close() is safe when session is already None."""
        engine.session = None
        engine.close()  # no error

    def test_close_handles_session_close_exception(self, engine):
        """close() logs but doesn't raise if session.close() fails."""
        engine.session.close.side_effect = Exception("close error")
        engine.close()
        assert engine.session is None

    def test_del_calls_close(self, engine):
        """__del__ invokes close()."""
        with patch.object(engine, "close") as mock_close:
            engine.__del__()
            mock_close.assert_called_once()

    def test_context_manager_enter(self, engine):
        """__enter__ returns self."""
        assert engine.__enter__() is engine

    def test_context_manager_exit(self, engine):
        """__exit__ calls close and returns False."""
        with patch.object(engine, "close") as mock_close:
            result = engine.__exit__(None, None, None)
            mock_close.assert_called_once()
            assert result is False


# ─── _make_request: RequestException path (lines 234-235) ───


class TestMakeRequestErrors:
    def test_make_request_request_exception_returns_empty(self, engine):
        """RequestException returns empty dict."""
        engine.session.get.side_effect = requests.RequestException("fail")
        result = engine._make_request("https://example.com")
        assert result == {}

    def test_make_request_connection_error_returns_empty(self, engine):
        """ConnectionError (subclass of RequestException) returns empty dict."""
        engine.session.get.side_effect = requests.ConnectionError("conn")
        result = engine._make_request("https://example.com")
        assert result == {}

    def test_make_request_post_with_params_and_data(self, engine):
        """POST passes params and json data."""
        mock_resp = Mock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"ok": True}
        engine.session.post.return_value = mock_resp
        result = engine._make_request(
            "https://example.com",
            params={"q": "test"},
            data={"ids": ["1"]},
            method="POST",
        )
        engine.session.post.assert_called_once_with(
            "https://example.com",
            params={"q": "test"},
            json={"ids": ["1"]},
            timeout=30,
        )
        assert result == {"ok": True}


# ─── _optimize_query: verbose result path (lines 280-283) ───


class TestOptimizeQueryVerbose:
    def test_optimize_query_too_many_words_returns_original(
        self, engine_with_llm
    ):
        """If optimized query has > 15 words, original is returned."""
        engine_with_llm.llm.invoke.return_value = Mock(
            content=" ".join(["word"] * 20)
        )
        result = engine_with_llm._optimize_query("short query")
        assert result == "short query"

    def test_optimize_query_contains_colon_returns_original(
        self, engine_with_llm
    ):
        """If optimized query contains ':', original is returned."""
        engine_with_llm.llm.invoke.return_value = Mock(
            content="Explanation: here is the query"
        )
        result = engine_with_llm._optimize_query("short query")
        assert result == "short query"

    def test_optimize_query_multiline_takes_first_line(self, engine_with_llm):
        """Multi-line response: only first line is used."""
        engine_with_llm.llm.invoke.return_value = Mock(
            content="good query\nextra line\nanother"
        )
        result = engine_with_llm._optimize_query("original")
        assert result == "good query"


# ─── _direct_search: tldr field, filters, exception (lines 318-340, 356-358) ───


class TestDirectSearchBranches:
    def test_direct_search_includes_tldr_field(self, engine):
        """When get_tldr=True, 'tldr' is in fields param."""
        engine.get_tldr = True
        with patch.object(
            engine, "_make_request", return_value={"data": []}
        ) as mr:
            engine._direct_search("test")
            params = mr.call_args[0][1]
            assert "tldr" in params["fields"]

    def test_direct_search_excludes_tldr_when_disabled(self, engine):
        """When get_tldr=False, 'tldr' is NOT in fields param."""
        engine.get_tldr = False
        with patch.object(
            engine, "_make_request", return_value={"data": []}
        ) as mr:
            engine._direct_search("test")
            params = mr.call_args[0][1]
            assert "tldr" not in params["fields"]

    def test_direct_search_fields_of_study_filter(self, engine):
        """fields_of_study adds fieldsOfStudy param."""
        engine.fields_of_study = ["Computer Science", "Medicine"]
        with patch.object(
            engine, "_make_request", return_value={"data": []}
        ) as mr:
            engine._direct_search("test")
            params = mr.call_args[0][1]
            assert params["fieldsOfStudy"] == "Computer Science,Medicine"

    def test_direct_search_publication_types_filter(self, engine):
        """publication_types adds publicationTypes param."""
        engine.publication_types = ["JournalArticle"]
        with patch.object(
            engine, "_make_request", return_value={"data": []}
        ) as mr:
            engine._direct_search("test")
            params = mr.call_args[0][1]
            assert params["publicationTypes"] == "JournalArticle"

    def test_direct_search_exception_returns_empty(self, engine):
        """Exception in _direct_search returns []."""
        with patch.object(
            engine, "_make_request", side_effect=Exception("boom")
        ):
            result = engine._direct_search("test")
            assert result == []

    def test_direct_search_limits_to_100(self, engine):
        """max_results > 100 is capped to 100 in API call."""
        engine.max_results = 200
        with patch.object(
            engine, "_make_request", return_value={"data": []}
        ) as mr:
            engine._direct_search("test")
            params = mr.call_args[0][1]
            assert params["limit"] == 100


# ─── _adaptive_search: LLM fallback, key_terms, single_term (lines 391-471) ───


class TestAdaptiveSearchFallbacks:
    def test_unquoted_fallback_no_results(self, engine):
        """Unquoted fallback returns no results -> continues to next strategy."""
        engine.llm = None
        # quoted query, direct_search always returns []
        with patch.object(engine, "_direct_search", return_value=[]):
            papers, strategy = engine._adaptive_search('"some query"')
            assert papers == []
            assert strategy == "standard"

    def test_llm_alternative_query_success(self, engine_with_llm):
        """LLM suggests alternative queries; first successful one is used."""
        mock_papers = [{"paperId": "1", "title": "Found"}]
        # 1st call: standard (empty), 2nd call: alt query 1 (found)
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return []
            return mock_papers

        engine_with_llm.llm.invoke.return_value = Mock(
            content="alt query one\nalt query two\nalt query three"
        )
        with patch.object(
            engine_with_llm, "_direct_search", side_effect=side_effect
        ):
            papers, strategy = engine_with_llm._adaptive_search(
                "no results query"
            )
            assert strategy == "llm_alternative"
            assert len(papers) == 1

    def test_llm_alternative_string_response(self, engine_with_llm):
        """LLM returns a plain string instead of object with .content."""
        mock_papers = [{"paperId": "1"}]
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            return [] if call_count == 1 else mock_papers

        # Return a string (no .content attribute)
        resp = "alt one\nalt two"
        # mock_llm_resp not needed - testing string return
        # We need isinstance check to pass for str
        engine_with_llm.llm.invoke.return_value = resp
        with patch.object(
            engine_with_llm, "_direct_search", side_effect=side_effect
        ):
            papers, strategy = engine_with_llm._adaptive_search("q")
            assert strategy == "llm_alternative"

    def test_llm_alternative_all_fail(self, engine_with_llm):
        """LLM alternatives all fail -> falls through to key_terms."""
        engine_with_llm.llm.invoke.return_value = Mock(
            content="alt1\nalt2\nalt3"
        )
        mock_papers = [{"paperId": "1"}]
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            # standard=empty, alt1=empty, alt2=empty, alt3=empty, key_terms=found
            if call_count <= 4:
                return []
            return mock_papers

        with patch.object(
            engine_with_llm, "_direct_search", side_effect=side_effect
        ):
            papers, strategy = engine_with_llm._adaptive_search(
                "longword1 longword2"
            )
            assert strategy == "key_terms"

    def test_llm_exception_falls_through(self, engine_with_llm):
        """LLM invoke raises -> falls through to key_terms strategy."""
        engine_with_llm.llm.invoke.side_effect = Exception("LLM error")
        mock_papers = [{"paperId": "1"}]
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            return [] if call_count == 1 else mock_papers

        with patch.object(
            engine_with_llm, "_direct_search", side_effect=side_effect
        ):
            papers, strategy = engine_with_llm._adaptive_search(
                "longword1 longword2"
            )
            assert strategy == "key_terms"

    def test_key_terms_fallback(self, engine):
        """Key terms strategy uses longest words > 6 chars."""
        engine.llm = None
        call_count = 0
        mock_papers = [{"paperId": "1"}]

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            return [] if call_count == 1 else mock_papers

        with patch.object(engine, "_direct_search", side_effect=side_effect):
            papers, strategy = engine._adaptive_search(
                "something longerthan six characters"
            )
            assert strategy == "key_terms"

    def test_key_terms_sorted_by_length(self, engine):
        """Key terms are sorted by length descending, max 3."""
        engine.llm = None
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return []
            # Verify the query contains the longest words
            return [{"paperId": "1"}]

        with patch.object(
            engine, "_direct_search", side_effect=side_effect
        ) as ds:
            engine._adaptive_search("abcdefgh abcdefg abcdef abcdefghi short")
            # Second call should have the 3 longest words
            second_call_query = ds.call_args_list[1][0][0]
            words = second_call_query.split()
            assert len(words) <= 3

    def test_key_terms_no_results_falls_to_single_term(self, engine):
        """Key terms fails -> single term fallback."""
        engine.llm = None
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            # standard=empty, key_terms=empty, single_term=found
            if call_count <= 2:
                return []
            return [{"paperId": "1"}]

        with patch.object(engine, "_direct_search", side_effect=side_effect):
            papers, strategy = engine._adaptive_search("abcdefgh short")
            assert strategy == "single_term"

    def test_single_term_too_short_skipped(self, engine):
        """Single term <= 5 chars is not tried."""
        engine.llm = None
        with patch.object(engine, "_direct_search", return_value=[]):
            papers, strategy = engine._adaptive_search("abc def ghi")
            assert papers == []
            assert strategy == "standard"

    def test_single_term_fallback_no_results(self, engine):
        """Single term fallback returns no results."""
        engine.llm = None
        with patch.object(engine, "_direct_search", return_value=[]):
            papers, strategy = engine._adaptive_search("abcdefgh")
            assert papers == []
            assert strategy == "standard"

    def test_no_longer_words_skips_key_terms(self, engine):
        """No words > 6 chars -> skip key_terms, go to single_term."""
        engine.llm = None
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return []
            return [{"paperId": "1"}]

        with patch.object(engine, "_direct_search", side_effect=side_effect):
            # "abcdef" is exactly 6 chars, not > 6, so key_terms is skipped
            # "abcdef" is > 5 chars, so single_term is tried
            papers, strategy = engine._adaptive_search("abcdef")
            assert strategy == "single_term"

    def test_llm_more_than_three_alternatives_limited(self, engine_with_llm):
        """Only first 3 LLM alternatives are tried."""
        call_count = 0

        def side_effect(q):
            nonlocal call_count
            call_count += 1
            return []

        engine_with_llm.llm.invoke.return_value = Mock(
            content="q1\nq2\nq3\nq4\nq5"
        )
        with patch.object(
            engine_with_llm, "_direct_search", side_effect=side_effect
        ):
            engine_with_llm._adaptive_search("x")
            # 1 standard + 3 alternatives = 4 calls total (not 5+)
            assert call_count == 4


# ─── _get_paper_details: all branches (lines 485-522) ───


class TestGetPaperDetails:
    def test_basic_details(self, engine):
        """Basic paper details request with default options."""
        mock_resp = {"paperId": "123", "title": "Paper"}
        with patch.object(
            engine, "_make_request", return_value=mock_resp
        ) as mr:
            result = engine._get_paper_details("123")
            assert result == mock_resp
            url = mr.call_args[0][0]
            assert "123" in url

    def test_includes_tldr_field(self, engine):
        """tldr field included when get_tldr=True."""
        engine.get_tldr = True
        with patch.object(engine, "_make_request", return_value={}) as mr:
            engine._get_paper_details("123")
            params = mr.call_args[0][1]
            assert "tldr" in params["fields"]

    def test_excludes_tldr_when_disabled(self, engine):
        """tldr field excluded when get_tldr=False."""
        engine.get_tldr = False
        with patch.object(engine, "_make_request", return_value={}) as mr:
            engine._get_paper_details("123")
            params = mr.call_args[0][1]
            assert "tldr" not in params["fields"]

    def test_includes_embedding_field(self, engine):
        """embedding field included when get_embeddings=True."""
        engine.get_embeddings = True
        with patch.object(engine, "_make_request", return_value={}) as mr:
            engine._get_paper_details("123")
            params = mr.call_args[0][1]
            assert "embedding" in params["fields"]

    def test_includes_citations_field(self, engine):
        """citations field included when get_citations=True."""
        engine.get_citations = True
        engine.citation_limit = 5
        with patch.object(engine, "_make_request", return_value={}) as mr:
            engine._get_paper_details("123")
            params = mr.call_args[0][1]
            assert "citations.limit(5)" in params["fields"]

    def test_includes_references_field(self, engine):
        """references field included when get_references=True."""
        engine.get_references = True
        engine.reference_limit = 7
        with patch.object(engine, "_make_request", return_value={}) as mr:
            engine._get_paper_details("123")
            params = mr.call_args[0][1]
            assert "references.limit(7)" in params["fields"]

    def test_exception_returns_empty(self, engine):
        """Exception in _get_paper_details returns {}."""
        with patch.object(
            engine, "_make_request", side_effect=Exception("err")
        ):
            result = engine._get_paper_details("123")
            assert result == {}


# ─── _get_previews: exception in paper processing (lines 603-604) ───


class TestGetPreviewsEdgeCases:
    def test_paper_processing_exception_skipped(self, engine):
        """If processing a single paper raises, it's skipped."""
        bad_paper = Mock()
        bad_paper.get = Mock(side_effect=Exception("bad paper"))
        good_paper = {
            "paperId": "1",
            "title": "Good",
            "abstract": None,
            "authors": [],
            "url": "http://x",
            "venue": "V",
            "year": 2023,
            "externalIds": {},
            "tldr": None,
        }
        with patch.object(
            engine,
            "_adaptive_search",
            return_value=([bad_paper, good_paper], "standard"),
        ):
            previews = engine._get_previews("test")
            assert len(previews) == 1
            assert previews[0]["title"] == "Good"

    def test_previews_sorted_by_year_descending(self, engine):
        """Previews are sorted by year, newest first."""
        papers = [
            {"paperId": "1", "title": "Old", "year": 2019, "authors": []},
            {"paperId": "2", "title": "New", "year": 2024, "authors": []},
            {"paperId": "3", "title": "Mid", "year": 2021, "authors": []},
        ]
        with patch.object(
            engine, "_adaptive_search", return_value=(papers, "standard")
        ):
            previews = engine._get_previews("test")
            years = [p["year"] for p in previews]
            assert years == [2024, 2021, 2019]

    def test_previews_none_year_sorted_last(self, engine):
        """Papers with None year are sorted last."""
        papers = [
            {"paperId": "1", "title": "No Year", "year": None, "authors": []},
            {"paperId": "2", "title": "Has Year", "year": 2020, "authors": []},
        ]
        with patch.object(
            engine, "_adaptive_search", return_value=(papers, "standard")
        ):
            previews = engine._get_previews("test")
            assert previews[0]["year"] == 2020
            assert previews[1]["year"] is None

    def test_previews_tldr_non_dict_ignored(self, engine):
        """If tldr is not a dict, tldr_text is empty."""
        papers = [
            {
                "paperId": "1",
                "title": "T",
                "tldr": "just a string",
                "authors": [],
            }
        ]
        with patch.object(
            engine, "_adaptive_search", return_value=(papers, "standard")
        ):
            previews = engine._get_previews("test")
            assert previews[0]["tldr"] == ""

    def test_previews_short_abstract_not_truncated(self, engine):
        """Short abstract is not truncated."""
        papers = [
            {"paperId": "1", "title": "T", "abstract": "Short", "authors": []}
        ]
        with patch.object(
            engine, "_adaptive_search", return_value=(papers, "standard")
        ):
            previews = engine._get_previews("test")
            assert previews[0]["snippet"] == "Short"

    def test_previews_openaccess_pdf_in_full_paper(self, engine):
        """_full_paper stores the original paper data."""
        papers = [
            {
                "paperId": "1",
                "title": "T",
                "openAccessPdf": {"url": "http://pdf"},
                "authors": [],
            }
        ]
        with patch.object(
            engine, "_adaptive_search", return_value=(papers, "standard")
        ):
            previews = engine._get_previews("test")
            assert (
                previews[0]["_full_paper"]["openAccessPdf"]["url"]
                == "http://pdf"
            )

    def test_previews_author_filtering(self, engine):
        """Authors without name or None entries are filtered out."""
        papers = [
            {
                "paperId": "1",
                "title": "T",
                "authors": [
                    {"name": "Alice"},
                    {"name": ""},
                    None,
                    {"name": "Bob"},
                    {},
                ],
            }
        ]
        with patch.object(
            engine, "_adaptive_search", return_value=(papers, "standard")
        ):
            previews = engine._get_previews("test")
            assert previews[0]["authors"] == ["Alice", "Bob"]


# ─── _get_full_content: references, embeddings, fields_of_study (lines 653-680) ───


class TestGetFullContentBranches:
    def test_references_added(self, engine):
        """References are added when get_references=True."""
        engine.get_references = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {"references": [{"paperId": "r1"}]}
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert results[0]["references"] == [{"paperId": "r1"}]

    def test_embeddings_added(self, engine):
        """Embeddings are added when get_embeddings=True."""
        engine.get_embeddings = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {"embedding": {"vector": [0.1, 0.2]}}
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert results[0]["embedding"] == {"vector": [0.1, 0.2]}

    def test_fields_of_study_added(self, engine):
        """fieldsOfStudy from details is added as fields_of_study."""
        engine.get_citations = True  # trigger details fetch
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {"fieldsOfStudy": ["CS", "Math"]}
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert results[0]["fields_of_study"] == ["CS", "Math"]

    def test_empty_paper_details(self, engine):
        """Empty paper details doesn't add extra fields."""
        engine.get_citations = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        with patch.object(engine, "_get_paper_details", return_value={}):
            results = engine._get_full_content(items)
            assert "citations" not in results[0]
            assert "references" not in results[0]

    def test_no_paper_id_skips_details(self, engine):
        """Item with empty _paper_id doesn't fetch details."""
        engine.get_citations = True
        items = [{"_paper_id": "", "_search_strategy": "s", "_full_paper": {}}]
        with patch.object(engine, "_get_paper_details") as mock_details:
            engine._get_full_content(items)
            mock_details.assert_not_called()

    def test_temp_fields_removed(self, engine):
        """_paper_id, _search_strategy, _full_paper removed from results."""
        items = [
            {
                "title": "P",
                "_paper_id": "1",
                "_search_strategy": "s",
                "_full_paper": {"x": 1},
            }
        ]
        results = engine._get_full_content(items)
        assert "_paper_id" not in results[0]
        assert "_search_strategy" not in results[0]
        assert "_full_paper" not in results[0]

    def test_multiple_items(self, engine):
        """Multiple items processed correctly."""
        engine.get_references = True
        items = [
            {"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}},
            {"_paper_id": "2", "_search_strategy": "s", "_full_paper": {}},
        ]
        details = {"references": [{"paperId": "r1"}]}
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert len(results) == 2
            assert all("references" in r for r in results)

    def test_citations_and_references_together(self, engine):
        """Both citations and references fetched together."""
        engine.get_citations = True
        engine.get_references = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {
            "citations": [{"paperId": "c1"}],
            "references": [{"paperId": "r1"}],
            "fieldsOfStudy": ["Biology"],
        }
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert results[0]["citations"] == [{"paperId": "c1"}]
            assert results[0]["references"] == [{"paperId": "r1"}]
            assert results[0]["fields_of_study"] == ["Biology"]

    def test_details_missing_citations_key(self, engine):
        """Details dict exists but doesn't have 'citations' key."""
        engine.get_citations = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {"title": "P"}  # no 'citations' key
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert "citations" not in results[0]

    def test_details_missing_references_key(self, engine):
        """Details dict exists but doesn't have 'references' key."""
        engine.get_references = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {"title": "P"}
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert "references" not in results[0]

    def test_details_missing_embedding_key(self, engine):
        """Details dict exists but doesn't have 'embedding' key."""
        engine.get_embeddings = True
        items = [{"_paper_id": "1", "_search_strategy": "s", "_full_paper": {}}]
        details = {"title": "P"}
        with patch.object(engine, "_get_paper_details", return_value=details):
            results = engine._get_full_content(items)
            assert "embedding" not in results[0]


# ─── _respect_rate_limit (line 190-193) ───


class TestRespectRateLimit:
    def test_respect_rate_limit_calls_tracker(self, engine):
        """_respect_rate_limit uses rate_tracker."""
        engine.rate_tracker = Mock()
        engine.rate_tracker.apply_rate_limit.return_value = 0.5
        engine.engine_type = "semantic_scholar"
        engine._respect_rate_limit()
        engine.rate_tracker.apply_rate_limit.assert_called_once_with(
            "semantic_scholar"
        )
        assert engine._last_wait_time == 0.5


# ─── _make_request raise_for_status path ───


class TestMakeRequestRaiseForStatus:
    def test_non_429_http_error(self, engine):
        """Non-429 HTTP error triggers raise_for_status -> RequestException."""
        mock_resp = Mock()
        mock_resp.status_code = 500
        mock_resp.raise_for_status.side_effect = requests.HTTPError("500 error")
        engine.session.get.return_value = mock_resp
        result = engine._make_request("https://example.com")
        assert result == {}
