"""Constraint graph construction from Python dependency metadata.

Builds a ``networkx.DiGraph`` where nodes are normalised package names and
edges carry version specifier constraints. The graph is built lazily by
fetching metadata from PyPI starting from the user's direct requirements
and expanding transitive dependencies recursively.
"""

from __future__ import annotations

import logging
import re
from typing import Any

import networkx as nx  # pyright: ignore[reportMissingTypeStubs]
from packaging.markers import default_environment
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version

from depwhy.fetcher.pypi import PyPIFetcher

logger = logging.getLogger(__name__)

_ROOT_NODE = "__root__"


def _normalize(name: str) -> str:
    """Normalise a package name: lowercase, hyphens/dots to underscores.

    Follows PEP 503 normalisation rules.
    """
    return re.sub(r"[-_.]+", "_", name).lower()


def _build_environment(
    python_version: str | None = None,
    platform: str | None = None,
) -> dict[str, str]:
    """Build an environment dict for marker evaluation.

    Starts from the current interpreter's default environment and overrides
    ``python_version`` and ``sys_platform`` when provided.
    """
    env: dict[str, str] = {str(k): str(v) for k, v in default_environment().items()}
    if python_version is not None:
        env["python_version"] = python_version
        # Also set python_full_version if only major.minor provided
        if python_version.count(".") == 1:
            env["python_full_version"] = python_version + ".0"
        else:
            env["python_full_version"] = python_version
    if platform is not None:
        env["sys_platform"] = platform
        # Map common platform names to os_name equivalents
        platform_to_os: dict[str, str] = {
            "linux": "posix",
            "darwin": "posix",
            "win32": "nt",
        }
        if platform in platform_to_os:
            env["os_name"] = platform_to_os[platform]
    return env


def _pick_version(
    releases: dict[str, Any],
    specifier_str: str,
) -> str | None:
    """Pick the best version from available releases that satisfies a specifier.

    For pinned requirements (``==x.y.z``), returns that exact version if it
    exists in releases. For range specifiers, returns the latest satisfying
    version. Returns ``None`` if no version matches.
    """
    spec = SpecifierSet(specifier_str) if specifier_str else SpecifierSet()

    valid_versions: list[Version] = []
    for ver_str in releases:
        try:
            v = Version(ver_str)
        except InvalidVersion:
            continue
        # Skip pre-releases unless the specifier explicitly allows them
        if v.is_prerelease or v.is_devrelease:
            continue
        if v in spec:
            valid_versions.append(v)

    if not valid_versions:
        # Fall back: try including pre-releases
        for ver_str in releases:
            try:
                v = Version(ver_str)
            except InvalidVersion:
                continue
            if v in spec:
                valid_versions.append(v)

    if not valid_versions:
        return None

    valid_versions.sort(reverse=True)
    return str(valid_versions[0])


