"""ArXivClient unit tests — mocked httpx, following test_semantic_scholar pattern."""

from __future__ import annotations

from unittest.mock import MagicMock, call, patch

import httpx
import pytest

from axm_bib.clients.arxiv import ArXivClient, ArXivError

# ── Helpers ──────────────────────────────────────────────────────────────

SAMPLE_BIBTEX = (
    "@misc{vaswani2017attention,\n"
    "  title={Attention Is All You Need},\n"
    "  author={Ashish Vaswani and Noam Shazeer and Niki Parmar},\n"
    "  year={2017},\n"
    "  eprint={1706.03762},\n"
    "  archivePrefix={arXiv},\n"
    "  primaryClass={cs.CL}\n"
    "}"
)


SAMPLE_ATOM_XML = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
      xmlns:arxiv="http://arxiv.org/schemas/atom">
  <entry>
    <id>http://arxiv.org/abs/1706.03762v7</id>
    <title>Attention Is All You Need</title>
    <summary>The dominant sequence transduction models are based on complex...</summary>
    <author><name>Ashish Vaswani</name></author>
    <author><name>Noam Shazeer</name></author>
    <author><name>Niki Parmar</name></author>
    <published>2017-06-12T17:57:34Z</published>
    <arxiv:primary_category term="cs.CL"/>
    <link href="http://arxiv.org/pdf/1706.03762v7"
          title="pdf" type="application/pdf"
          rel="related"/>
  </entry>
