"""Async PyPI metadata client with caching and retry logic.

Fetches package metadata from the PyPI JSON API, with disk caching,
concurrency control via semaphore, and exponential backoff on transient errors.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

import httpx

from depwhy.fetcher.cache import DiskCache

logger = logging.getLogger(__name__)

_PYPI_BASE_URL = "https://pypi.org/pypi"
_MAX_RETRIES = 3
_INITIAL_BACKOFF = 1.0
_CACHE_VERSION_KEY = "__all__"


class PyPIFetcher:
    """Async client for fetching package metadata from PyPI.

    Uses a shared ``httpx.AsyncClient`` and an ``asyncio.Semaphore`` to
    limit concurrent requests.  Results are cached to disk via ``DiskCache``.

    Supports use as an async context manager::

        async with PyPIFetcher(cache) as fetcher:
            data = await fetcher.fetch("httpx")
    """

    def __init__(self, cache: DiskCache, max_concurrency: int = 10) -> None:
        """Initialise the fetcher.

        Args:
            cache: Disk cache instance for storing fetched metadata.
            max_concurrency: Maximum number of concurrent HTTP requests.
        """
        self._cache = cache
        self._semaphore = asyncio.Semaphore(max_concurrency)
        self._client = httpx.AsyncClient(
            follow_redirects=True,
            timeout=httpx.Timeout(30.0),
            headers={"Accept": "application/json"},
        )

    async def __aenter__(self) -> PyPIFetcher:
        """Enter the async context manager."""
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object,
    ) -> None:
        """Exit the async context manager and close the HTTP client."""
        await self.close()

    async def fetch(self, package_name: str) -> dict[str, Any]:
        """Fetch metadata for a single package from PyPI.

        Checks the disk cache first.  On a cache miss, fetches from the
        PyPI JSON API with retry/backoff for 429 and 503 responses.

        Args:
            package_name: The PyPI package name (case-insensitive).

        Returns:
            Parsed JSON metadata dict, or an empty dict if the package
            does not exist (404).
        """
        normalised = package_name.strip().lower()

        # Check cache first
        cached = self._cache.get(normalised, _CACHE_VERSION_KEY)
        if cached is not None:
            logger.debug("Cache hit for %s", normalised)
            return cached

        # Fetch from PyPI with concurrency control
        async with self._semaphore:
            data = await self._fetch_with_retry(normalised)

        # Write to cache (even empty dicts are not cached — only successful responses)
        if data:
            self._cache.set(normalised, _CACHE_VERSION_KEY, data)

        return data

    async def fetch_many(self, package_names: list[str]) -> dict[str, dict[str, Any]]:
        """Fetch metadata for multiple packages concurrently.

        Args:
            package_names: List of PyPI package names.

        Returns:
            Mapping of package name to its metadata dict.
        """
        tasks = [self.fetch(name) for name in package_names]
        results = await asyncio.gather(*tasks)
        return dict(zip(package_names, results, strict=True))

    async def close(self) -> None:
        """Close the underlying HTTP client."""
        await self._client.aclose()

    async def _fetch_with_retry(self, package_name: str) -> dict[str, Any]:
        """Fetch from PyPI with exponential backoff on transient errors.

        Args:
            package_name: Normalised package name.

        Returns:
            Parsed JSON dict or empty dict on 404.
        """
        url = f"{_PYPI_BASE_URL}/{package_name}/json"
        backoff = _INITIAL_BACKOFF

        for attempt in range(_MAX_RETRIES):
            try:
                response = await self._client.get(url)
            except httpx.HTTPError as exc:
                logger.warning(
                    "HTTP error fetching %s (attempt %d/%d): %s",
                    package_name,
                    attempt + 1,
                    _MAX_RETRIES,
                    exc,
                )
                if attempt < _MAX_RETRIES - 1:
                    await asyncio.sleep(backoff)
                    backoff *= 2
                    continue
                return {}

            if response.status_code == 200:
                data: dict[str, Any] = response.json()
                logger.debug("Fetched metadata for %s", package_name)
                return data

            if response.status_code == 404:
                logger.warning("Package not found on PyPI: %s", package_name)
                return {}

            if response.status_code in (429, 503):
                retry_after = response.headers.get("Retry-After")
                if retry_after is not None:
                    try:
                        wait = float(retry_after)
                    except ValueError:
                        wait = backoff
                else:
                    wait = backoff

                logger.warning(
                    "Rate limited / unavailable for %s (HTTP %d, attempt %d/%d), retrying in %.1fs",
                    package_name,
                    response.status_code,
                    attempt + 1,
                    _MAX_RETRIES,
                    wait,
                )
                if attempt < _MAX_RETRIES - 1:
                    await asyncio.sleep(wait)
                    backoff *= 2
                    continue
                return {}

            # Unexpected status code — don't retry
            logger.warning("Unexpected HTTP %d for %s", response.status_code, package_name)
            return {}

        return {}  # pragma: no cover — loop always returns
