"""arXiv API client with proactive throttling.

HTTP concerns live here; parsing/IDs/errors are split into sibling modules.
"""

from __future__ import annotations

import random
import sys
import time
from typing import Any, Callable

import httpx

from arxivy import __version__
from arxivy.api.constants import (
    BASE_URL,
    DEFAULT_MAX_RETRIES,
    DEFAULT_MAX_RETRY_WAIT,
    THROTTLE_SECONDS,
)
from arxivy.api.errors import ArxivError, NetworkError, NotFoundError, RateLimitError
from arxivy.api.ids import normalize_arxiv_id
from arxivy.api.parser import parse_feed
from arxivy.api.types import SortBy, SortOrder
from arxivy.models import Paper, PaperListResult


def _default_status_callback(message: str) -> None:
    if sys.stderr.isatty():
        print(f"\r{message}", end="", file=sys.stderr, flush=True)
    else:
        print(message, file=sys.stderr)


def _default_status_clear() -> None:
    if sys.stderr.isatty():
        print("\r\033[K", end="", file=sys.stderr, flush=True)
    else:
        # Nothing to clear in non-interactive environments.
        return


def _parse_retry_after(response: httpx.Response) -> int | None:
    retry_after = response.headers.get("Retry-After")
    if retry_after:
        try:
            return int(retry_after)
        except ValueError:
            return None
    return None


# Backwards-compatible aliases for older imports/tests.
_normalize_arxiv_id = normalize_arxiv_id
_parse_feed = parse_feed


