"""PyPI JSON API client for remote package metadata."""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal, Required, TypedDict, cast

import httpx
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version

from peta.core import cache, http, validation
from peta.core.compatibility import supports_python
from peta.core.models import PackageInfo, Vulnerability
from peta.core.validation import (
    ResponseValidationError,
    expect_list,
    expect_mapping,
    expect_string,
    optional_string,
    optional_string_list,
    optional_string_mapping,
)

if TYPE_CHECKING:
    from packaging.specifiers import Specifier, SpecifierSet

    from peta.core.cache import Provenance

__all__ = [
    "NetworkError",
    "PackageNotFoundError",
    "PyPIInfo",
    "PyPIReleaseFile",
    "PyPIResponse",
    "PyPIVulnerability",
    "get_package",
    "get_package_matching",
]


PYPI_BASE_URL = "https://pypi.org/pypi"


# The PyPI JSON API is untyped from Python's perspective (``response.json()``
# returns ``Any``). These ``TypedDict``\ s describe only the fields peta reads,
# so the decoded body can be brought into the typed world with a single
# ``typing.cast`` at each ``.json()`` boundary. Fields peta always indexes
# directly are ``Required``; everything read via ``.get(...)`` is optional
# (``total=False``), mirroring the fact that PyPI does not guarantee any key.
class PyPIInfo(TypedDict, total=False):
    """The ``info`` object of a PyPI package payload."""

    name: Required[str]
    version: Required[str]
    summary: str | None
    author: str | None
    author_email: str | None
    maintainer: str | None
    license: str | None
    license_expression: str | None
    requires_python: str | None
    home_page: str | None
    project_urls: dict[str, str] | None
    requires_dist: list[str] | None
    classifiers: list[str]
    keywords: str | None


class PyPIVulnerability(TypedDict, total=False):
    """A single entry of the ``vulnerabilities`` array."""

    id: Required[str]
    aliases: list[str]
    summary: str
    fixed_in: list[str]


class PyPIReleaseFile(TypedDict, total=False):
    """A single distribution file within a release entry."""

    upload_time: str
    yanked: bool


class PyPIResponse(TypedDict, total=False):
    """The top-level PyPI JSON payload for a package."""

    info: Required[PyPIInfo]
    vulnerabilities: list[PyPIVulnerability]
    releases: dict[str, list[PyPIReleaseFile]]


class PackageNotFoundError(Exception):
    """Raised when a package is not found on PyPI."""

    def __init__(self, name: str, version: str | None = None) -> None:
        """Store the missing package name and optional version."""
        self.name: str = name
        self.version: str | None = version
        target = f"{name}=={version}" if version else name
        super().__init__(f"Package '{target}' not found on PyPI")


class NetworkError(Exception):
    """Raised when a network request fails."""

    def __init__(self, message: str) -> None:
        """Wrap a network failure message."""
        super().__init__(f"Network error: {message}")


def _pypi_url(name: str, version: str | None) -> str:
    """Build the JSON API URL for a package, under its canonical name.

    PyPI serves every equivalent spelling of a name identically — ``Zope.Interface``,
    ``zope_interface`` and ``zope-interface`` all return the same payload — but
    they are different URLs, so caching by raw name would store the same
    response several times and let an offline lookup miss an entry it holds
    under another spelling. Canonicalizing matches what
    :mod:`peta.core.resolve` and :mod:`peta.core.deptree` already do with
    names. Error messages keep the user's own spelling; only the request does
    not.

    Returns:
        The absolute JSON API URL.
    """
    canonical = canonicalize_name(name)
    if version:
        return f"{PYPI_BASE_URL}/{canonical}/{version}/json"
    return f"{PYPI_BASE_URL}/{canonical}/json"


def _fetch(name: str, version: str | None) -> tuple[PyPIResponse, Provenance]:
    """Fetch the raw PyPI JSON payload for a package.

    The response is offered to the cache only once it has decoded and
    validated, so a ``200`` carrying an error page or truncated JSON is not
    stored and replayed.

    A pinned version gets no longer a lifetime than a bare name: its metadata
    is settled, but the same response carries the ``vulnerabilities`` array,
    and an advisory can be published against a release at any time.

    Returns:
        The decoded JSON body, and where it came from.

    Raises:
        PackageNotFoundError: If PyPI responds 404 for the package/version.
        NetworkError: If the request fails or returns a non-success status.
    """
    url = _pypi_url(name, version)
    try:
        fetched = http.get(url, ttl=cache.LATEST, scope="package")
    except httpx.RequestError as exc:
        raise NetworkError(str(exc)) from exc
    response = fetched.response

    if response.status_code == 404:  # ruff: ignore[magic-value-comparison]
        raise PackageNotFoundError(name, version)

    try:
        _ = response.raise_for_status()
    except httpx.HTTPStatusError as exc:
        msg = f"PyPI returned HTTP {exc.response.status_code}"
        raise NetworkError(msg) from exc

    payload = _decode_response(response)
    http.keep(fetched)
    return payload, fetched.provenance


