"""Conflict detection for Python dependency constraint graphs.

Walks the constraint graph built by :class:`~depwhy.graph.builder.GraphBuilder`,
collects all incoming version specifiers for each package, and determines
whether a satisfying version exists on PyPI.  Produces :class:`Conflict`
objects for hard conflicts and warning strings for narrowed-but-valid ranges.
"""

from __future__ import annotations

import logging
import re
from typing import Any

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

from depwhy.graph.models import Conflict, Constraint, FixCandidate

logger = logging.getLogger(__name__)

_ROOT_NODE = "__root__"


def _normalize(name: str) -> str:
    """Normalise a package name following PEP 503."""
    return re.sub(r"[-_.]+", "_", name).lower()


def extract_available_versions(
    pypi_responses: dict[str, dict[str, Any]],
) -> dict[str, list[str]]:
    """Extract available version strings from PyPI response data.

    Args:
        pypi_responses: Mapping of package names to their PyPI JSON response
            dicts.  Each response is expected to contain a ``"releases"`` key
            whose value is a dict mapping version strings to release file lists.

    Returns:
        A dict mapping normalised package names to sorted lists of version
        strings (newest first).
    """
    result: dict[str, list[str]] = {}
    for pkg_name, data in pypi_responses.items():
        norm = _normalize(pkg_name)
        releases: dict[str, Any] = data.get("releases", {})
        versions: list[Version] = []
        for ver_str in releases:
            try:
                versions.append(Version(ver_str))
            except InvalidVersion:
                continue
        versions.sort(reverse=True)
        result[norm] = [str(v) for v in versions]
    return result


def _versions_satisfying(
    versions: list[str],
    spec: SpecifierSet,
) -> list[Version]:
    """Return all versions from *versions* that satisfy *spec*."""
    result: list[Version] = []
    for ver_str in versions:
        try:
            v = Version(ver_str)
        except InvalidVersion:
            continue
        if v in spec:
            result.append(v)
    return result


def detect_conflicts(
    graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument]
    available_versions: dict[str, list[str]],
) -> tuple[list[Conflict], list[str]]:
    """Detect hard conflicts and soft warnings in a dependency constraint graph.

    For each package node in *graph* (excluding the ``__root__`` sentinel),
    collects all incoming edge specifiers, computes their intersection, and
    checks whether any version in *available_versions* satisfies the combined
    constraint.

    Args:
        graph: A ``networkx.DiGraph`` as produced by
            :meth:`~depwhy.graph.builder.GraphBuilder.build`.
        available_versions: Mapping of normalised package names to lists of
            available version strings (as returned by
            :func:`extract_available_versions`).

    Returns:
        A ``(conflicts, warnings)`` tuple where *conflicts* is a list of
        :class:`Conflict` objects for packages with no satisfying version, and
        *warnings* is a list of human-readable strings for packages whose
        combined range is narrower than any single constraint but still valid.
    """
    conflicts: list[Conflict] = []
    warnings: list[str] = []

    for node in graph.nodes:  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
        pkg: str = str(node)  # pyright: ignore[reportUnknownArgumentType]
        if pkg == _ROOT_NODE:
            continue

        # Collect all incoming edge specifiers
        predecessors: list[str] = list(graph.predecessors(pkg))  # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType]
        if len(predecessors) < 2:
            # A package with only one incoming constraint cannot have a
            # conflict (it either satisfies or does not exist on PyPI, but
            # that is not a *constraint* conflict).
            continue

        # Build per-edge specifier sets and Constraint objects
        edge_specs: list[tuple[str, SpecifierSet]] = []
        constraint_objects: list[Constraint] = []

        for pred in predecessors:  # pyright: ignore[reportUnknownVariableType]
            edge_data: dict[str, Any] = graph.edges[pred, pkg]  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
            spec_str: str = edge_data.get("specifier", "")
            required_by: str = edge_data.get("required_by", str(pred))
            chain: list[str] = edge_data.get("chain", [])

            constraint_objects.append(
                Constraint(
                    package=pkg,
                    specifier=spec_str,
                    required_by=required_by,
                    chain=list(chain),
                )
            )

            if spec_str:
                edge_specs.append((spec_str, SpecifierSet(spec_str)))

        # If no edge carries a specifier, nothing to conflict on
        if not edge_specs:
            continue

        # Compute the combined specifier (intersection of all)
        combined = SpecifierSet()
        for _spec_str, spec_set in edge_specs:
            combined = combined & spec_set

        # Check available versions against the combined specifier
        pkg_versions = available_versions.get(pkg, [])
        satisfying = _versions_satisfying(pkg_versions, combined)

        if not satisfying:
            # Hard conflict -- no version satisfies all constraints
            placeholder_fix = FixCandidate(
                description="",
                pip_command="",
                is_alternative=False,
            )
            conflicts.append(
                Conflict(
                    package=pkg,
                    problem="",
                    constraints=constraint_objects,
                    solution=placeholder_fix,
                    alternative=None,
                )
            )
        else:
            # Check for soft warning: combined range narrower than any single
            # constraint.  We compare the number of satisfying versions for
            # each individual spec against the combined set.
            combined_count = len(satisfying)
            for spec_str, individual_spec in edge_specs:
                individual_satisfying = _versions_satisfying(pkg_versions, individual_spec)
                if len(individual_satisfying) > combined_count:
                    warnings.append(
                        f"{pkg}: combined constraint {combined} is narrower "
                        f"than {spec_str} alone "
                        f"({combined_count} vs {len(individual_satisfying)} "
                        f"satisfying versions)"
                    )
                    break  # One warning per package is enough

    return conflicts, warnings
