"""arXiv API client — native BibTeX + Atom XML search.

Endpoints used:
    - BibTeX: ``https://arxiv.org/bibtex/{id}``
    - Search: ``http://export.arxiv.org/api/query``

Rate limit: **1 request per 3 seconds** (arXiv Terms of Use).
"""

from __future__ import annotations

import logging
import re
import time
import xml.etree.ElementTree as ET

import httpx

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

__all__ = ["ArXivClient", "ArXivError"]

logger = logging.getLogger(__name__)


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

_BIBTEX_URL = "https://arxiv.org/bibtex/{id}"
_SEARCH_URL = "http://export.arxiv.org/api/query"
_PDF_BASE = "https://arxiv.org/pdf/{id}.pdf"

_ATOM_NS = "http://www.w3.org/2005/Atom"
_ARXIV_NS = "http://arxiv.org/schemas/atom"

_RATE_LIMIT_SECONDS = 3


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


class ArXivError(Exception):
    """Raised on arXiv API failures."""


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


class ArXivClient:
    """arXiv API client with native BibTeX and Atom XML search.

    All public methods respect the arXiv rate limit (1 req / 3 s).
    The limiter is **class-level** so multiple instances share the
    same cooldown — required by arXiv Terms of Use.
    """

    _last_request_time: float = 0.0  # class-level, monotonic clock

    # ── Rate limiting ────────────────────────────────────────────────

    def _rate_limit(self) -> None:
        """Sleep only the remaining delta to honour the 3 s arXiv gap."""
        elapsed = time.monotonic() - ArXivClient._last_request_time
        if elapsed < _RATE_LIMIT_SECONDS:
            time.sleep(_RATE_LIMIT_SECONDS - elapsed)
        ArXivClient._last_request_time = time.monotonic()

    # ── Static helpers ───────────────────────────────────────────────

    @staticmethod
    def normalize_id(identifier: str) -> str:
        """Normalize an arXiv identifier to its bare form.

        Handles:
            - ``arXiv:1706.03762`` → ``1706.03762``
            - ``https://arxiv.org/abs/1706.03762`` → ``1706.03762``
            - ``1706.03762`` → ``1706.03762`` (pass-through)
            - ``math/0102101`` → ``math/0102101`` (old format)
        """
        # Strip URL prefix
        for prefix in ("https://arxiv.org/abs/", "http://arxiv.org/abs/"):
            if identifier.startswith(prefix):
                return identifier[len(prefix) :]

        # Strip arXiv: prefix
        if identifier.startswith("arXiv:"):
            return identifier[len("arXiv:") :]

        return identifier

    @staticmethod
    def _strip_version(arxiv_id: str) -> str:
        """Strip version suffix for BibTeX lookup.

        ``1706.03762v5`` → ``1706.03762``.
        """
        return re.sub(r"v\d+$", "", arxiv_id)

    @staticmethod
    def pdf_url(arxiv_id: str) -> str:
        """Construct PDF download URL from arXiv ID.

        Args:
            arxiv_id: Bare arXiv ID (e.g. ``1706.03762``, ``math/0102101``).

        Returns:
            Full PDF URL.
        """
        return _PDF_BASE.format(id=arxiv_id)

    # ── BibTeX ───────────────────────────────────────────────────────

    def bibtex(self, identifier: str) -> BibEntry:
        """Fetch native BibTeX entry from arXiv.

        Args:
            identifier: arXiv ID in any format (URL, prefixed, bare).

        Returns:
            Parsed ``BibEntry``.

        Raises:
            ArXivError: On HTTP errors or parse failures.
        """
        arxiv_id = self.normalize_id(identifier)
        lookup_id = self._strip_version(arxiv_id)
        url = _BIBTEX_URL.format(id=lookup_id)

        self._rate_limit()
        try:
            resp = request_with_retry(
                "GET",
                url,
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = f"BibTeX fetch failed for {arxiv_id}: HTTP {exc.response.status_code}"
            raise ArXivError(msg) from exc
        except httpx.TransportError as exc:
            msg = f"BibTeX fetch failed for {arxiv_id}: {exc}"
            raise ArXivError(msg) from exc

        return self._parse_bibtex(resp.text)

    # ── Search ───────────────────────────────────────────────────────

    def search(self, query: str, *, limit: int = 5) -> list[Paper]:
        """Search arXiv via Atom XML API.

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

        Returns:
            List of ``Paper`` objects.

        Raises:
            ArXivError: On HTTP or XML parse failures.
        """
        if not query.strip():
            return []

        self._rate_limit()
        try:
            resp = request_with_retry(
                "GET",
                _SEARCH_URL,
                params={
                    "search_query": f"all:{query}",
                    "max_results": str(limit),
                    "sortBy": "relevance",
                },
                timeout=30,
                follow_redirects=True,
            )
        except httpx.HTTPStatusError as exc:
            msg = f"arXiv search failed: HTTP {exc.response.status_code}"
            raise ArXivError(msg) from exc
        except httpx.TransportError as exc:
            msg = f"arXiv search failed: {exc}"
            raise ArXivError(msg) from exc

        return self._parse_atom(resp.text)

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

    @staticmethod
    def _parse_bibtex(raw: str) -> BibEntry:
        """Parse a raw BibTeX string into a ``BibEntry``."""
        # Extract key: @type{key,
        m = re.match(r"@(\w+)\{([^,]+),", raw)
        if not m:
            msg = "Failed to parse BibTeX: no entry found"
            raise ArXivError(msg)

        entry_type = m.group(1).lower()
        key = m.group(2).strip()

        # Extract fields
        fields = _extract_bibtex_fields(raw)

        title = fields.get("title")
        authors_raw = fields.get("author", "")
        authors = [a.strip() for a in re.split(r"\s+and\s+", authors_raw) if a.strip()]

        year: int | None = None
        year_str = fields.get("year", "")
        if year_str.isdigit():
            year = int(year_str)

        doi = fields.get("doi")

        return BibEntry(
            key=key,
            entry_type=entry_type,
            raw=raw.strip(),
            doi=doi,
            title=title,
            authors=authors,
            year=year,
        )

    @staticmethod
    def _parse_atom(xml_text: str) -> list[Paper]:
        """Parse Atom XML feed into Paper objects."""
        try:
            root = ET.fromstring(xml_text)  # noqa: S314
        except ET.ParseError as exc:
            msg = f"Failed to parse arXiv XML: {exc}"
            raise ArXivError(msg) from exc

        papers: list[Paper] = []
        for entry in root.findall(f"{{{_ATOM_NS}}}entry"):
            paper = _atom_entry_to_paper(entry)
            if paper is not None:
                papers.append(paper)

        return papers


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


def _extract_bibtex_fields(raw: str) -> dict[str, str]:
    """Extract field=value pairs from a BibTeX entry."""
    fields: dict[str, str] = {}
    for m in re.finditer(r"(\w+)\s*=\s*\{([^}]*)\}", raw, re.DOTALL):
        fields[m.group(1).lower()] = m.group(2).strip()
    return fields


def _parse_atom_authors(entry: ET.Element) -> list[str]:
    """Extract author names from an Atom entry."""
    return [
        name_el.text
        for author_el in entry.findall(f"{{{_ATOM_NS}}}author")
        if (name_el := author_el.find(f"{{{_ATOM_NS}}}name")) is not None
        and name_el.text
    ]


def _parse_atom_year(entry: ET.Element) -> int | None:
    """Extract publication year from an Atom entry."""
    pub_el = entry.find(f"{{{_ATOM_NS}}}published")
    if pub_el is not None and pub_el.text:
        m = re.match(r"(\d{4})", pub_el.text)
        if m:
            return int(m.group(1))
    return None


def _parse_atom_abstract(entry: ET.Element) -> str | None:
    """Extract abstract from an Atom entry."""
    summary_el = entry.find(f"{{{_ATOM_NS}}}summary")
    if summary_el is not None and summary_el.text:
        return summary_el.text.strip()
    return None


def _parse_atom_pdf_url(entry: ET.Element) -> str | None:
    """Extract PDF link from an Atom entry."""
    for link in entry.findall(f"{{{_ATOM_NS}}}link"):
        if link.get("title") == "pdf":
            return link.get("href")
    return None


def _atom_entry_to_paper(entry: ET.Element) -> Paper | None:
    """Convert a single Atom XML entry to a Paper."""
    title_el = entry.find(f"{{{_ATOM_NS}}}title")
    if title_el is None or not title_el.text:
        return None

    title = " ".join(title_el.text.split())

    authors = _parse_atom_authors(entry)
    if not authors:
        return None

    # arXiv ID from <id>
    id_el = entry.find(f"{{{_ATOM_NS}}}id")
    arxiv_id: str | None = None
    if id_el is not None and id_el.text:
        arxiv_id = id_el.text.rsplit("/", 1)[-1]

    return Paper(
        title=title,
        authors=authors,
        year=_parse_atom_year(entry),
        arxiv_id=arxiv_id,
        abstract=_parse_atom_abstract(entry),
        open_access_pdf=_parse_atom_pdf_url(entry),
        source="arxiv",
    )