class GraphBuilder:
    """Builds a constraint graph from a list of requirement strings.

    The resulting ``networkx.DiGraph`` has:

    - **Nodes**: normalised package names (lowercase, underscores).
    - **Edges**: ``(dependent, dependency)`` with attributes:
        - ``specifier``: the PEP 440 version specifier string.
        - ``required_by``: human-readable string like ``"requests==2.28.0"``.
        - ``chain``: the full dependency chain as a list of strings.
    """

    def __init__(
        self,
        fetcher: PyPIFetcher,
        python_version: str | None = None,
        platform: str | None = None,
    ) -> None:
        """Initialise the graph builder.

        Args:
            fetcher: A ``PyPIFetcher`` instance for retrieving package metadata.
            python_version: Target Python version for marker evaluation, e.g. ``"3.11"``.
            platform: Target platform for marker evaluation, e.g. ``"linux"``.
        """
        self._fetcher = fetcher
        self._python_version = python_version
        self._platform = platform
        self._env = _build_environment(python_version, platform)

    async def build(  # pyright: ignore[reportUnknownParameterType]
        self, requirements: list[str]
    ) -> nx.DiGraph:  # type: ignore[type-arg]
        """Build a constraint graph from a list of PEP 508 requirement strings.

        Args:
            requirements: List of requirement strings such as
                ``["requests>=2.28", "flask==2.3.0"]``.

        Returns:
            A ``networkx.DiGraph`` representing all dependency constraints.
        """
        graph = nx.DiGraph()  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
        visited: set[str] = set()

        if not requirements:
            return graph  # pyright: ignore[reportUnknownVariableType]

        graph.add_node(_ROOT_NODE)  # pyright: ignore[reportUnknownMemberType]

        for req_str in requirements:
            req = Requirement(req_str)
            dep_name = _normalize(req.name)
            specifier = str(req.specifier) if req.specifier else ""
            chain = [_ROOT_NODE, f"{dep_name}{req.specifier}"]

            graph.add_node(dep_name)  # pyright: ignore[reportUnknownMemberType]
            graph.add_edge(  # pyright: ignore[reportUnknownMemberType]
                _ROOT_NODE,
                dep_name,
                specifier=specifier,
                required_by=_ROOT_NODE,
                chain=list(chain),
            )

            await self._expand(  # pyright: ignore[reportUnknownMemberType]
                graph, dep_name, specifier, chain, visited
            )

        return graph  # pyright: ignore[reportUnknownVariableType]

    async def _expand(
        self,
        graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType]
        package: str,
        specifier_str: str,
        parent_chain: list[str],
        visited: set[str],
    ) -> None:
        """Recursively expand a package's dependencies into the graph.

        Args:
            graph: The graph being built.
            package: Normalised package name to expand.
            specifier_str: The version specifier constraining which version to inspect.
            parent_chain: The dependency chain leading to this package.
            visited: Set of already-visited package names to prevent infinite loops.
        """
        if package in visited:
            return
        visited.add(package)

        metadata = await self._fetcher.fetch(package)
        if not metadata:
            logger.warning("No metadata found for %s, skipping expansion", package)
            return

        releases: dict[str, Any] = metadata.get("releases", {})
        info: dict[str, Any] = metadata.get("info", {})

        # Determine which version to inspect for requires_dist
        chosen_version = _pick_version(releases, specifier_str)
        if chosen_version is None:
            # Fall back to the latest version from info
            fallback: str | None = info.get("version")
            chosen_version = fallback

        if chosen_version is None:
            return

        required_by = f"{package}=={chosen_version}"

        # Get requires_dist from info
        requires_dist_raw: list[str] | None = info.get("requires_dist")

        if requires_dist_raw is None:
            return

        requires_dist: list[str] = requires_dist_raw

        for dep_str in requires_dist:
            try:
                dep_req = Requirement(dep_str)
            except Exception:
                logger.warning("Failed to parse requirement: %s", dep_str)
                continue

            # Evaluate environment markers -- skip if they don't match
            if dep_req.marker is not None:
                try:
                    if not dep_req.marker.evaluate(self._env):
                        continue
                except Exception:
                    logger.warning("Failed to evaluate marker for %s, including edge", dep_str)

            dep_name = _normalize(dep_req.name)
            dep_spec = str(dep_req.specifier) if dep_req.specifier else ""
            chain = parent_chain + [f"{dep_name}{dep_req.specifier}"]

            graph.add_node(dep_name)  # pyright: ignore[reportUnknownMemberType]

            # NetworkX DiGraph only keeps one edge per (u, v) pair.
            # For the constraint graph we allow the last writer to win since
            # the conflict detector collects all incoming edges per node.
            graph.add_edge(  # pyright: ignore[reportUnknownMemberType]
                package,
                dep_name,
                specifier=dep_spec,
                required_by=required_by,
                chain=list(chain),
            )

            await self._expand(  # pyright: ignore[reportUnknownMemberType]
                graph, dep_name, dep_spec, chain, visited
            )