class ArxivClient:
    """Client for the arXiv API.

    Implements proactive throttling (3s between requests) as requested by arXiv,
    and exponential backoff retry for network errors and 5xx responses.

    Usage::

        with ArxivClient() as api:
            results = api.search("attention is all you need")
    """

    def __init__(
        self,
        max_retries: int = DEFAULT_MAX_RETRIES,
        max_retry_wait: int = DEFAULT_MAX_RETRY_WAIT,
        status_callback: Callable[[str], None] | None = None,
        throttle_seconds: float = THROTTLE_SECONDS,
        retry_on_rate_limit: bool = True,
    ):
        self.max_retries = max_retries
        self.max_retry_wait = max_retry_wait
        self.status_callback = status_callback or _default_status_callback
        self.throttle_seconds = throttle_seconds
        self.retry_on_rate_limit = retry_on_rate_limit
        self._client: httpx.Client | None = None
        self._last_request_time: float = 0.0

    @property
    def client(self) -> httpx.Client:
        if self._client is None:
            self._client = httpx.Client(
                headers={"User-Agent": f"arxivy/{__version__} (https://github.com/mrshu/arxivy)"},
                timeout=30.0,
            )
        return self._client

    def __enter__(self) -> ArxivClient:
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()

    def close(self) -> None:
        if self._client is not None:
            self._client.close()
            self._client = None

    def _throttle(self) -> None:
        """Enforce minimum delay between requests (arXiv asks for 3s)."""
        if self._last_request_time > 0:
            elapsed = time.monotonic() - self._last_request_time
            remaining = self.throttle_seconds - elapsed
            if remaining > 0:
                time.sleep(remaining)

    def _request(self, params: dict[str, Any]) -> str:
        """Make a throttled, retried GET request to the arXiv API.

        Returns the raw XML response text.
        """
        last_error: Exception | None = None

        for attempt in range(self.max_retries + 1):
            try:
                self._throttle()

                try:
                    response = self.client.get(BASE_URL, params=params)
                finally:
                    # Throttle is intended to enforce a minimum delay *between* requests.
                    # Tracking the time after an attempt completes makes this invariant
                    # independent of request latency.
                    self._last_request_time = time.monotonic()

                if response.status_code == 200:
                    return response.text

                if response.status_code == 429:
                    retry_after = _parse_retry_after(response)
                    last_error = RateLimitError(retry_after=retry_after)
                    if self.retry_on_rate_limit and attempt < self.max_retries:
                        wait = self._backoff_wait(attempt, retry_after)
                        self._wait_with_countdown(wait, attempt + 1)
                        continue
                    raise last_error

                # Retry on 5xx
                if response.status_code >= 500:
                    last_error = ArxivError(
                        message=f"arXiv API returned {response.status_code}",
                        code="SERVER_ERROR",
                        status_code=response.status_code,
                    )
                    if attempt < self.max_retries:
                        wait = self._backoff_wait(attempt, None)
                        self._report_retry(wait, attempt, label="Server error")
                        time.sleep(wait)
                        continue
                    raise last_error

                # Non-retryable HTTP error
                raise ArxivError(
                    message=f"arXiv API returned {response.status_code}",
                    code="API_ERROR",
                    suggestion="Check the query parameters",
                    status_code=response.status_code,
                )

            except httpx.RequestError as e:
                last_error = NetworkError(str(e))
                if attempt < self.max_retries:
                    wait = self._backoff_wait(attempt, None)
                    self._report_retry(wait, attempt, label="Network error")
                    time.sleep(wait)
                    continue
                raise last_error

        # Should not reach here
        if last_error:
            raise last_error
        raise ArxivError(message="Request failed", code="UNKNOWN")

    def _backoff_wait(self, attempt: int, retry_after: int | None) -> float:
        """Calculate exponential backoff with jitter.

        If retry_after is provided, prefer it (capped) as a baseline.
        """
        if retry_after is not None:
            wait = min(float(retry_after), float(self.max_retry_wait))
        else:
            wait = min(2.0 ** attempt, self.max_retry_wait)
        jitter = wait * 0.25 * (random.random() * 2 - 1)
        return max(0.5, wait + jitter)

    def _report_retry(self, wait_seconds: float, attempt: int, *, label: str) -> None:
        self.status_callback(
            f"{label}. Retrying ({attempt + 1}/{self.max_retries}) in {int(wait_seconds)}s..."
        )

    def _wait_with_countdown(self, wait_seconds: float, attempt: int) -> None:
        """Wait before a retry, showing a countdown when interactive."""

        if not sys.stderr.isatty():
            self.status_callback(
                f"Rate limited. Retrying ({attempt}/{self.max_retries}) in {int(wait_seconds)}s..."
            )
            time.sleep(wait_seconds)
            return

        end_time = time.monotonic() + wait_seconds
        while True:
            remaining = end_time - time.monotonic()
            if remaining <= 0:
                break

            if remaining >= 1:
                msg = (
                    f"Rate limited. Retrying ({attempt}/{self.max_retries}) in {int(remaining)}s..."
                )
            else:
                msg = f"Rate limited. Retrying ({attempt}/{self.max_retries}) now..."

            self.status_callback(msg)
            time.sleep(min(0.5, remaining))

        _default_status_clear()

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

    def search(
        self,
        query: str,
        limit: int = 10,
        offset: int = 0,
        category: str | None = None,
        sort_by: SortBy | str | None = None,
        sort_order: SortOrder | str | None = None,
    ) -> PaperListResult:
        """Search arXiv papers.

        Args:
            query: Free-text search query.
            limit: Maximum number of results (default 10).
            offset: Pagination offset.
            category: Filter by arXiv category (e.g. "cs.AI").
            sort_by: One of "relevance", "lastUpdatedDate", "submittedDate".
            sort_order: "ascending" or "descending".

        Returns:
            PaperListResult with "results" and optional "meta".
        """
        search_query = f"all:{query}"
        if category:
            search_query = f"cat:{category} AND {search_query}"

        params: dict[str, Any] = {
            "search_query": search_query,
            "start": offset,
            "max_results": limit,
        }
        if sort_by:
            params["sortBy"] = sort_by.value if isinstance(sort_by, SortBy) else sort_by
        if sort_order:
            params["sortOrder"] = sort_order.value if isinstance(sort_order, SortOrder) else sort_order

        xml_text = self._request(params)
        entries, meta = parse_feed(xml_text)
        meta.query = query
        if category:
            meta.category = category
        return PaperListResult(results=entries, meta=meta)

    def get_paper(self, arxiv_id: str) -> Paper:
        """Fetch a single paper by arXiv ID.

        Args:
            arxiv_id: arXiv identifier (e.g. "1706.03762", URL, or old-style).

        Returns:
            Paper dict.

        Raises:
            NotFoundError: If the paper doesn't exist.
        """
        normalized = normalize_arxiv_id(arxiv_id)
        params: dict[str, Any] = {"id_list": normalized, "max_results": 1}

        xml_text = self._request(params)
        entries, _ = parse_feed(xml_text)

        if not entries:
            raise NotFoundError(arxiv_id)

        return entries[0]

    def get_papers(self, arxiv_ids: list[str]) -> list[Paper]:
        """Fetch multiple papers by arXiv IDs (batch).

        Args:
            arxiv_ids: List of arXiv identifiers.

        Returns:
            List of paper dicts.
        """
        normalized = [normalize_arxiv_id(aid) for aid in arxiv_ids]
        id_list = ",".join(normalized)
        params: dict[str, Any] = {"id_list": id_list, "max_results": len(normalized)}

        xml_text = self._request(params)
        entries, _ = parse_feed(xml_text)
        return entries

    def get_new(
        self,
        category: str,
        limit: int = 10,
        offset: int = 0,
    ) -> PaperListResult:
        """Get recent papers in a category.

        Args:
            category: arXiv category (e.g. "cs.AI").
            limit: Maximum number of results.
            offset: Pagination offset.

        Returns:
            PaperListResult with "results" and optional "meta".
        """
        params: dict[str, Any] = {
            "search_query": f"cat:{category}",
            "start": offset,
            "max_results": limit,
            "sortBy": "submittedDate",
            "sortOrder": "descending",
        }

        xml_text = self._request(params)
        entries, meta = parse_feed(xml_text)
        meta.category = category
        return PaperListResult(results=entries, meta=meta)
