"""TDD integration tests for axm_bib.pipeline. Written BEFORE implementation.

Tests use mocked clients — no real HTTP calls.
"""

from __future__ import annotations

import logging
import os
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from axm_bib.clients.semantic_scholar import SemanticScholarError
from axm_bib.exceptions import IdentifierNotFound, ResolutionError
from axm_bib.models import BibEntry, DownloadResult, Paper
from axm_bib.pipeline import BibPipeline
from axm_bib.storage import FileSystemStorage
from tests.conftest import SAMPLE_CROSSREF_BIBTEX, SAMPLE_DOI


def _make_real_pdf_bytes() -> bytes:
    """Create a minimal real PDF in memory and return its bytes."""
    import pymupdf

    doc = pymupdf.open()
    page = doc.new_page(width=612, height=792)
    page.insert_text((72, 100), "Test paper content", fontsize=12)
    data: bytes = bytes(doc.tobytes())
    doc.close()
    return data


@pytest.fixture
def mock_pipeline(tmp_path: Path) -> BibPipeline:
    """Pipeline with mocked clients and tmp storage."""
    storage = FileSystemStorage(output_dir=tmp_path)
    return BibPipeline(mailto="test@example.com", storage=storage)


class TestSearch:
    """BibPipeline.search() tests — fan-out across all sources."""

    def test_search_returns_papers(self, mock_pipeline: BibPipeline) -> None:
        """Search returns papers from multiple sources."""
        s2_paper = Paper(
            title="Deep Learning",
            authors=["John Smith"],
            year=2024,
            doi=SAMPLE_DOI,
            source="semantic_scholar",
        )
        with (
            patch.object(
                mock_pipeline._scholar, "search_paper", return_value=[s2_paper]
            ),
            patch.object(mock_pipeline._crossref, "search_by_title", return_value=[]),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
        ):
            papers = mock_pipeline.search("Deep Learning", limit=5)
            assert len(papers.papers) == 1
            assert papers.papers[0].doi == SAMPLE_DOI

    def test_search_limit_zero_raises(self, mock_pipeline: BibPipeline) -> None:
        """limit=0 → ValueError."""
        with pytest.raises(ValueError, match="limit"):
            mock_pipeline.search("test", limit=0)

    def test_search_limit_negative_raises(self, mock_pipeline: BibPipeline) -> None:
        """limit=-1 → ValueError."""
        with pytest.raises(ValueError, match="limit"):
            mock_pipeline.search("test", limit=-1)

    def test_search_limit_clamped(self, mock_pipeline: BibPipeline) -> None:
        """limit=200 → clamped to 100 downstream."""
        with (
            patch.object(mock_pipeline._scholar, "search_paper") as mock_s,
            patch.object(mock_pipeline._crossref, "search_by_title", return_value=[]),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
        ):
            mock_s.return_value = []
            mock_pipeline.search("test", limit=200)
            mock_s.assert_called_once_with("test", limit=100)

    def test_search_parallel_execution(self, mock_pipeline: BibPipeline) -> None:
        """AXM-262: All 6 sources queried in parallel, not sequentially."""
        import time as _time

        def _slow_search(_q: str, _n: int = 0, **_kw: int) -> list[Paper]:
            _time.sleep(0.1)
            return []

        with (
            patch.object(
                mock_pipeline._scholar, "search_paper", side_effect=_slow_search
            ),
            patch.object(
                mock_pipeline._crossref, "search_by_title", side_effect=_slow_search
            ),
            patch.object(mock_pipeline._dblp, "search", side_effect=_slow_search),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(side_effect=_slow_search))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(side_effect=_slow_search))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(side_effect=_slow_search))
                ),
            ),
        ):
            t0 = _time.monotonic()
            result = mock_pipeline.search("test", limit=5)
            elapsed = _time.monotonic() - t0

        # 6 x 0.1s parallel should complete in <0.4s (vs ~0.6s sequential)
        assert elapsed < 0.4, f"Search took {elapsed:.2f}s — not parallel?"
        assert len(result.sources_queried) == 6
        assert result.sources_failed == []

    def test_search_one_source_fails_others_succeed(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """AXM-262: One source failure doesn't affect others."""
        s2_paper = Paper(
            title="Deep Learning",
            authors=["Author"],
            year=2024,
            doi=SAMPLE_DOI,
            source="semantic_scholar",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                return_value=[s2_paper],
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
        ):
            result = mock_pipeline.search("Deep Learning", limit=5)

        assert len(result.papers) == 1
        assert result.papers[0].title == "Deep Learning"
        assert "S2" in result.sources_failed
        assert len(result.sources_failed) == 1
        assert len(result.sources_queried) == 6

    def test_search_all_sources_fail(self, mock_pipeline: BibPipeline) -> None:
        """AXM-262: All 6 sources fail → empty papers, all in sources_failed."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=SemanticScholarError("fail"),
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                side_effect=OSError("fail"),
            ),
            patch.object(mock_pipeline._dblp, "search", side_effect=OSError("fail")),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(
                        search=MagicMock(side_effect=OSError("fail"))
                    )
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(
                        search=MagicMock(side_effect=OSError("fail"))
                    )
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(
                        search=MagicMock(side_effect=OSError("fail"))
                    )
                ),
            ),
        ):
            result = mock_pipeline.search("test", limit=5)

        assert result.papers == []
        assert len(result.sources_failed) == 6
        assert len(result.sources_queried) == 6


class TestResolveBibtex:
    """BibPipeline.resolve_bibtex() tests."""

    def test_resolve_bibtex(self, mock_pipeline: BibPipeline) -> None:
        """DOI → CrossRef → BibEntry with proper key."""
        with patch.object(mock_pipeline._crossref, "doi_to_bibtex") as mock_resolve:
            mock_resolve.return_value = BibEntry(
                key="smith2024deep",
                entry_type="article",
                raw=SAMPLE_CROSSREF_BIBTEX,
                doi=SAMPLE_DOI,
                title="Deep Learning for Citation Analysis",
                authors=["John Smith", "Jane Doe"],
                year=2024,
            )
            entry = mock_pipeline.resolve_bibtex(SAMPLE_DOI)
            assert isinstance(entry, BibEntry)
            assert entry.key == "smith2024deep"


class TestResolve:
    """BibPipeline.resolve() tests."""

    def test_resolve_rejects_title(self, mock_pipeline: BibPipeline) -> None:
        """Title input is rejected with 'use bib_search' hint."""
        with pytest.raises(ResolutionError, match="bib_search"):
            mock_pipeline.resolve("Some Paper Title")

    def test_resolve_empty_raises(self, mock_pipeline: BibPipeline) -> None:
        """Empty input raises ValueError."""
        with pytest.raises(ResolutionError, match="identifier is required"):
            mock_pipeline.resolve("")

    def test_pipeline_no_search_and_resolve(
        self,
        mock_pipeline: BibPipeline,
    ) -> None:
        """search_and_resolve method no longer exists."""
        assert not hasattr(mock_pipeline, "search_and_resolve")

    def test_resolve_hal(self, mock_pipeline: BibPipeline) -> None:
        """AXM-263: resolve() with HAL ID calls hal.bibtex."""
        entry = BibEntry(
            key="hal-01234567",
            entry_type="article",
            raw="@article{hal-01234567, title={Test}}",
            title="Test",
        )
        with patch.object(
            type(mock_pipeline),
            "hal",
            new_callable=lambda: property(
                lambda self: MagicMock(bibtex=MagicMock(return_value=entry))
            ),
        ):
            result = mock_pipeline.resolve("hal-01234567")
        assert result.key == "hal-01234567"

    def test_resolve_dblp(self, mock_pipeline: BibPipeline) -> None:
        """AXM-263: resolve() with DBLP key calls _dblp.bibtex."""
        entry = BibEntry(
            key="conf/iclr/Test2024",
            entry_type="inproceedings",
            raw="@inproceedings{Test2024, title={Test}}",
            title="Test",
        )
        with patch.object(mock_pipeline._dblp, "bibtex", return_value=entry):
            result = mock_pipeline.resolve("conf/iclr/Test2024")
        assert result.key == "conf/iclr/Test2024"

    def test_resolve_openreview(self, mock_pipeline: BibPipeline) -> None:
        """AXM-263: resolve() with OpenReview ID calls openreview.bibtex."""
        entry = BibEntry(
            key="test2024openreview",
            entry_type="inproceedings",
            raw="@inproceedings{test2024, title={Test}}",
            title="Test",
        )
        with patch.object(
            type(mock_pipeline),
            "openreview",
            new_callable=lambda: property(
                lambda self: MagicMock(bibtex=MagicMock(return_value=entry))
            ),
        ):
            result = mock_pipeline.resolve("openreview:abc123")
        assert result.key == "test2024openreview"


class TestLazyInitProperties:
    """AXM-263: lazy-init client properties create real instances."""

    def test_hal_property_creates_client(self) -> None:
        """hal property creates HALClient on first access."""
        pipeline = BibPipeline(mailto="test@example.com")
        assert pipeline._hal is None
        client = pipeline.hal
        assert client is not None
        assert pipeline._hal is client
        # Second access returns same instance
        assert pipeline.hal is client

    def test_arxiv_property_creates_client(self) -> None:
        """arxiv property creates ArXivClient on first access."""
        pipeline = BibPipeline(mailto="test@example.com")
        assert pipeline._arxiv is None
        client = pipeline.arxiv
        assert client is not None
        assert pipeline._arxiv is client

    def test_openreview_property_creates_client(self) -> None:
        """openreview property creates OpenReviewClient on first access."""
        pipeline = BibPipeline(mailto="test@example.com")
        assert pipeline._openreview is None
        client = pipeline.openreview
        assert client is not None
        assert pipeline._openreview is client

    def test_openalex_property_creates_client(self) -> None:
        """openalex property creates OpenAlexClient on first access."""
        pipeline = BibPipeline(mailto="test@example.com")
        assert pipeline._openalex is None
        client = pipeline.openalex
        assert client is not None
        assert pipeline._openalex is client


class TestVenueLookup:
    """Tests for VenueEnricher._lookup_venue, enrich, and resolve arXiv enrichment."""

    def test_lookup_venue_s2_success(self, mock_pipeline: BibPipeline) -> None:
        """S2 direct lookup returns venue → used immediately."""
        paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="International Conference on Learning Representations",
            source="semantic_scholar",
        )
        with patch.object(mock_pipeline._scholar, "get_paper", return_value=paper):
            venue = mock_pipeline._venue_enricher._lookup_venue("2210.02406")
        assert venue == "International Conference on Learning Representations"

    def test_lookup_venue_s2_fail_dblp_fallback(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """S2 fails → DBLP fallback returns conference venue."""
        dblp_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="ICLR",
            source="dblp",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[dblp_paper]),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue("2210.02406")
        assert venue == "ICLR"

    def test_lookup_venue_dblp_skips_corr(self, mock_pipeline: BibPipeline) -> None:
        """DBLP fallback skips CoRR entries and finds conference venue."""
        corr_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="CoRR",
            source="dblp",
        )
        conf_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="ICLR",
            source="dblp",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(
                mock_pipeline._dblp,
                "search",
                return_value=[corr_paper, conf_paper],
            ),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue("2210.02406")
        assert venue == "ICLR"

    def test_lookup_venue_dblp_only_corr_returns_none(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """DBLP returns only CoRR → returns None (not CoRR)."""
        corr_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="CoRR",
            source="dblp",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[corr_paper]),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue("2210.02406")
        assert venue is None

    def test_lookup_venue_all_fail(self, mock_pipeline: BibPipeline) -> None:
        """Both S2 and DBLP fail → returns None."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("timeout"),
            ),
            patch.object(mock_pipeline._dblp, "search", side_effect=OSError("timeout")),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue("2210.02406")
        assert venue is None

    def test_enrich_venue_injects_booktitle(self, mock_pipeline: BibPipeline) -> None:
        """Venue found → booktitle injected and type promoted."""
        entry = BibEntry(
            key="khot2023decomposed",
            entry_type="misc",
            raw="@misc{khot2023decomposed,\n  title={Decomposed Prompting},\n}",
            title="Decomposed Prompting",
            authors=["Khot"],
            year=2023,
        )
        with patch.object(
            mock_pipeline._venue_enricher,
            "_lookup_venue",
            return_value="International Conference on Learning Representations",
        ):
            result = mock_pipeline._enrich_venue("2210.02406", entry)

        assert result.entry_type == "inproceedings"
        expected_venue = "International Conference on Learning Representations"
        assert f"booktitle = {{{expected_venue}}}" in result.raw
        assert "@inproceedings{" in result.raw

    def test_enrich_venue_no_venue_unchanged(self, mock_pipeline: BibPipeline) -> None:
        """No venue found → returns original entry unchanged."""
        entry = BibEntry(
            key="khot2023decomposed",
            entry_type="misc",
            raw="@misc{khot2023decomposed,\n  title={Test},\n}",
            title="Test",
            authors=["Khot"],
            year=2023,
        )
        with patch.object(
            mock_pipeline._venue_enricher, "_lookup_venue", return_value=None
        ):
            result = mock_pipeline._enrich_venue("2210.02406", entry)

        assert result is entry  # Same object, not enriched

    def test_resolve_arxiv_enriches_venue(self, mock_pipeline: BibPipeline) -> None:
        """resolve() with arXiv ID calls _enrich_venue on the result."""
        raw = "@misc{khot2023decomposed,\n  title={Test},\n}"
        arx_entry = BibEntry(
            key="khot2023decomposed",
            entry_type="misc",
            raw=raw,
            title="Test",
            authors=["Khot"],
            year=2023,
        )
        with (
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(bibtex=MagicMock(return_value=arx_entry))
                ),
            ),
            patch.object(
                mock_pipeline,
                "_enrich_venue",
                wraps=mock_pipeline._enrich_venue,
            ) as mock_enrich,
            patch.object(
                mock_pipeline._venue_enricher,
                "_lookup_venue",
                return_value=None,
            ),
        ):
            mock_pipeline.resolve("2210.02406")
            mock_enrich.assert_called_once()

    def test_lookup_venue_title_fallback(self, mock_pipeline: BibPipeline) -> None:
        """AC1+AC2: DBLP(arXiv) → CoRR only, DBLP(title) → ICLR."""
        corr_paper = Paper(
            title="Decomposed Prompting",
            authors=["Khot"],
            venue="CoRR",
            source="dblp",
        )
        iclr_paper = Paper(
            title="Decomposed Prompting",
            authors=["Khot"],
            venue="ICLR",
            source="dblp",
        )

        def _dblp_side_effect(query: str, limit: int = 5) -> list[Paper]:
            if query == "2210.02406":
                return [corr_paper]
            return [iclr_paper]  # title search

        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(mock_pipeline._dblp, "search", side_effect=_dblp_side_effect),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue(
                "2210.02406", title="Decomposed Prompting"
            )
        assert venue == "ICLR"

    def test_lookup_venue_title_fallback_also_corr(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """AC4: DBLP(arXiv) → CoRR, DBLP(title) → CoRR → returns None."""
        corr_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="CoRR",
            source="dblp",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[corr_paper]),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue(
                "2210.02406", title="Test Paper"
            )
        assert venue is None

    def test_lookup_venue_no_title(self, mock_pipeline: BibPipeline) -> None:
        """Edge: DBLP(arXiv) → CoRR, title=None → returns None (no crash)."""
        corr_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="CoRR",
            source="dblp",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[corr_paper]),
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue("2210.02406")
        assert venue is None

    def test_lookup_venue_s2_success_unchanged(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """AC5: S2 returns venue → no DBLP title search attempted."""
        s2_paper = Paper(
            title="Test Paper",
            authors=["Author"],
            venue="NeurIPS",
            source="semantic_scholar",
        )
        with (
            patch.object(mock_pipeline._scholar, "get_paper", return_value=s2_paper),
            patch.object(mock_pipeline._dblp, "search") as mock_dblp,
        ):
            venue = mock_pipeline._venue_enricher._lookup_venue(
                "2210.02406", title="Test Paper"
            )
        assert venue == "NeurIPS"
        mock_dblp.assert_not_called()


class TestDownloadPdf:
    """BibPipeline.download_pdf() tests."""

    def test_download_pdf(self, mock_pipeline: BibPipeline) -> None:
        """DOI → Unpaywall → download → DownloadResult."""
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://arxiv.org/pdf/123.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{...}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)
            assert result is not None
            assert isinstance(result, DownloadResult)
            assert result.is_open_access is True
            assert Path(result.path).exists()

    def test_download_pdf_not_oa(self, mock_pipeline: BibPipeline) -> None:
        """Closed-access paper returns None."""
        with patch.object(mock_pipeline._unpaywall, "get_pdf_urls", return_value=[]):
            result = mock_pipeline.download_pdf("10.1234/closed")
            assert result is None

    def test_download_pdf_fallback_on_403(self, mock_pipeline: BibPipeline) -> None:
        """First URL 403s, second URL succeeds."""
        import httpx as real_httpx

        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=[
                    "https://publisher.com/pdf",
                    "https://arxiv.org/pdf/123.pdf",
                ],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{...}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            forbidden_exc = real_httpx.HTTPStatusError(
                "403", request=MagicMock(), response=MagicMock()
            )
            success = MagicMock()
            success.headers = {"content-type": "application/pdf"}
            success.content = _make_real_pdf_bytes()
            success.raise_for_status = MagicMock()
            mock_retry.side_effect = [forbidden_exc, success]

            result = mock_pipeline.download_pdf(SAMPLE_DOI)
            assert result is not None
            assert result.is_open_access is True

    def test_download_pdf_skips_html_landing_pages(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """First URL returns HTML (e.g., DOAJ), second URL is real PDF."""
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=[
                    "https://doaj.org/article/123",  # HTML landing page
                    "https://publisher.com/paper.pdf",  # Real PDF
                ],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{...}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            # First response: HTML
            html_resp = MagicMock()
            html_resp.headers = {"content-type": "text/html; charset=utf-8"}
            html_resp.content = b"<!DOCTYPE html><html>...</html>"
            html_resp.raise_for_status = MagicMock()

            # Second response: PDF
            pdf_resp = MagicMock()
            pdf_resp.headers = {"content-type": "application/pdf"}
            pdf_resp.content = _make_real_pdf_bytes()
            pdf_resp.raise_for_status = MagicMock()

            mock_retry.side_effect = [html_resp, pdf_resp]

            result = mock_pipeline.download_pdf(SAMPLE_DOI)
            assert result is not None
            assert result.is_open_access is True
            # Should have tried both URLs
            assert mock_retry.call_count == 2

    def test_pipeline_custom_mailto(self) -> None:
        """mailto propagated to CrossRef client."""
        pipeline = BibPipeline(mailto="user@uni.edu")
        assert pipeline._crossref._mailto == "user@uni.edu"


class TestDownloadPdfFullPipeline:
    """Tests for extended download_pdf: download → folder → extract → bibtex."""

    @staticmethod
    def _make_real_pdf_bytes() -> bytes:
        """Create a minimal real PDF in memory and return its bytes."""
        import pymupdf

        doc = pymupdf.open()
        page = doc.new_page(width=612, height=792)
        page.insert_text((72, 100), "Test paper content", fontsize=12)
        data: bytes = bytes(doc.tobytes())
        doc.close()
        return data

    def test_download_creates_paper_folder(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """download_pdf creates {key}/ subfolder."""
        pdf_bytes = self._make_real_pdf_bytes()
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://arxiv.org/pdf/123.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{smith2024deep, title={Deep}}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)

            assert result is not None
            # Paper folder should exist
            paper_dir = tmp_path / "smith2024deep"
            assert paper_dir.is_dir()

    def test_download_writes_bibtex(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """download_pdf writes paper.bib in the paper folder."""
        pdf_bytes = self._make_real_pdf_bytes()
        raw_bib = "@article{smith2024deep, title={Deep Learning}}"
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://arxiv.org/pdf/123.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw=raw_bib,
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            mock_pipeline.download_pdf(SAMPLE_DOI)

            bib_path = tmp_path / "smith2024deep" / "paper.bib"
            assert bib_path.exists()
            assert bib_path.read_text() == raw_bib

    def test_download_runs_extraction(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """download_pdf extracts markdown + figures."""
        pdf_bytes = self._make_real_pdf_bytes()
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://arxiv.org/pdf/123.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{smith2024deep, title={Deep}}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)

            assert result is not None
            # content.md should exist in the paper folder
            md_path = tmp_path / "smith2024deep" / "content.md"
            assert md_path.exists()
            assert len(md_path.read_text()) > 0
            # ExtractionResult should be in the result
            assert result.extraction is not None
            assert result.extraction.page_count >= 1

    def test_download_result_has_bibtex_path(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """DownloadResult includes paper_dir and bibtex_path."""
        pdf_bytes = self._make_real_pdf_bytes()
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://arxiv.org/pdf/123.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{smith2024deep, title={Deep}}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)

            assert result is not None
            assert result.paper_dir is not None
            assert result.bibtex_path is not None
            assert Path(result.bibtex_path).name == "paper.bib"

    def test_not_open_access_no_folder(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """Closed-access paper returns None, no folder created."""
        with patch.object(mock_pipeline._unpaywall, "get_pdf_urls", return_value=[]):
            result = mock_pipeline.download_pdf("10.1234/closed")
            assert result is None
            # No folders should have been created
            children = list(tmp_path.iterdir())
            assert len(children) == 0

    def test_download_overwrites_existing(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """Re-download replaces old content."""
        pdf_bytes = self._make_real_pdf_bytes()
        # Pre-create folder with old content
        old_dir = tmp_path / "smith2024deep"
        old_dir.mkdir()
        (old_dir / "content.md").write_text("old markdown")

        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://arxiv.org/pdf/123.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{smith2024deep, title={New}}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)

            assert result is not None
            new_md = (tmp_path / "smith2024deep" / "content.md").read_text()
            assert "old markdown" not in new_md


class TestDoiFallbackUrls:
    """Tests for doi_fallback_urls helper."""

    def test_arxiv_doi_returns_pdf_url(self) -> None:
        """arXiv DOI → arxiv.org PDF URL."""
        from axm_bib.download import doi_fallback_urls

        assert doi_fallback_urls("10.48550/arXiv.2411.19275") == [
            "https://arxiv.org/pdf/2411.19275"
        ]

    def test_acl_doi_returns_pdf_url(self) -> None:
        """ACL Anthology DOI → aclanthology.org PDF URL."""
        from axm_bib.download import doi_fallback_urls

        assert doi_fallback_urls("10.18653/v1/2024.acl-long.172") == [
            "https://aclanthology.org/2024.acl-long.172.pdf"
        ]

    def test_unknown_doi_returns_empty(self) -> None:
        """Unknown publisher DOI → empty list."""
        from axm_bib.download import doi_fallback_urls

        assert doi_fallback_urls("10.1145/363235.363259") == []

    def test_arxiv_case_insensitive(self) -> None:
        """ARXIV. prefix (uppercase) → still matched."""
        from axm_bib.download import doi_fallback_urls

        urls = doi_fallback_urls("10.48550/ARXIV.2411.19275")
        assert urls == ["https://arxiv.org/pdf/2411.19275"]


class TestAclAnthologyFallback:
    """Tests for ACL Anthology fallback in download_pdf."""

    def test_acl_doi_fallback(self, mock_pipeline: BibPipeline, tmp_path: Path) -> None:
        """ACL DOI + Unpaywall empty → downloads from aclanthology.org."""
        pdf_bytes = _make_real_pdf_bytes()
        with (
            patch.object(mock_pipeline._unpaywall, "get_pdf_urls", return_value=[]),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="bai2024longbench",
                    entry_type="inproceedings",
                    raw="@inproceedings{bai2024longbench, title={LongBench}}",
                    doi="10.18653/v1/2024.acl-long.172",
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("10.18653/v1/2024.acl-long.172")

            assert result is not None
            assert result.is_open_access is True
            mock_retry.assert_called_once()
            call_url = mock_retry.call_args[0][1]
            assert call_url == "https://aclanthology.org/2024.acl-long.172.pdf"

    def test_acl_unpaywall_html_then_fallback(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """Unpaywall returns non-PDF URL (doi.org redirect) → fallback PDF succeeds."""
        pdf_bytes = _make_real_pdf_bytes()

        html_resp = MagicMock()
        html_resp.headers = {"content-type": "text/html; charset=utf-8"}
        html_resp.content = b"<html>landing page</html>"
        html_resp.raise_for_status = MagicMock()

        pdf_resp = MagicMock()
        pdf_resp.headers = {"content-type": "application/pdf"}
        pdf_resp.content = pdf_bytes
        pdf_resp.raise_for_status = MagicMock()

        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://doi.org/10.18653/v1/2024.acl-long.172"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="bai2024longbench",
                    entry_type="inproceedings",
                    raw="@inproceedings{bai2024longbench, title={LongBench}}",
                    doi="10.18653/v1/2024.acl-long.172",
                ),
            ),
        ):
            mock_retry.side_effect = [html_resp, pdf_resp]

            result = mock_pipeline.download_pdf("10.18653/v1/2024.acl-long.172")

            assert result is not None
            assert result.is_open_access is True
            assert mock_retry.call_count == 2
            # Second call should be the fallback URL
            second_url = mock_retry.call_args_list[1][0][1]
            assert second_url == "https://aclanthology.org/2024.acl-long.172.pdf"


class TestArxivFallback:
    """Tests for arXiv fallback in download_pdf."""

    def test_arxiv_doi_fallback(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """arXiv DOI + Unpaywall empty → downloads from arxiv.org/pdf/."""
        pdf_bytes = _make_real_pdf_bytes()
        with (
            patch.object(mock_pipeline._unpaywall, "get_pdf_urls", return_value=[]),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="author2024paper",
                    entry_type="article",
                    raw="@article{author2024paper, title={Paper}}",
                    doi="10.48550/arXiv.2411.19275",
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("10.48550/arXiv.2411.19275")

            assert result is not None
            assert result.is_open_access is True
            # Should have called request_with_retry with arxiv URL
            mock_retry.assert_called_once()
            call_url = mock_retry.call_args[0][1]
            assert "arxiv.org/pdf/2411.19275" in call_url

    def test_non_arxiv_no_fallback(self, mock_pipeline: BibPipeline) -> None:
        """Non-arXiv DOI + Unpaywall empty → None (no fallback)."""
        with patch.object(mock_pipeline._unpaywall, "get_pdf_urls", return_value=[]):
            result = mock_pipeline.download_pdf("10.1145/363235.363259")
            assert result is None

    def test_unpaywall_success_no_fallback(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """Unpaywall has URL → uses it, arXiv fallback not triggered."""
        pdf_bytes = _make_real_pdf_bytes()
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://publisher.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="author2024paper",
                    entry_type="article",
                    raw="@article{author2024paper, title={Paper}}",
                    doi="10.48550/arXiv.2411.19275",
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("10.48550/arXiv.2411.19275")

            assert result is not None
            call_url = mock_retry.call_args[0][1]
            assert call_url == "https://publisher.com/paper.pdf"

    def test_arxiv_case_insensitive(
        self, mock_pipeline: BibPipeline, tmp_path: Path
    ) -> None:
        """ARXIV. prefix (uppercase) → fallback still works."""
        pdf_bytes = _make_real_pdf_bytes()
        with (
            patch.object(mock_pipeline._unpaywall, "get_pdf_urls", return_value=[]),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="author2024paper",
                    entry_type="article",
                    raw="@article{author2024paper, title={Paper}}",
                    doi="10.48550/ARXIV.2411.19275",
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = pdf_bytes
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("10.48550/ARXIV.2411.19275")

            assert result is not None
            call_url = mock_retry.call_args[0][1]
            assert "arxiv.org/pdf/2411.19275" in call_url


# ── AXM-208 new tests ─────────────────────────────────────────────────────


class TestSearchLogsS2Fallback:
    """AC2: Search catches SemanticScholarError and logs, continues to other sources."""

    def test_search_logs_s2_failure(
        self, mock_pipeline: BibPipeline, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Logger records S2 error message when S2 fails."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                return_value=[],
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            caplog.at_level(logging.INFO, logger="axm_bib.pipeline"),
        ):
            mock_pipeline.search("test", limit=5)

        assert any("rate limited" in r.message for r in caplog.records)

    def test_search_does_not_catch_other_exceptions(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """Non-_SEARCH_ERRORS exceptions propagate (not silenced)."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=RuntimeError("unexpected"),
            ),
            pytest.raises(RuntimeError, match="unexpected"),
        ):
            mock_pipeline.search("test", limit=5)


class TestSearchFanOut:
    """Fan-out search, dedup, and filter tests."""

    @staticmethod
    def _make_paper(  # noqa: PLR0913
        title: str = "Test",
        *,
        doi: str | None = None,
        arxiv_id: str | None = None,
        source: str = "semantic_scholar",
        venue: str | None = None,
        year: int | None = None,
        authors: list[str] | None = None,
    ) -> Paper:
        return Paper(
            title=title,
            authors=authors or ["Author"],
            doi=doi,
            arxiv_id=arxiv_id,
            source=source,  # type: ignore[arg-type]
            venue=venue,
            year=year,
        )

    def test_fanout_all_sources(self, mock_pipeline: BibPipeline) -> None:
        """Papers from all 6 sources are aggregated."""
        papers = [
            self._make_paper(f"Deep Learning Paper {i}", doi=f"10.{i}/x", source=s)
            for i, s in enumerate(
                [
                    "semantic_scholar",
                    "crossref",
                    "dblp",
                    "openreview",
                    "arxiv",
                    "openalex",
                ],
                1,
            )
        ]
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                return_value=[papers[0]],
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                return_value=[papers[1]],
            ),
            patch.object(
                mock_pipeline._dblp,
                "search",
                return_value=[papers[2]],
            ),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[papers[3]]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[papers[4]]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[papers[5]]))
                ),
            ),
        ):
            result = mock_pipeline.search("Deep Learning Paper", limit=10)
            assert len(result.papers) == 6
            sources = {p.source for p in result.papers}
            assert len(sources) == 6

    def test_fanout_one_source_fails(self, mock_pipeline: BibPipeline) -> None:
        """S2 fails, other sources succeed."""
        cr_paper = self._make_paper(
            "Deep Learning Methods", doi="10.1/cr", source="crossref"
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=SemanticScholarError("down"),
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                return_value=[cr_paper],
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
        ):
            result = mock_pipeline.search("Deep Learning Methods", limit=10)
            assert len(result.papers) == 1
            assert result.papers[0].source == "crossref"

    def test_dedup_by_doi(self, mock_pipeline: BibPipeline) -> None:
        """Same DOI from 2 sources → 1 paper (richest kept)."""
        sparse = self._make_paper("Paper", doi="10.1/same", source="crossref")
        rich = self._make_paper(
            "Paper Full Title",
            doi="10.1/same",
            source="semantic_scholar",
            venue="NeurIPS",
            year=2024,
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                return_value=[rich],
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                return_value=[sparse],
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
        ):
            result = mock_pipeline.search("paper", limit=10)
            assert len(result.papers) == 1
            assert result.papers[0].venue == "NeurIPS"  # richest kept

    def test_dedup_by_arxiv_id(self, mock_pipeline: BibPipeline) -> None:
        """Same arXiv ID from 2 sources → deduplicated."""
        from axm_bib.search import dedup_papers

        p1 = self._make_paper("Paper A", arxiv_id="2401.00001", source="arxiv")
        p2 = self._make_paper(
            "Paper A",
            arxiv_id="2401.00001",
            source="semantic_scholar",
            venue="NeurIPS",
        )
        result = dedup_papers([p1, p2])
        assert len(result) == 1

    def test_filter_venue(self, mock_pipeline: BibPipeline) -> None:
        """venue filter returns only matching papers."""
        from axm_bib.search import filter_papers

        p1 = self._make_paper("A", venue="NeurIPS 2024", doi="10.1/a")
        p2 = self._make_paper("B", venue="ICML 2024", doi="10.1/b")
        result = filter_papers([p1, p2], venue="NeurIPS")
        assert len(result) == 1
        assert result[0].venue == "NeurIPS 2024"

    def test_filter_year(self, mock_pipeline: BibPipeline) -> None:
        """year filter returns only matching papers."""
        from axm_bib.search import filter_papers

        p1 = self._make_paper("A", year=2023, doi="10.1/a")
        p2 = self._make_paper("B", year=2024, doi="10.1/b")
        result = filter_papers([p1, p2], year=2024)
        assert len(result) == 1
        assert result[0].year == 2024

    def test_filter_author(self, mock_pipeline: BibPipeline) -> None:
        """author filter matches case-insensitively."""
        from axm_bib.search import filter_papers

        p1 = self._make_paper("A", authors=["Alice Smith"], doi="10.1/a")
        p2 = self._make_paper("B", authors=["Bob Jones"], doi="10.1/b")
        result = filter_papers([p1, p2], author="smith")
        assert len(result) == 1

    def test_combined_filters(self, mock_pipeline: BibPipeline) -> None:
        """venue + year filters combined."""
        from axm_bib.search import filter_papers

        p1 = self._make_paper("A", venue="NeurIPS", year=2024, doi="10.1/a")
        p2 = self._make_paper("B", venue="NeurIPS", year=2023, doi="10.1/b")
        p3 = self._make_paper("C", venue="ICML", year=2024, doi="10.1/c")
        result = filter_papers([p1, p2, p3], venue="NeurIPS", year=2024)
        assert len(result) == 1
        assert result[0].title == "A"

    def test_all_sources_fail(self, mock_pipeline: BibPipeline) -> None:
        """All 6 sources fail → empty list, no crash."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=SemanticScholarError("s2"),
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                side_effect=OSError("cr"),
            ),
            patch.object(
                mock_pipeline._dblp,
                "search",
                side_effect=OSError("dblp"),
            ),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(side_effect=OSError("or")))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(side_effect=OSError("ax")))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(side_effect=OSError("oa")))
                ),
            ),
        ):
            result = mock_pipeline.search("test", limit=10)
            assert result.papers == []

    def test_all_sources_same_paper(self, mock_pipeline: BibPipeline) -> None:
        """6 copies with same DOI → 1 deduplicated paper."""
        from axm_bib.search import dedup_papers

        papers = [
            self._make_paper("Same", doi="10.1/dup", source=s)
            for s in [
                "semantic_scholar",
                "crossref",
                "dblp",
                "openreview",
                "arxiv",
                "openalex",
            ]
        ]
        result = dedup_papers(papers)
        assert len(result) == 1

    def test_unicode_author_filter(self, mock_pipeline: BibPipeline) -> None:
        """Author filter works with accented characters."""
        from axm_bib.search import filter_papers

        p = self._make_paper("Paper", authors=["Florian Tramèr"], doi="10.1/t")
        result = filter_papers([p], author="Tramer")
        assert len(result) == 1

    def test_empty_query_with_venue_filter(self, mock_pipeline: BibPipeline) -> None:
        """Empty query with venue-only filter works."""
        from axm_bib.search import filter_papers

        p1 = self._make_paper("A", venue="NeurIPS", doi="10.1/a")
        p2 = self._make_paper("B", venue=None, doi="10.1/b")
        result = filter_papers([p1, p2], venue="NeurIPS")
        assert len(result) == 1


class TestUserAgentUsesVersion:
    """AC3: User-Agent header contains dynamic __version__."""

    def test_user_agent_uses_version(self, mock_pipeline: BibPipeline) -> None:
        """download_pdf sends User-Agent with current __version__."""
        from axm_bib import __version__

        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://example.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="test2024",
                    entry_type="article",
                    raw="@article{test2024, title={T}}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            mock_pipeline.download_pdf(SAMPLE_DOI)

            call_kwargs = mock_retry.call_args
            ua = call_kwargs.kwargs.get("headers", {}).get("User-Agent", "")
            assert __version__ in ua
            assert "axm-bib/" in ua


class TestFetchPdfRetryBehavior:
    """AXM-260: _fetch_pdf_content uses request_with_retry."""

    def test_fetch_pdf_retries_on_503(self, mock_pipeline: BibPipeline) -> None:
        """request_with_retry is called with retry_on=(429, 500, 502, 503)."""
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["https://arxiv.org/pdf/2411.19275"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(mock_pipeline, "resolve", side_effect=ValueError("skip")),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("10.48550/arXiv.2411.19275")
            assert result is not None

            # Verify retry_on includes 503
            call_kwargs = mock_retry.call_args
            assert call_kwargs.kwargs["retry_on"] == (429, 500, 502, 503)
            assert call_kwargs.kwargs["timeout"] == 60.0
            assert call_kwargs.kwargs["follow_redirects"] is True

    def test_fetch_pdf_ssrf_guard_before_retry(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """Unsafe URLs are blocked by _is_safe_url BEFORE request_with_retry."""
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["http://127.0.0.1/internal"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
        ):
            result = mock_pipeline.download_pdf("10.1234/test")
            assert result is None
            # request_with_retry should NOT have been called (SSRF blocked)
            mock_retry.assert_not_called()


class TestDownloadPdfSafety:
    """Tests for SSRF safety and exception narrowing."""

    def test_skips_unsafe_urls(
        self,
        mock_pipeline: BibPipeline,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Unsafe URLs are skipped with a warning log."""
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["http://127.0.0.1/secret"],
            ),
            caplog.at_level(logging.WARNING, logger="axm_bib.pipeline"),
        ):
            result = mock_pipeline.download_pdf("10.1234/test")
            assert result is None
            assert any("unsafe" in r.message.lower() for r in caplog.records)

    def test_lazy_bibtex_not_called_when_all_fail(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """CrossRef.doi_to_bibtex is NOT called if all URLs fail."""
        import httpx as real_httpx

        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://example.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
            ) as mock_bibtex,
        ):
            fail_exc = real_httpx.HTTPStatusError(
                "404", request=MagicMock(), response=MagicMock()
            )
            mock_retry.side_effect = fail_exc

            with pytest.raises(real_httpx.HTTPStatusError):
                mock_pipeline.download_pdf("10.1234/test")

            mock_bibtex.assert_not_called()

    def test_permission_error_propagates(self, mock_pipeline: BibPipeline) -> None:
        """PermissionError on storage is NOT caught, it propagates."""
        with (
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=["https://example.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="test2024",
                    entry_type="article",
                    raw="@article{test2024, title={T}}",
                    doi=SAMPLE_DOI,
                ),
            ),
            patch.object(
                mock_pipeline._storage,
                "save",
                side_effect=PermissionError("read-only fs"),
            ),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            with pytest.raises(PermissionError, match="read-only"):
                mock_pipeline.download_pdf(SAMPLE_DOI)


# ── AXM-252 multi-source download tests ──────────────────────────────────


class TestDownloadPdfMultiSource:
    """Tests for AXM-252 multi-source PDF download + any-ID input."""

    def test_download_arxiv_by_id(self, mock_pipeline: BibPipeline) -> None:
        """download_pdf('arXiv:2503.18813') builds arXiv PDF URL."""
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["https://arxiv.org/pdf/2503.18813"],
            ) as mock_build,
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(mock_pipeline, "resolve", side_effect=ValueError("no bib")),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("arXiv:2503.18813")
            assert result is not None
            assert result.is_open_access is True
            assert result.doi is None  # arXiv has no DOI
            mock_build.assert_called_once()

    def test_pdf_url_chain_priority(self, mock_pipeline: BibPipeline) -> None:
        """S2 OA URL is tried first in the URL chain."""
        s2_url = "https://s2.com/oa/paper.pdf"
        unpaywall_url = "https://unpaywall.org/paper.pdf"

        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                return_value=[
                    Paper(
                        title="Test",
                        authors=["A"],
                        open_access_pdf=s2_url,
                        source="semantic_scholar",
                    )
                ],
            ),
            patch.object(
                mock_pipeline._unpaywall,
                "get_pdf_urls",
                return_value=[unpaywall_url],
            ),
        ):
            from axm_bib.resolver import IdentifierType

            urls = mock_pipeline._build_pdf_urls(
                "10.1234/test", IdentifierType.DOI, "10.1234/test"
            )
            assert urls[0] == s2_url  # S2 first
            assert unpaywall_url in urls  # Unpaywall also present

    def test_download_uses_resolve_for_bibtex(self, mock_pipeline: BibPipeline) -> None:
        """download_pdf uses resolve() instead of crossref.doi_to_bibtex."""
        entry = BibEntry(
            key="test2024",
            entry_type="article",
            raw="@article{test2024, title={T}}",
            doi=SAMPLE_DOI,
        )
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["https://example.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(mock_pipeline, "resolve", return_value=entry) as mock_resolve,
            patch.object(mock_pipeline._crossref, "doi_to_bibtex") as mock_crossref,
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)
            mock_resolve.assert_called_once_with(SAMPLE_DOI)
            mock_crossref.assert_not_called()
            assert result is not None
            assert result.bibtex_path is not None

    def test_download_doi_backward_compat(self, mock_pipeline: BibPipeline) -> None:
        """DOI-based download still works as before (backward compat)."""
        entry = BibEntry(
            key="test2024",
            entry_type="article",
            raw="@article{test2024, title={T}}",
            doi=SAMPLE_DOI,
        )
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["https://example.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(mock_pipeline, "resolve", return_value=entry),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf(SAMPLE_DOI)
            assert result is not None
            assert result.doi == SAMPLE_DOI
            assert result.is_open_access is True

    def test_no_bibtex_still_downloads(self, mock_pipeline: BibPipeline) -> None:
        """If resolve() fails, PDF is still downloaded without bibtex_path."""
        with (
            patch.object(
                mock_pipeline,
                "_build_pdf_urls",
                return_value=["https://example.com/paper.pdf"],
            ),
            patch("axm_bib.download.request_with_retry") as mock_retry,
            patch.object(mock_pipeline, "resolve", side_effect=ValueError("fail")),
        ):
            mock_resp = MagicMock()
            mock_resp.headers = {"content-type": "application/pdf"}
            mock_resp.content = _make_real_pdf_bytes()
            mock_resp.raise_for_status = MagicMock()
            mock_retry.return_value = mock_resp

            result = mock_pipeline.download_pdf("arXiv:1234.5678")
            assert result is not None
            assert result.bibtex_path is None
            assert result.doi is None

    def test_ssrf_guard_on_all_urls(self, mock_pipeline: BibPipeline) -> None:
        """SSRF guard blocks malicious URLs from any source."""
        with patch.object(
            mock_pipeline,
            "_build_pdf_urls",
            return_value=[
                "http://169.254.169.254/metadata",
                "http://127.0.0.1/internal",
                "ftp://evil.com/file",
            ],
        ):
            result = mock_pipeline.download_pdf("10.1234/hack")
            assert result is None

    def test_empty_ref_raises(self, mock_pipeline: BibPipeline) -> None:
        """Empty ref raises ValueError."""
        with pytest.raises(ResolutionError, match="identifier is required"):
            mock_pipeline.download_pdf("")


# ── AXM-251 graph tests ──────────────────────────────────────────────────


class TestGraph:
    """BibPipeline.graph() tests — S2 primary, OpenAlex fallback."""

    def test_graph_citations(self, mock_pipeline: BibPipeline) -> None:
        """direction='citations' → returns citing papers from S2."""
        papers = [
            Paper(
                title="Citing Paper",
                authors=["Bob"],
                year=2025,
                doi="10.1234/citing",
                source="semantic_scholar",
            ),
        ]
        with patch.object(
            mock_pipeline._scholar, "citations", return_value=papers
        ) as mock_cite:
            result = mock_pipeline.graph("10.1234/test", direction="citations")
            assert len(result) == 1
            assert result[0].title == "Citing Paper"
            mock_cite.assert_called_once_with("10.1234/test", limit=20)

    def test_graph_references(self, mock_pipeline: BibPipeline) -> None:
        """direction='references' → returns referenced papers from S2."""
        papers = [
            Paper(
                title="Referenced Paper",
                authors=["Charlie"],
                year=2020,
                doi="10.1234/ref",
                source="semantic_scholar",
            ),
        ]
        with patch.object(mock_pipeline._scholar, "references", return_value=papers):
            result = mock_pipeline.graph("10.1234/test", direction="references")
            assert len(result) == 1
            assert result[0].title == "Referenced Paper"

    def test_graph_similar(self, mock_pipeline: BibPipeline) -> None:
        """direction='similar' → returns recommended papers from S2."""
        papers = [
            Paper(
                title="Similar Paper",
                authors=["Dave"],
                year=2024,
                doi="10.1234/sim",
                source="semantic_scholar",
            ),
        ]
        with patch.object(
            mock_pipeline._scholar, "recommendations", return_value=papers
        ):
            result = mock_pipeline.graph("10.1234/test", direction="similar")
            assert len(result) == 1
            assert result[0].title == "Similar Paper"

    def test_graph_s2_fails_openalex_fallback(self, mock_pipeline: BibPipeline) -> None:
        """S2 fails for citations → OpenAlex fallback succeeds."""
        oa_paper = Paper(
            title="OA Citing Paper",
            authors=["Eve"],
            year=2025,
            doi="10.1234/oa",
            source="openalex",
            openalex_id="W999",
        )
        search_paper = Paper(
            title="Target Paper",
            authors=["Alice"],
            year=2024,
            doi="10.1234/test",
            source="openalex",
            openalex_id="W111",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "citations",
                side_effect=SemanticScholarError("504 timeout"),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(
                        search=MagicMock(return_value=[search_paper]),
                        cited_by=MagicMock(return_value=[oa_paper]),
                    )
                ),
            ),
        ):
            result = mock_pipeline.graph("10.1234/test", direction="citations")
            assert len(result) == 1
            assert result[0].title == "OA Citing Paper"

    def test_graph_both_fail(self, mock_pipeline: BibPipeline) -> None:
        """S2 + OA both fail → raises error."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "citations",
                side_effect=SemanticScholarError("S2 down"),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(
                        search=MagicMock(return_value=[]),
                    )
                ),
            ),
            pytest.raises(IdentifierNotFound, match="not found"),
        ):
            mock_pipeline.graph("10.1234/test", direction="citations")

    def test_graph_invalid_direction(self, mock_pipeline: BibPipeline) -> None:
        """Invalid direction → ValueError."""
        with pytest.raises(ValueError, match="invalid direction"):
            mock_pipeline.graph("10.1234/test", direction="backward")

    def test_graph_empty_paper_id(self, mock_pipeline: BibPipeline) -> None:
        """Empty paper_id → ValueError."""
        with pytest.raises(ValueError, match="paper_id"):
            mock_pipeline.graph("")

    def test_graph_doi_input(self, mock_pipeline: BibPipeline) -> None:
        """DOI passed to S2 as paper_id."""
        with patch.object(
            mock_pipeline._scholar, "citations", return_value=[]
        ) as mock_cite:
            mock_pipeline.graph("10.1145/363235.363259", direction="citations")
            mock_cite.assert_called_once_with("10.1145/363235.363259", limit=20)

    def test_graph_limit_clamped(self, mock_pipeline: BibPipeline) -> None:
        """limit=10000 → clamped to 9999."""
        with patch.object(
            mock_pipeline._scholar, "citations", return_value=[]
        ) as mock_cite:
            mock_pipeline.graph("10.1234/test", direction="citations", limit=10000)
            mock_cite.assert_called_once_with("10.1234/test", limit=9999)

    def test_graph_similar_no_fallback(self, mock_pipeline: BibPipeline) -> None:
        """S2 fails for 'similar' → no OA fallback, re-raises directly."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "recommendations",
                side_effect=SemanticScholarError("S2 down"),
            ),
            pytest.raises(SemanticScholarError, match="S2 down"),
        ):
            mock_pipeline.graph("10.1234/test", direction="similar")


# ── AXM-254: venue enrichment tests ──────────────────────────────────────


class TestVenueEnrichment:
    """AXM-254: arXiv BibEntry enriched with venue from S2/DBLP."""

    _RAW_ARXIV = (
        "@misc{khot2022decomposed,\n"
        "  title={Decomposed Prompting},\n"
        "  author={Khot, Tushar},\n"
        "  year={2022},\n"
        "}"
    )

    @staticmethod
    def _arxiv_entry(**overrides: object) -> BibEntry:
        defaults: dict[str, object] = {
            "key": "khot2022decomposed",
            "entry_type": "misc",
            "raw": TestVenueEnrichment._RAW_ARXIV,
            "doi": None,
            "title": "Decomposed Prompting",
            "authors": ["Tushar Khot"],
            "year": 2022,
        }
        defaults.update(overrides)
        return BibEntry(**defaults)  # type: ignore[arg-type]

    def test_enrich_promotes_to_inproceedings(
        self,
        mock_pipeline: BibPipeline,
    ) -> None:
        """S2 returns venue='NeurIPS' → entry_type promoted to inproceedings."""
        arxiv_entry = self._arxiv_entry()
        s2_paper = Paper(
            title="Decomposed Prompting",
            authors=["Tushar Khot"],
            year=2022,
            venue="NeurIPS",
            source="semantic_scholar",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                return_value=s2_paper,
            ),
            patch.object(mock_pipeline.arxiv, "bibtex", return_value=arxiv_entry),
        ):
            result = mock_pipeline.resolve("arXiv:2210.02406")

        assert result.entry_type == "inproceedings"
        assert "booktitle = {NeurIPS}" in result.raw
        assert "@inproceedings{" in result.raw

    def test_enrich_fallback_to_dblp(
        self,
        mock_pipeline: BibPipeline,
    ) -> None:
        """S2 fails, DBLP has venue → enriched from DBLP."""
        arxiv_entry = self._arxiv_entry()
        dblp_paper = Paper(
            title="Decomposed Prompting",
            authors=["Tushar Khot"],
            year=2022,
            venue="ICLR",
            source="dblp",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(
                mock_pipeline._dblp,
                "search",
                return_value=[dblp_paper],
            ),
            patch.object(mock_pipeline.arxiv, "bibtex", return_value=arxiv_entry),
        ):
            result = mock_pipeline.resolve("arXiv:2210.02406")

        assert result.entry_type == "inproceedings"
        assert "booktitle = {ICLR}" in result.raw

    def test_enrich_no_venue_keeps_original(
        self,
        mock_pipeline: BibPipeline,
    ) -> None:
        """Both S2 and DBLP have no venue → original @misc returned."""
        arxiv_entry = self._arxiv_entry()
        empty_paper = Paper(
            title="Decomposed Prompting",
            authors=["Tushar Khot"],
            year=2022,
            venue=None,
            source="semantic_scholar",
        )
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                return_value=empty_paper,
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[]),
            patch.object(mock_pipeline.arxiv, "bibtex", return_value=arxiv_entry),
        ):
            result = mock_pipeline.resolve("arXiv:2210.02406")

        assert result.entry_type == "misc"
        assert result.raw == self._RAW_ARXIV

    def test_enrich_network_error_graceful(
        self,
        mock_pipeline: BibPipeline,
    ) -> None:
        """Both S2 and DBLP raise → original entry returned unchanged."""
        arxiv_entry = self._arxiv_entry()
        with (
            patch.object(
                mock_pipeline._scholar,
                "get_paper",
                side_effect=SemanticScholarError("S2 down"),
            ),
            patch.object(
                mock_pipeline._dblp,
                "search",
                side_effect=OSError("DBLP down"),
            ),
            patch.object(mock_pipeline.arxiv, "bibtex", return_value=arxiv_entry),
        ):
            result = mock_pipeline.resolve("arXiv:2210.02406")

        assert result.entry_type == "misc"
        assert result.raw == self._RAW_ARXIV
        assert result.key == "khot2022decomposed"


class TestSearchRelevance:
    """Tests for _rank_by_relevance and search relevance integration."""

    def test_rank_exact_match_first(self) -> None:
        """Exact title match ranks first among noise."""
        from axm_bib.search import rank_by_relevance

        exact = Paper(
            title="Decomposed Prompting: A Modular Approach for Solving Complex Tasks",
            authors=["Khot"],
            source="dblp",
        )
        noise = [
            Paper(
                title="Figure 35. Teachers who 'frequently' ask",
                authors=["A"],
                source="crossref",
            ),
            Paper(
                title="Complex task optimization methods",
                authors=["B"],
                source="crossref",
            ),
            Paper(
                title="A survey of modular neural networks",
                authors=["C"],
                source="crossref",
            ),
        ]
        papers = [*noise, exact]
        ranked = rank_by_relevance(
            "Decomposed Prompting: A Modular Approach for Solving Complex Tasks",
            papers,
        )
        assert len(ranked) >= 1
        assert ranked[0].title == exact.title

    def test_rank_drops_below_threshold(self) -> None:
        """Papers with similarity < 0.4 are filtered out."""
        from axm_bib.search import rank_by_relevance

        good1 = Paper(
            title="Decomposed Prompting",
            authors=["A"],
            source="dblp",
        )
        good2 = Paper(
            title="Decomposed Prompting: A Modular Approach",
            authors=["B"],
            source="dblp",
        )
        noise = [
            Paper(title="pH in soil", authors=["C"], source="crossref"),
            Paper(title="Mars rocks", authors=["D"], source="crossref"),
            Paper(title="Bird songs", authors=["E"], source="crossref"),
        ]
        ranked = rank_by_relevance(
            "Decomposed Prompting: A Modular Approach for Solving Complex Tasks",
            [good1, good2, *noise],
        )
        # Only the two good papers should survive
        assert len(ranked) == 2
        titles = [p.title for p in ranked]
        assert "Introduction to quantum computing" not in titles
        assert "Underwater basket weaving fundamentals" not in titles

    def test_rank_empty_query_passthrough(self) -> None:
        """Empty query returns all papers unchanged."""
        from axm_bib.search import rank_by_relevance

        papers = [
            Paper(title="Paper A", authors=["A"], source="semantic_scholar"),
            Paper(title="Paper B", authors=["B"], source="semantic_scholar"),
            Paper(title="Paper C", authors=["C"], source="semantic_scholar"),
            Paper(title="Paper D", authors=["D"], source="semantic_scholar"),
            Paper(title="Paper E", authors=["E"], source="semantic_scholar"),
        ]
        ranked = rank_by_relevance("", papers)
        assert ranked == papers

    def test_rank_unicode_titles(self) -> None:
        """Unicode accents in titles are handled correctly."""
        from axm_bib.search import rank_by_relevance

        paper = Paper(
            title="Réseau de neurones récurrents pour la détection",
            authors=["Lévy"],
            source="hal",
        )
        ranked = rank_by_relevance(
            "Réseau de neurones récurrents pour la détection",
            [paper],
        )
        assert len(ranked) == 1
        assert ranked[0].title == paper.title

    def test_search_relevance_dblp_over_crossref(
        self, mock_pipeline: BibPipeline
    ) -> None:
        """DBLP exact match ranks above CrossRef noise in full search."""
        exact_paper = Paper(
            title="Decomposed Prompting: A Modular Approach for Solving Complex Tasks",
            authors=["Tushar Khot"],
            year=2023,
            venue="ICLR",
            source="dblp",
            dblp_key="conf/iclr/KhotTFF0CS23",
        )
        crossref_noise = [
            Paper(
                title=f"Irrelevant CrossRef Paper {i}",
                authors=[f"Author {i}"],
                source="crossref",
            )
            for i in range(5)
        ]
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=SemanticScholarError("rate limited"),
            ),
            patch.object(
                mock_pipeline._crossref,
                "search_by_title",
                return_value=crossref_noise,
            ),
            patch.object(mock_pipeline._dblp, "search", return_value=[exact_paper]),
            patch.object(
                type(mock_pipeline),
                "openreview",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "arxiv",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
            patch.object(
                type(mock_pipeline),
                "openalex",
                new_callable=lambda: property(
                    lambda self: MagicMock(search=MagicMock(return_value=[]))
                ),
            ),
        ):
            result = mock_pipeline.search(
                "Decomposed Prompting: A Modular Approach for Solving Complex Tasks",
                limit=5,
            )
            # DBLP exact match should be present and first
            assert len(result.papers) >= 1
            assert result.papers[0].title == exact_paper.title
            assert result.papers[0].dblp_key == "conf/iclr/KhotTFF0CS23"

    def test_rank_all_below_threshold(self) -> None:
        """All papers below threshold → empty list."""
        from axm_bib.search import rank_by_relevance

        noise = [
            Paper(
                title="Quantum computing overview",
                authors=["A"],
                source="semantic_scholar",
            ),
            Paper(
                title="Underwater basket weaving",
                authors=["B"],
                source="semantic_scholar",
            ),
            Paper(
                title="Advanced pottery techniques",
                authors=["C"],
                source="semantic_scholar",
            ),
        ]
        ranked = rank_by_relevance(
            "Decomposed Prompting: A Modular Approach for Solving Complex Tasks",
            noise,
        )
        assert ranked == []

    def test_rank_special_chars_in_query(self) -> None:
        """Special characters in query don't crash."""
        from axm_bib.search import rank_by_relevance

        paper = Paper(
            title="What's up with NLP?",
            authors=["A"],
            source="semantic_scholar",
        )
        ranked = rank_by_relevance("What's up?", [paper])
        # Should not crash; paper may or may not survive threshold
        assert isinstance(ranked, list)

    def test_rank_short_query(self) -> None:
        """Short keyword queries don't over-filter."""
        from axm_bib.search import rank_by_relevance

        paper = Paper(
            title="GPT-4: Technical Report",
            authors=["OpenAI"],
            source="semantic_scholar",
        )
        ranked = rank_by_relevance("GPT", [paper])
        # Low threshold (0.3) should allow partial matches
        assert isinstance(ranked, list)

    def test_rank_venue_tiebreaker(self) -> None:
        """At equal similarity, published papers rank before preprints."""
        from axm_bib.search import rank_by_relevance

        # Both have identical titles → identical similarity ratio
        preprint = Paper(
            title="Decomposed Prompting",
            authors=["Khot"],
            source="arxiv",
            venue=None,
        )
        published = Paper(
            title="Decomposed Prompting",
            authors=["Khot"],
            source="dblp",
            venue="ICLR",
        )
        ranked = rank_by_relevance(
            "Decomposed Prompting",
            [preprint, published],  # preprint inserted first
        )
        assert len(ranked) == 2
        # Published (with venue) should come first despite same ratio
        assert ranked[0].venue == "ICLR"
        assert ranked[1].venue is None


class TestS2ApiKey:
    """S2_API_KEY env var passed to SemanticScholarClient."""

    def test_api_key_from_env(self, tmp_path: Path) -> None:
        """S2_API_KEY env var is passed to SemanticScholarClient."""
        with patch.dict(
            os.environ,
            {"S2_API_KEY": "test-key-123", "UNPAYWALL_EMAIL": "test@example.com"},
        ):
            pipeline = BibPipeline(storage=FileSystemStorage(tmp_path))
            assert pipeline._scholar._headers.get("x-api-key") == "test-key-123"

    def test_no_api_key(self, tmp_path: Path) -> None:
        """Without S2_API_KEY, no x-api-key header is set."""
        env = os.environ.copy()
        env.pop("S2_API_KEY", None)
        env.setdefault("UNPAYWALL_EMAIL", "test@example.com")
        with patch.dict(os.environ, env, clear=True):
            pipeline = BibPipeline(storage=FileSystemStorage(tmp_path))
            assert "x-api-key" not in pipeline._scholar._headers