def _decode_response(response: httpx.Response) -> PyPIResponse:
    try:
        body = validation.json_body(response, source="PyPI")
    except ResponseValidationError as exc:
        msg = f"malformed response from PyPI: {exc}"
        raise NetworkError(msg) from exc
    except ValueError as exc:
        msg = "PyPI returned invalid JSON"
        raise NetworkError(msg) from exc
    try:
        return _validate_response(body)
    except ResponseValidationError as exc:
        msg = f"malformed response from PyPI: {exc}"
        raise NetworkError(msg) from exc


def _validate_vulnerability(value: object, index: int) -> None:
    path = f"$.vulnerabilities[{index}]"
    vuln = expect_mapping(value, source="PyPI", path=path)
    _ = expect_string(vuln.get("id"), source="PyPI", path=f"{path}.id")
    _ = optional_string(vuln, "summary", source="PyPI", path=path)
    _ = optional_string_list(vuln, "aliases", source="PyPI", path=path)
    _ = optional_string_list(vuln, "fixed_in", source="PyPI", path=path)


def _validate_info(value: object) -> None:
    info = expect_mapping(value, source="PyPI", path="$.info")
    _ = expect_string(info.get("name"), source="PyPI", path="$.info.name")
    _ = expect_string(info.get("version"), source="PyPI", path="$.info.version")
    for key in (
        "summary",
        "author",
        "author_email",
        "maintainer",
        "license",
        "license_expression",
        "requires_python",
        "home_page",
        "keywords",
    ):
        _ = optional_string(info, key, source="PyPI", path="$.info")
    _ = optional_string_mapping(info, "project_urls", source="PyPI", path="$.info")
    _ = optional_string_list(info, "requires_dist", source="PyPI", path="$.info")
    _ = optional_string_list(info, "classifiers", source="PyPI", path="$.info")


def _validate_releases(value: object) -> None:
    source = "PyPI"
    releases = expect_mapping(value, source=source, path="$.releases")
    for version, raw_files in releases.items():
        path = f"$.releases[{version!r}]"
        files = expect_list(raw_files, source="PyPI", path=path)
        for index, raw_file in enumerate(files):
            file_path = f"{path}[{index}]"
            release_file = expect_mapping(raw_file, source="PyPI", path=file_path)
            if "yanked" in release_file and not isinstance(
                release_file["yanked"], bool
            ):
                raise ResponseValidationError(
                    source, f"{file_path}.yanked", "a boolean"
                )


def _validate_response(body: object) -> PyPIResponse:
    root = expect_mapping(body, source="PyPI", path="$")
    _validate_info(root.get("info"))
    if "releases" in root:
        _validate_releases(root["releases"])
    raw_vulnerabilities = root.get("vulnerabilities", [])
    vulnerabilities = expect_list(
        raw_vulnerabilities, source="PyPI", path="$.vulnerabilities"
    )
    for index, vulnerability in enumerate(vulnerabilities):
        _validate_vulnerability(vulnerability, index)
    return cast("PyPIResponse", cast("object", root))


def _parse_keywords(raw: str | None) -> list[str]:
    if not raw:
        return []
    return [k.strip() for k in raw.split(",") if k.strip()]


def _parse_vulnerabilities(raw: list[PyPIVulnerability]) -> list[Vulnerability]:
    return [
        Vulnerability(
            id=v["id"],
            aliases=v.get("aliases") or [],
            summary=v.get("summary") or "",
            fixed_in=v.get("fixed_in") or [],
        )
        for v in raw
    ]


def _parse_license(
    info: PyPIInfo,
) -> tuple[str | None, Literal["expression", "legacy"] | None]:
    expression = info.get("license_expression")
    if expression:
        return expression, "expression"
    legacy = info.get("license")
    return legacy, "legacy" if legacy else None


def _package_from_response(data: PyPIResponse, provenance: Provenance) -> PackageInfo:
    info = data["info"]
    license_value, license_source = _parse_license(info)
    return PackageInfo(
        name=info["name"],
        version=info["version"],
        summary=info.get("summary"),
        author=info.get("author"),
        author_email=info.get("author_email"),
        maintainer=info.get("maintainer"),
        license=license_value,
        license_source=license_source,
        python_requires=info.get("requires_python"),
        homepage=info.get("home_page"),
        project_urls=info.get("project_urls") or {},
        dependencies=info.get("requires_dist") or [],
        classifiers=info.get("classifiers") or [],
        keywords=_parse_keywords(info.get("keywords")),
        files=None,
        vulnerabilities=_parse_vulnerabilities(data.get("vulnerabilities", [])),
        source="remote",
        retrieved_at=provenance.retrieved_at,
        freshness=provenance.freshness,
    )


