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

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

import httpx
import pytest

from axm_bib.clients.openalex import OpenAlexClient, OpenAlexError

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

SAMPLE_SEARCH_RESPONSE = {
    "results": [
        {
            "id": "https://openalex.org/W2741809807",
            "title": "Attention Is All You Need",
            "publication_year": 2017,
            "type": "proceedings-article",
            "ids": {
                "openalex": "https://openalex.org/W2741809807",
                "doi": "https://doi.org/10.48550/arXiv.1706.03762",
                "mag": "2741809807",
            },
            "authorships": [
                {
                    "author": {
                        "display_name": "Ashish Vaswani",
                    },
                },
                {
                    "author": {
                        "display_name": "Noam Shazeer",
                    },
                },
            ],
            "primary_location": {
                "source": {
                    "display_name": "Neural Information Processing Systems",
                },
            },
            "cited_by_count": 98234,
            "abstract_inverted_index": {
                "The": [0, 15],
                "dominant": [1],
                "sequence": [2],
            },
        },
    ],
    "meta": {"count": 1},
}

SAMPLE_SINGLE_WORK = {
    "id": "https://openalex.org/W2741809807",
    "title": "Attention Is All You Need",
    "publication_year": 2017,
    "type": "proceedings-article",
    "ids": {
        "openalex": "https://openalex.org/W2741809807",
        "doi": "https://doi.org/10.48550/arXiv.1706.03762",
        "mag": "2741809807",
    },
    "authorships": [
        {"author": {"display_name": "Ashish Vaswani"}},
        {"author": {"display_name": "Noam Shazeer"}},
    ],
    "primary_location": {
        "source": {"display_name": "NeurIPS"},
    },
    "cited_by_count": 98234,
    "abstract_inverted_index": None,
}


def _mock_response(
    json_data: dict[str, Any] | None = None,
    status_code: int = 200,
) -> MagicMock:
    """Create a mock httpx.Response."""
    resp = MagicMock(spec=httpx.Response)
    resp.status_code = status_code
    resp.headers = {}
    resp.json.return_value = json_data or {}
    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


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


class TestOpenAlexSearch:
    """Search endpoint tests."""

    @patch("axm_bib.http.httpx.request")
    def test_search_returns_papers(self, mock_get: MagicMock) -> None:
        """Search returns list of Paper objects."""
        mock_get.return_value = _mock_response(SAMPLE_SEARCH_RESPONSE)

        client = OpenAlexClient()
        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) == 2
        assert p.year == 2017
        assert p.source == "openalex"
        assert "api.openalex.org/works" in mock_get.call_args[0][1]

    @patch("axm_bib.http.httpx.request")
    def test_crossref_ids_extracted(self, mock_get: MagicMock) -> None:
        """DOI, MAG, and OpenAlex IDs extracted from ids field."""
        mock_get.return_value = _mock_response(SAMPLE_SEARCH_RESPONSE)

        client = OpenAlexClient()
        papers = client.search("attention")

        p = papers[0]
        assert p.doi == "10.48550/arXiv.1706.03762"
        assert p.openalex_id == "W2741809807"

    @patch("axm_bib.http.httpx.request")
    def test_venue_extracted(self, mock_get: MagicMock) -> None:
        """Venue extracted from primary_location.source."""
        mock_get.return_value = _mock_response(SAMPLE_SEARCH_RESPONSE)

        client = OpenAlexClient()
        papers = client.search("attention")

        assert papers[0].venue == "Neural Information Processing Systems"

    @patch("axm_bib.http.httpx.request")
    def test_empty_results(self, mock_get: MagicMock) -> None:
        """Empty results return empty list."""
        mock_get.return_value = _mock_response({"results": [], "meta": {"count": 0}})

        client = OpenAlexClient()
        papers = client.search("nonexistent paper xyz")

        assert papers == []

    @patch("axm_bib.http.httpx.request")
    def test_api_error(self, mock_get: MagicMock) -> None:
        """HTTP error raises OpenAlexError."""
        mock_get.return_value = _mock_response(status_code=500)

        client = OpenAlexClient()
        with pytest.raises(OpenAlexError, match="failed"):
            client.search("test")

    @patch("axm_bib.http.httpx.request")
    def test_empty_query_returns_empty(self, mock_get: MagicMock) -> None:
        """Empty query returns empty list without API call."""
        client = OpenAlexClient()
        papers = client.search("  ")

        assert papers == []
        mock_get.assert_not_called()


class TestOpenAlexGetById:
    """Direct lookup tests."""

    @patch("axm_bib.http.httpx.request")
    def test_get_by_id(self, mock_get: MagicMock) -> None:
        """Direct lookup returns Paper."""
        mock_get.return_value = _mock_response(SAMPLE_SINGLE_WORK)

        client = OpenAlexClient()
        paper = client.get_by_id("W2741809807")

        assert paper.title == "Attention Is All You Need"
        assert paper.openalex_id == "W2741809807"
        assert paper.source == "openalex"

    @patch("axm_bib.http.httpx.request")
    def test_get_by_id_not_found(self, mock_get: MagicMock) -> None:
        """404 raises OpenAlexError."""
        mock_get.return_value = _mock_response(status_code=404)

        client = OpenAlexClient()
        with pytest.raises(OpenAlexError, match="failed"):
            client.get_by_id("W0000000000")

    @patch("axm_bib.http.httpx.request")
    def test_polite_header(self, mock_get: MagicMock) -> None:
        """Requests include polite User-Agent with mailto."""
        mock_get.return_value = _mock_response(SAMPLE_SINGLE_WORK)

        client = OpenAlexClient(email="test@example.com")
        client.get_by_id("W2741809807")

        headers = mock_get.call_args[1].get("headers", {})
        assert "mailto:test@example.com" in headers.get("User-Agent", "")


class TestOpenAlexEdgeCases:
    """Edge cases."""

    @patch("axm_bib.http.httpx.request")
    def test_missing_ids_field(self, mock_get: MagicMock) -> None:
        """Work with no ids field doesn't crash."""
        work = {**SAMPLE_SINGLE_WORK, "ids": {}}
        mock_get.return_value = _mock_response(work)

        client = OpenAlexClient()
        paper = client.get_by_id("W1234")

        assert paper.doi is None
        assert paper.openalex_id is None

    @patch("axm_bib.http.httpx.request")
    def test_missing_location(self, mock_get: MagicMock) -> None:
        """Work with no primary_location doesn't crash."""
        work = {**SAMPLE_SINGLE_WORK, "primary_location": None}
        mock_get.return_value = _mock_response(work)

        client = OpenAlexClient()
        paper = client.get_by_id("W1234")

        assert paper.venue is None

    @patch("axm_bib.http.httpx.request")
    def test_abstract_reconstruction(self, mock_get: MagicMock) -> None:
        """Abstract reconstructed from inverted index."""
        mock_get.return_value = _mock_response(SAMPLE_SEARCH_RESPONSE)

        client = OpenAlexClient()
        papers = client.search("attention")

        # Inverted index: {"The": [0, 15], "dominant": [1], "sequence": [2]}
        assert papers[0].abstract is not None
        assert "The" in papers[0].abstract
        assert "dominant" in papers[0].abstract
