"""OpenAlex API client — cross-referencing & title search.

Endpoints used:
    - Search: ``https://api.openalex.org/works?search={query}``
    - Direct: ``https://api.openalex.org/works/{id}``

OpenAlex indexes 240M+ works with CC0 data.  Its key strength is
**cross-referencing**: given a title it returns DOI, MAG ID, OpenAlex ID,
and can bridge to all other identifier systems.
"""

from __future__ import annotations

import logging
from typing import Any

import httpx

from axm_bib.http import request_with_retry
from axm_bib.models import Paper

__all__ = ["OpenAlexClient", "OpenAlexError"]

logger = logging.getLogger(__name__)


# ── Constants ────────────────────────────────────────────────────────────

_API_BASE = "https://api.openalex.org/works"
_DEFAULT_UA = "axm-bib/1.0 (https://github.com/axm-protocols/axm-bib)"


# ── Exceptions ───────────────────────────────────────────────────────────


class OpenAlexError(Exception):
    """Raised on OpenAlex API failures."""


# ── Client ───────────────────────────────────────────────────────────────


class OpenAlexClient:
    """OpenAlex API client for cross-referencing and title search.

    Args:
        email: Contact email for polite User-Agent header (optional
            but recommended by OpenAlex).
    """

    def __init__(self, email: str | None = None) -> None:
        ua = _DEFAULT_UA
        if email:
            ua = f"axm-bib/1.0 (mailto:{email})"
        self._headers = {"User-Agent": ua}

    # ── Public API ───────────────────────────────────────────────────

    def search(self, query: str, *, limit: int = 5) -> list[Paper]:
        """Search OpenAlex works by title/keywords.

        Args:
            query: Search query string.
            limit: Maximum results (default 5).

        Returns:
            List of ``Paper`` objects.

        Raises:
            OpenAlexError: On HTTP errors.
        """
        if not query.strip():
            return []

        try:
            resp = request_with_retry(
                "GET",
                _API_BASE,
                params={
                    "search": query,
                    "per_page": str(limit),
                },
                headers=self._headers,
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = f"OpenAlex search failed: HTTP {exc.response.status_code}"
            raise OpenAlexError(msg) from exc
        except httpx.RequestError as exc:
            msg = f"OpenAlex search failed: {exc}"
            raise OpenAlexError(msg) from exc

        data = resp.json()
        results = data.get("results", [])
        return [self._normalize(w) for w in results if w.get("title")]

    def get_by_id(self, openalex_id: str) -> Paper:
        """Look up a single work by OpenAlex ID.

        Args:
            openalex_id: OpenAlex work ID (e.g. ``W2741809807``).

        Returns:
            ``Paper`` object.

        Raises:
            OpenAlexError: On HTTP errors.
        """
        # Accept both bare ID and full URL
        if openalex_id.startswith("https://"):
            url = openalex_id
        else:
            url = f"{_API_BASE}/{openalex_id}"

        try:
            resp = request_with_retry(
                "GET",
                url,
                headers=self._headers,
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = (
                f"OpenAlex lookup failed for "
                f"{openalex_id}: HTTP {exc.response.status_code}"
            )
            raise OpenAlexError(msg) from exc
        except httpx.RequestError as exc:
            msg = f"OpenAlex lookup failed for {openalex_id}: {exc}"
            raise OpenAlexError(msg) from exc

        return self._normalize(resp.json())

    def cited_by(self, work_id: str, *, limit: int = 10) -> list[Paper]:
        """Get papers that cite the given work.

        Args:
            work_id: OpenAlex work ID (e.g. ``W2741809807``) or full URL.
            limit: Maximum results (default 10).

        Returns:
            List of citing ``Paper`` objects.

        Raises:
            OpenAlexError: On HTTP errors.
        """
        # OpenAlex filter: cites:<openalex_url>
        openalex_url = (
            work_id
            if work_id.startswith("https://")
            else f"https://openalex.org/{work_id}"
        )
        try:
            resp = request_with_retry(
                "GET",
                _API_BASE,
                params={
                    "filter": f"cites:{openalex_url}",
                    "per_page": str(limit),
                },
                headers=self._headers,
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = (
                f"OpenAlex cited_by failed for "
                f"{work_id}: HTTP {exc.response.status_code}"
            )
            raise OpenAlexError(msg) from exc
        except httpx.RequestError as exc:
            msg = f"OpenAlex cited_by failed for {work_id}: {exc}"
            raise OpenAlexError(msg) from exc

        results = resp.json().get("results", [])
        return [self._normalize(w) for w in results if w.get("title")]

    def referenced_works(self, work_id: str) -> list[Paper]:
        """Get papers referenced by the given work.

        Fetches the work's metadata and resolves each referenced work ID.

        Args:
            work_id: OpenAlex work ID (e.g. ``W2741809807``) or full URL.

        Returns:
            List of referenced ``Paper`` objects.

        Raises:
            OpenAlexError: On HTTP errors.
        """
        # First, get the work to extract referenced_works list
        if work_id.startswith("https://"):
            url = work_id
        else:
            url = f"{_API_BASE}/{work_id}"

        try:
            resp = request_with_retry(
                "GET",
                url,
                headers=self._headers,
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = (
                f"OpenAlex referenced_works failed for "
                f"{work_id}: HTTP {exc.response.status_code}"
            )
            raise OpenAlexError(msg) from exc
        except httpx.RequestError as exc:
            msg = f"OpenAlex referenced_works failed for {work_id}: {exc}"
            raise OpenAlexError(msg) from exc

        work_data = resp.json()
        ref_urls: list[str] = work_data.get("referenced_works", [])
        if not ref_urls:
            return []

        # Batch-fetch referenced works via filter
        ref_ids = "|".join(ref_urls)
        try:
            resp = request_with_retry(
                "GET",
                _API_BASE,
                params={
                    "filter": f"openalex:{ref_ids}",
                    "per_page": "50",
                },
                headers=self._headers,
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = (
                f"OpenAlex referenced_works batch failed: "
                f"HTTP {exc.response.status_code}"
            )
            raise OpenAlexError(msg) from exc
        except httpx.RequestError as exc:
            msg = f"OpenAlex referenced_works batch failed: {exc}"
            raise OpenAlexError(msg) from exc

        results = resp.json().get("results", [])
        return [self._normalize(w) for w in results if w.get("title")]

    # ── Private ──────────────────────────────────────────────────────

    @staticmethod
    def _normalize(work: dict[str, Any]) -> Paper:
        """Convert an OpenAlex work dict to a ``Paper``."""
        title = work.get("title") or "Untitled"

        authors = _extract_authors(work)
        year = work.get("publication_year")

        ids = work.get("ids", {})
        doi = _extract_doi(ids.get("doi"))
        openalex_id = _extract_openalex_id(ids.get("openalex", ""))
        arxiv_id = _extract_arxiv_from_doi(ids.get("doi", ""))

        venue = _extract_venue(work)
        citation_count = work.get("cited_by_count")
        abstract = _reconstruct_abstract(
            work.get("abstract_inverted_index"),
        )

        return Paper(
            title=title,
            authors=authors,
            year=year,
            doi=doi,
            arxiv_id=arxiv_id,
            openalex_id=openalex_id,
            venue=venue,
            citation_count=citation_count,
            abstract=abstract,
            source="openalex",
        )


# ── Module-level helpers ─────────────────────────────────────────────────


def _extract_authors(work: dict[str, Any]) -> list[str]:
    """Extract author display names from an OpenAlex work."""
    authors: list[str] = []
    for authorship in work.get("authorships", []):
        author = authorship.get("author", {})
        name = author.get("display_name")
        if name:
            authors.append(name)
    return authors or ["Unknown"]


def _extract_arxiv_from_doi(doi_str: str) -> str | None:
    """Extract arXiv ID from a DOI string if present."""
    if "arXiv" in doi_str:
        _, _, suffix = doi_str.rpartition("arXiv.")
        if suffix:
            return suffix
    return None


def _extract_venue(work: dict[str, Any]) -> str | None:
    """Extract venue display name from primary_location."""
    loc = work.get("primary_location")
    if loc and isinstance(loc, dict):
        source = loc.get("source")
        if source and isinstance(source, dict):
            name: str | None = source.get("display_name")
            return name
    return None


def _extract_doi(raw: str | None) -> str | None:
    """Extract bare DOI from ``https://doi.org/...`` URL."""
    if not raw:
        return None
    prefix = "https://doi.org/"
    if raw.startswith(prefix):
        return raw[len(prefix) :]
    return raw


def _extract_openalex_id(raw: str | None) -> str | None:
    """Extract bare OpenAlex ID from URL (``W2741809807``)."""
    if not raw:
        return None
    # https://openalex.org/W2741809807 → W2741809807
    if "/" in raw:
        return raw.rsplit("/", 1)[-1]
    return raw


def _reconstruct_abstract(
    inverted_index: dict[str, list[int]] | None,
) -> str | None:
    """Reconstruct abstract from OpenAlex inverted index format.

    OpenAlex stores abstracts as ``{word: [positions]}`` dicts.
    We rebuild the text by placing each word at its position(s).
    """
    if not inverted_index:
        return None

    # Find max position to size the output
    max_pos = 0
    for positions in inverted_index.values():
        if positions:
            max_pos = max(max_pos, max(positions))

    words: list[str] = [""] * (max_pos + 1)
    for word, positions in inverted_index.items():
        for pos in positions:
            words[pos] = word

    text = " ".join(words).strip()
    return text if text else None
