"""OpenReview API client — search, venue listing & synthetic BibTeX.

Provides paper discovery from OpenReview (NeurIPS, ICLR, ICML, COLM, TMLR).
Uses the public REST API v2 directly via httpx. BibTeX is generated
synthetically from metadata — OpenReview has no native BibTeX endpoint.
"""

from __future__ import annotations

import datetime
import logging
import re
from typing import Any

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

logger = logging.getLogger(__name__)

__all__ = ["OpenReviewClient", "OpenReviewError"]

_BASE_URL = "https://api2.openreview.net"
_V1_BASE_URL = "https://api.openreview.net"
_HTTP_OK = 200


class OpenReviewError(Exception):
    """Error raised by OpenReview operations."""


class OpenReviewClient:
    """OpenReview API client (direct httpx).

    No authentication required for public endpoints. Uses the REST API v2
    for search and venue-based paper listing.
    """

    def search(self, query: str, limit: int = 5) -> list[Paper]:
        """Search papers by content on OpenReview.

        Args:
            query: Search query string.
            limit: Maximum number of results.

        Returns:
            List of normalized Paper objects.

        Raises:
            OpenReviewError: On API failure.
        """
        if not query.strip():
            return []

        params: dict[str, str | int] = {
            "term": query,
            "limit": limit,
            "type": "terms",
        }

        try:
            resp = request_with_retry(
                "GET",
                f"{_BASE_URL}/notes/search",
                params=params,
                timeout=30.0,
            )
        except Exception as exc:
            msg = f"OpenReview search failed: {exc}"
            raise OpenReviewError(msg) from exc

        notes = resp.json().get("notes", [])

        papers: list[Paper] = []
        for note in notes:
            paper = self._normalize(note)
            if paper is not None:
                papers.append(paper)

        return papers

    def venue_papers(
        self,
        venue_id: str,
        limit: int = 50,
    ) -> list[Paper]:
        """List papers from a specific conference venue.

        Args:
            venue_id: OpenReview venue ID
                (e.g. ``NeurIPS.cc/2024/Conference``).
            limit: Maximum number of results.

        Returns:
            List of normalized Paper objects.

        Raises:
            OpenReviewError: On API failure.
        """
        params: dict[str, str | int] = {
            "content.venueid": venue_id,
            "limit": limit,
        }

        try:
            resp = request_with_retry(
                "GET",
                f"{_BASE_URL}/notes",
                params=params,
                timeout=60.0,
            )
        except Exception as exc:
            msg = f"OpenReview venue listing failed for '{venue_id}': {exc}"
            raise OpenReviewError(msg) from exc

        notes = resp.json().get("notes", [])

        papers: list[Paper] = []
        for note in notes:
            paper = self._normalize(note)
            if paper is not None:
                papers.append(paper)

        return papers

    def bibtex(self, forum_id: str) -> BibEntry:
        """Generate BibTeX from an OpenReview forum note.

        Fetches the note metadata and generates a synthetic
        ``@inproceedings`` entry.  Uses direct ID lookup first,
        then falls back to text search.

        Args:
            forum_id: OpenReview forum ID.

        Returns:
            Generated BibEntry with normalized key.

        Raises:
            OpenReviewError: On fetch failure or missing metadata.
        """
        note = self._fetch_note(forum_id)

        meta = _extract_metadata(note)

        # Generate key and BibTeX
        key = generate_key(meta["authors"], meta["year"], meta["title"])
        authors_bibtex = " and ".join(meta["authors"])
        year_str = str(meta["year"]) if meta["year"] is not None else ""

        raw = (
            f"@inproceedings{{{key},\n"
            f"  title = {{{meta['title']}}},\n"
            f"  author = {{{authors_bibtex}}},\n"
            f"  booktitle = {{{meta['venue']}}},\n"
            f"  year = {{{year_str}}}\n"
            f"}}"
        )

        return BibEntry(
            key=key,
            entry_type="inproceedings",
            raw=raw,
            title=meta["title"],
            authors=meta["authors"],
            year=meta["year"],
        )

    def _try_note_lookup(
        self,
        url: str,
        params: dict[str, Any],
        *,
        match_id: str | None = None,
    ) -> dict[str, Any] | None:
        """Attempt a single note lookup against an OpenReview endpoint.

        Args:
            url: API endpoint URL.
            params: Query parameters.
            match_id: If set, iterate notes and match on forum/id.
                If None, return the first note directly.

        Returns:
            Note dict or None if not found or on error.
        """
        try:
            resp = request_with_retry("GET", url, params=params, timeout=30.0)
            if resp.status_code != _HTTP_OK:
                return None
            notes: list[dict[str, Any]] = resp.json().get("notes", [])
            if match_id is None:
                return notes[0] if notes else None
            return next(
                (
                    n
                    for n in notes
                    if n.get("forum") == match_id or n.get("id") == match_id
                ),
                None,
            )
        except Exception:  # noqa: BLE001
            return None

    def _fetch_note(self, forum_id: str) -> dict[str, Any]:
        """Fetch a single OpenReview note by forum ID.

        Tries direct ID lookup first (``GET /notes?id=``), then
        falls back to text search (``GET /notes/search``),
        then to the v1 API for older conferences.

        Args:
            forum_id: OpenReview forum ID.

        Returns:
            Note dict from the API.

        Raises:
            OpenReviewError: On network error or if no note is found.
        """
        # 1. Direct ID lookup (fast and precise)
        note = self._try_note_lookup(f"{_BASE_URL}/notes", {"id": forum_id})
        if note:
            return note
        logger.debug("Direct note lookup failed for '%s'", forum_id)

        # 2. Fallback: text search (v2)
        note = self._try_note_lookup(
            f"{_BASE_URL}/notes/search",
            {"term": forum_id, "limit": 5, "type": "terms"},
            match_id=forum_id,
        )
        if note:
            return note
        logger.debug("v2 text search failed for '%s'", forum_id)

        # 3. Fallback: v1 API (older conferences like ICLR 2023)
        note = self._try_note_lookup(f"{_V1_BASE_URL}/notes", {"id": forum_id})
        if note:
            return note
        logger.debug("v1 note lookup failed for '%s'", forum_id)

        msg = f"No note found for forum ID '{forum_id}'"
        raise OpenReviewError(msg)

    @staticmethod
    def _normalize(note: dict[str, Any]) -> Paper | None:
        """Convert an OpenReview note to a Paper model."""
        try:
            meta = _extract_metadata(note)

            return Paper(
                title=meta["title"],
                authors=meta["authors"],
                year=meta["year"],
                openreview_id=note.get("forum") or note.get("id"),
                venue=meta["venue"] or None,
                abstract=meta["abstract"],
                open_access_pdf=meta["pdf_url"],
                source="openreview",
            )
        except Exception:  # noqa: BLE001
            logger.debug(
                "Failed to normalize OpenReview note: %s",
                note.get("id", "?"),
                exc_info=True,
            )
            return None


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