</feed>"""


def _mock_response(text: str, status_code: int = 200) -> MagicMock:
    """Create a mock httpx.Response."""
    resp = MagicMock(spec=httpx.Response)
    resp.status_code = status_code
    resp.headers = {}
    resp.text = text
    resp.raise_for_status = MagicMock()
    if status_code >= 400:
        resp.raise_for_status.side_effect = httpx.HTTPStatusError(
            message=f"HTTP {status_code}",
            request=MagicMock(spec=httpx.Request),
            response=resp,
        )
    return resp


@pytest.fixture(autouse=True)
def _reset_rate_limiter() -> None:
    """Reset class-level rate limiter between tests."""
    ArXivClient._last_request_time = 0.0


# ── Test Classes ─────────────────────────────────────────────────────────


class TestArXivBibtex:
    """BibTeX endpoint tests."""

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_bibtex_fetch(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """Fetch valid BibTeX from native endpoint."""
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client = ArXivClient()
        entry = client.bibtex("1706.03762")

        assert entry.key == "vaswani2017attention"
        assert entry.entry_type == "misc"
        assert entry.title == "Attention Is All You Need"
        assert len(entry.authors) == 3
        assert entry.year == 2017
        assert "arxiv.org/bibtex/1706.03762" in mock_get.call_args[0][1]

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_bibtex_not_found(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """404 response raises ArXivError."""
        mock_get.return_value = _mock_response("", status_code=404)

        client = ArXivClient()
        with pytest.raises(ArXivError, match="failed"):
            client.bibtex("nonexistent")

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_bibtex_strips_prefix(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """arXiv: prefix is stripped from ID."""
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client = ArXivClient()
        client.bibtex("arXiv:1706.03762")

        url = mock_get.call_args[0][1]
        assert "arxiv.org/bibtex/1706.03762" in url
        assert "arXiv:" not in url


class TestArXivSearch:
    """Atom XML search tests."""

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_search_returns_papers(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """Search parses Atom XML into Paper objects."""
        mock_get.return_value = _mock_response(SAMPLE_ATOM_XML)

        client = ArXivClient()
        papers = client.search("attention", limit=5)

        assert len(papers) == 1
        p = papers[0]
        assert p.title == "Attention Is All You Need"
        assert len(p.authors) == 3
        assert p.arxiv_id == "1706.03762v7"
        assert p.year == 2017
        assert p.source == "arxiv"
        assert p.abstract is not None
        assert p.open_access_pdf is not None
        assert "arxiv.org/pdf/" in p.open_access_pdf

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_search_empty_query(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """Empty query returns empty list without calling API."""
        client = ArXivClient()
        papers = client.search("  ")

        assert papers == []
        mock_get.assert_not_called()


class TestArXivIDNormalization:
    """arXiv ID normalization tests."""

    def test_plain_id(self) -> None:
        """Plain ID passes through."""
        assert ArXivClient.normalize_id("1706.03762") == "1706.03762"

    def test_prefix_stripped(self) -> None:
        """arXiv: prefix is stripped."""
        assert ArXivClient.normalize_id("arXiv:1706.03762") == "1706.03762"

    def test_url_stripped(self) -> None:
        """Full URL is normalized to bare ID."""
        assert (
            ArXivClient.normalize_id("https://arxiv.org/abs/1706.03762") == "1706.03762"
        )

    def test_old_format_preserved(self) -> None:
        """Old format IDs (math/0102101) are preserved."""
        assert ArXivClient.normalize_id("math/0102101") == "math/0102101"


class TestArXivPDFUrl:
    """PDF URL construction tests."""

    def test_pdf_url_construction(self) -> None:
        """Standard ID produces correct PDF URL."""
        assert (
            ArXivClient.pdf_url("1706.03762") == "https://arxiv.org/pdf/1706.03762.pdf"
        )

    def test_old_id_format(self) -> None:
        """Old format ID produces correct PDF URL."""
        assert (
            ArXivClient.pdf_url("math/0102101")
            == "https://arxiv.org/pdf/math/0102101.pdf"
        )

    def test_id_with_version(self) -> None:
        """Version is preserved in PDF URL."""
        assert (
            ArXivClient.pdf_url("1706.03762v5")
            == "https://arxiv.org/pdf/1706.03762v5.pdf"
        )


class TestArXivEdgeCases:
    """Edge case tests."""

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_xml_parse_error(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """Malformed XML raises ArXivError."""
        mock_get.return_value = _mock_response("<not valid xml>>>")

        client = ArXivClient()
        with pytest.raises(ArXivError, match="parse"):
            client.search("test")

    @patch("axm_bib.clients.arxiv.time.monotonic", return_value=100.0)
    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    def test_bibtex_version_stripped_for_lookup(
        self,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
        mock_monotonic: MagicMock,
    ) -> None:
        """Version suffix is stripped for BibTeX lookup."""
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client = ArXivClient()
        client.bibtex("1706.03762v5")

        url = mock_get.call_args[0][1]
        # BibTeX endpoint uses versionless ID
        assert "1706.03762v5" not in url or "1706.03762" in url


class TestArXivRateLimiter:
    """Class-level rate limiter tests."""

    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    @patch("axm_bib.clients.arxiv.time.monotonic")
    def test_rate_limit_skips_sleep_if_enough_time_passed(
        self,
        mock_monotonic: MagicMock,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
    ) -> None:
        """No sleep when >= 3 s elapsed since last request."""
        # First call at t=100, enough time since _last_request_time=0.0
        mock_monotonic.return_value = 100.0
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client = ArXivClient()
        client.bibtex("1706.03762")

        mock_sleep.assert_not_called()

    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    @patch("axm_bib.clients.arxiv.time.monotonic")
    def test_rate_limit_sleeps_remaining_delta(
        self,
        mock_monotonic: MagicMock,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
    ) -> None:
        """Sleeps only the remaining delta when < 3 s elapsed."""
        # First call sets _last_request_time = 100.0
        # Second call at t=101 → elapsed=1 → sleep(2)
        mock_monotonic.side_effect = [100.0, 100.0, 101.0, 101.0]
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client = ArXivClient()
        client.bibtex("1706.03762")  # first call
        client.bibtex("1706.03762")  # second call — should sleep(2)

        mock_sleep.assert_called_once_with(2.0)

    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    @patch("axm_bib.clients.arxiv.time.monotonic")
    def test_rate_limit_across_instances(
        self,
        mock_monotonic: MagicMock,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
    ) -> None:
        """Two different clients share the class-level timestamp."""
        # client1 call sets _last_request_time = 100.0
        # client2 call at t=101.5 → elapsed=1.5 → sleep(1.5)
        mock_monotonic.side_effect = [100.0, 100.0, 101.5, 101.5]
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client1 = ArXivClient()
        client2 = ArXivClient()

        client1.bibtex("1706.03762")
        client2.bibtex("1706.03762")

        mock_sleep.assert_called_once_with(1.5)

    @patch("axm_bib.http.httpx.request")
    @patch("axm_bib.clients.arxiv.time.sleep")
    @patch("axm_bib.clients.arxiv.time.monotonic")
    def test_rate_limit_rapid_fire(
        self,
        mock_monotonic: MagicMock,
        mock_sleep: MagicMock,
        mock_get: MagicMock,
    ) -> None:
        """Rapid-fire calls each sleep the correct delta."""
        # Call 1: t=100 (no sleep, first ever), sets _last=100
        # Call 2: t=100.5 (elapsed=0.5, sleep 2.5), sets _last=100.5
        # Call 3: t=101.0 (elapsed=0.5, sleep 2.5), sets _last=101.0
        mock_monotonic.side_effect = [100.0, 100.0, 100.5, 100.5, 101.0, 101.0]
        mock_get.return_value = _mock_response(SAMPLE_BIBTEX)

        client = ArXivClient()
        client.bibtex("1706.03762")
        client.bibtex("1706.03762")
        client.bibtex("1706.03762")

        assert mock_sleep.call_count == 2
        mock_sleep.assert_has_calls([call(2.5), call(2.5)])
