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

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

from __future__ import annotations

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

import pytest

from axm_bib.models import BibEntry, DownloadResult, Paper
from axm_bib.pipeline import BibPipeline, NoDOIError
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."""

    def test_search_returns_papers(self, mock_pipeline: BibPipeline) -> None:
        """Title search returns list[Paper] via Semantic Scholar."""
        with patch.object(mock_pipeline._scholar, "search_paper") as mock_search:
            mock_search.return_value = [
                Paper(
                    title="Deep Learning",
                    authors=["John Smith"],
                    year=2024,
                    doi=SAMPLE_DOI,
                    source="semantic_scholar",
                )
            ]
            papers = mock_pipeline.search("Deep Learning", limit=5)
            assert len(papers) == 1
            assert papers[0].doi == SAMPLE_DOI

    def test_search_fallback_crossref(self, mock_pipeline: BibPipeline) -> None:
        """When Semantic Scholar fails, falls back to CrossRef search."""
        with (
            patch.object(
                mock_pipeline._scholar,
                "search_paper",
                side_effect=Exception("S2 down"),
            ),
            patch.object(mock_pipeline._crossref, "search_by_title") as mock_cr_search,
        ):
            mock_cr_search.return_value = [
                Paper(
                    title="Deep Learning",
                    authors=["John Smith"],
                    doi=SAMPLE_DOI,
                    source="crossref",
                )
            ]
            papers = mock_pipeline.search("Deep Learning", limit=5)
            assert len(papers) == 1
            assert papers[0].source == "crossref"

    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:
            mock_s.return_value = []
            mock_pipeline.search("test", limit=200)
            mock_s.assert_called_once_with("test", limit=100)


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 TestSearchAndResolve:
    """BibPipeline.search_and_resolve() tests."""

    def test_search_and_resolve(self, mock_pipeline: BibPipeline) -> None:
        """title → Paper → DOI → BibEntry full chain."""
        paper = Paper(
            title="Deep Learning",
            authors=["John Smith"],
            year=2024,
            doi=SAMPLE_DOI,
            source="semantic_scholar",
        )
        entry = BibEntry(
            key="smith2024deep",
            entry_type="article",
            raw=SAMPLE_CROSSREF_BIBTEX,
            doi=SAMPLE_DOI,
            authors=["John Smith"],
            year=2024,
        )
        with (
            patch.object(mock_pipeline._scholar, "search_paper", return_value=[paper]),
            patch.object(mock_pipeline._crossref, "doi_to_bibtex", return_value=entry),
        ):
            result = mock_pipeline.search_and_resolve("Deep Learning")
            assert isinstance(result, BibEntry)
            assert result.doi == SAMPLE_DOI

    def test_search_and_resolve_no_doi(self, mock_pipeline: BibPipeline) -> None:
        """Title found but no DOI raises NoDOIError."""
        paper_no_doi = Paper(
            title="Workshop Paper",
            authors=["Alice"],
            source="semantic_scholar",
        )
        with patch.object(
            mock_pipeline._scholar, "search_paper", return_value=[paper_no_doi]
        ):
            with pytest.raises(NoDOIError):
                mock_pipeline.search_and_resolve("Workshop Paper")


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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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.pipeline.httpx") as mock_httpx,
            patch.object(
                mock_pipeline._crossref,
                "doi_to_bibtex",
                return_value=BibEntry(
                    key="smith2024deep",
                    entry_type="article",
                    raw="@article{...}",
                    doi=SAMPLE_DOI,
                ),
            ),
        ):
            forbidden = MagicMock()
            forbidden.raise_for_status.side_effect = 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_httpx.get.side_effect = [forbidden, 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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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_httpx.get.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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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 TestExtractArxivId:
    """Tests for _extract_arxiv_id helper."""

    def test_standard_arxiv_doi(self) -> None:
        """10.48550/arXiv.2411.19275 → 2411.19275."""
        from axm_bib.pipeline import _extract_arxiv_id

        assert _extract_arxiv_id("10.48550/arXiv.2411.19275") == "2411.19275"

    def test_old_arxiv_format(self) -> None:
        """10.48550/arXiv.hep-th/0601001 → hep-th/0601001."""
        from axm_bib.pipeline import _extract_arxiv_id

        assert _extract_arxiv_id("10.48550/arXiv.hep-th/0601001") == "hep-th/0601001"

    def test_non_arxiv_doi(self) -> None:
        """Non-arXiv DOI → None."""
        from axm_bib.pipeline import _extract_arxiv_id

        assert _extract_arxiv_id("10.1145/363235.363259") is None

    def test_case_insensitive(self) -> None:
        """10.48550/ARXIV.123 → 123."""
        from axm_bib.pipeline import _extract_arxiv_id

        assert _extract_arxiv_id("10.48550/ARXIV.2411.19275") == "2411.19275"


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.pipeline.httpx") as mock_httpx,
            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_httpx.get.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 httpx.get with arxiv URL
            mock_httpx.get.assert_called_once()
            call_url = mock_httpx.get.call_args[0][0]
            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.pipeline.httpx") as mock_httpx,
            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_httpx.get.return_value = mock_resp

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

            assert result is not None
            call_url = mock_httpx.get.call_args[0][0]
            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.pipeline.httpx") as mock_httpx,
            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_httpx.get.return_value = mock_resp

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

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