def _extract_metadata(note: dict[str, Any]) -> dict[str, Any]:
    """Extract normalized metadata fields from an OpenReview note.

    Handles both API v1 (plain values) and v2 (``{value: ...}`` wrapper).

    Args:
        note: OpenReview note dict.

    Returns:
        Dict with keys: title, authors, venue, year, abstract, pdf_url.
    """
    content = note.get("content", {})

    title = _unwrap_value(content.get("title", "")) or "Untitled"

    raw_authors = _unwrap_value(content.get("authors", []))
    if isinstance(raw_authors, list):
        authors = raw_authors if raw_authors else ["Unknown"]
    else:
        authors = ["Unknown"]

    venue = _unwrap_value(content.get("venue", "")) or ""
    abstract = _unwrap_value(content.get("abstract")) or None
    year = _extract_year_from_note(note, venue)

    # PDF URL
    pdf_path = _unwrap_value(content.get("pdf"))
    pdf_url: str | None = None
    if pdf_path:
        pdf_url = f"https://openreview.net{pdf_path}"

    return {
        "title": title,
        "authors": authors,
        "venue": venue,
        "year": year,
        "abstract": abstract,
        "pdf_url": pdf_url,
    }


def _unwrap_value(val: Any) -> Any:
    """Unwrap OpenReview API v2 ``{value: ...}`` wrapper.

    API v2 wraps fields in ``{"value": ...}``.
    API v1 stores values directly.

    Args:
        val: Raw value from note content.

    Returns:
        Unwrapped value.
    """
    if isinstance(val, dict) and "value" in val:
        return val["value"]
    return val


def _extract_year_from_note(
    note: dict[str, Any],
    venue: str,
) -> int | None:
    """Extract publication year from note metadata.

    Tries (in order):
    1. Last 4-digit number in venue string (e.g. "NeurIPS 2024")
    2. Year from ``pdate`` (publication date, milliseconds since epoch)

    Args:
        note: OpenReview note dict.
        venue: Venue string (e.g. "NeurIPS 2024").

    Returns:
        Publication year or None.
    """
    # Try venue string first (e.g. "NeurIPS 2024")
    if venue:
        years = re.findall(r"\b((?:19|20)\d{2})\b", venue)
        if years:
            return int(years[-1])

    # Try pdate
    pdate = note.get("pdate")
    if pdate and isinstance(pdate, int | float):
        dt = datetime.datetime.fromtimestamp(
            pdate / 1000,
            tz=datetime.UTC,
        )
        return dt.year

    return None