def get_package(name: str, version: str | None = None) -> PackageInfo:
    """Get metadata for a package from PyPI.

    Args:
        name: Package name to look up.
        version: Optional specific version; if ``None`` the latest is fetched.

    Returns:
        A :class:`PackageInfo` with ``source="remote"``. Not-found and network
        failures propagate from :func:`_fetch`.
    """
    data, provenance = _fetch(name, version)
    return _package_from_response(data, provenance)


def _matches_exact_pin(item: Specifier, raw: str, version: Version) -> bool:
    if item.operator == "===":
        return bool(item.version.casefold() == raw.casefold())
    if item.operator != "==" or item.version.endswith(".*"):
        return False
    try:
        return bool(Version(item.version) == version)
    except InvalidVersion:
        return False


def _is_exact_pin(specifier: SpecifierSet, raw: str, version: Version) -> bool:
    return any(_matches_exact_pin(item, raw, version) for item in specifier)


def _is_arbitrary_pin(specifier: SpecifierSet, raw: str) -> bool:
    return any(
        item.operator == "===" and item.version.casefold() == raw.casefold()
        for item in specifier
    )


def _release_candidates(  # ruff: ignore[complex-structure]
    releases: dict[str, list[PyPIReleaseFile]], specifier: SpecifierSet
) -> tuple[list[str], set[Version], set[str]]:
    candidates: list[Version] = []
    arbitrary_candidates: list[str] = []
    filtered_candidates: set[Version] = set()
    filtered_arbitrary_candidates: set[str] = set()
    allows_prereleases = not specifier or specifier.prereleases is True
    for raw, files in releases.items():
        if _is_arbitrary_pin(specifier, raw):
            if not files:
                filtered_arbitrary_candidates.add(raw.casefold())
                continue
            arbitrary_candidates.append(raw)
            continue
        try:
            version = Version(raw)
        except InvalidVersion:
            if not files or all(file.get("yanked", False) for file in files):
                filtered_arbitrary_candidates.add(raw.casefold())
            continue
        if not specifier.contains(version, prereleases=allows_prereleases):
            continue
        if not files:
            filtered_candidates.add(version)
            continue
        exact_pin = _is_exact_pin(specifier, raw, version)
        fully_yanked = all(file.get("yanked", False) for file in files)
        if fully_yanked and not exact_pin:
            filtered_candidates.add(version)
            continue
        candidates.append(version)
    stable_candidates = sorted(
        (candidate for candidate in candidates if not candidate.is_prerelease),
        reverse=True,
    )
    prerelease_candidates = sorted(
        (candidate for candidate in candidates if candidate.is_prerelease), reverse=True
    )
    version_candidates = (
        sorted(candidates, reverse=True)
        if specifier.prereleases is True
        else [*stable_candidates, *prerelease_candidates]
    )
    ordered_candidates = [
        *(str(version) for version in version_candidates),
        *arbitrary_candidates,
    ]
    return ordered_candidates, filtered_candidates, filtered_arbitrary_candidates


def get_package_matching(  # ruff: ignore[complex-structure]
    name: str, specifier: SpecifierSet, marker_environment: dict[str, str] | None
) -> PackageInfo:
    """Get the newest PyPI release satisfying a dependency requirement.

    The project endpoint lists releases but only a version endpoint supplies
    historical ``Requires-Python`` and dependency metadata, so candidates are
    checked newest-first until one also supports the requested target.

    Returns:
        The newest compatible package, or the current release when no release
        satisfies the requirement so callers can surface a conflict.

    Raises:
        PackageNotFoundError: If a matching current release has no usable files.
    """
    data, provenance = _fetch(name, None)
    releases = data.get("releases") or {}
    ordered_candidates, filtered_candidates, filtered_arbitrary_candidates = (
        _release_candidates(releases, specifier)
    )
    best_incompatible: PackageInfo | None = None
    for version in ordered_candidates:
        package = (
            _package_from_response(data, provenance)
            if data.get("info") is not None  # pyright: ignore[reportUnnecessaryComparison]  # Defensive for mocked/legacy payloads.
            and version == data["info"]["version"]
            else get_package(name, version)
        )
        if supports_python(package, marker_environment):
            return package
        if best_incompatible is None:
            best_incompatible = package
    fallback = (
        _package_from_response(data, provenance)
        if data.get("info") is not None  # pyright: ignore[reportUnnecessaryComparison]  # Defensive for mocked/legacy payloads.
        else get_package(name)
    )
    if fallback.version.casefold() in filtered_arbitrary_candidates:
        if best_incompatible is not None:
            return best_incompatible
        raise PackageNotFoundError(name, fallback.version)
    try:
        fallback_version = Version(fallback.version)
    except InvalidVersion:
        return best_incompatible or fallback
    if fallback_version in filtered_candidates:
        if best_incompatible is not None:
            return best_incompatible
        raise PackageNotFoundError(name, fallback.version)
    return best_incompatible or fallback
