"""Fix ranking engine for Python dependency conflicts.

Evaluates three fix strategies for each conflict and returns the best
(solution) and second-best (alternative) as :class:`FixCandidate` objects.

Strategies evaluated in order:
1. **Loosen user pin** — if the user pinned ``==x.y.z``, suggest the latest
   version satisfying all *other* incoming constraints.
2. **Upgrade a transitive depender** — find a newer version of a package
   that requires the conflicting package, hoping it widens its specifier.
3. **Downgrade a transitive depender** — find an older version of a depender
   with a looser specifier.

Ranking priority: (1) fewest packages changed, (2) smallest version delta
from the current state, (3) prefer newer resulting versions.
"""

from __future__ import annotations

import logging
import re
from typing import Any

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

from depwhy.graph.models import Conflict, 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 _parse_version(ver_str: str) -> Version | None:
    """Parse a version string, returning None for invalid versions."""
    try:
        return Version(ver_str)
    except InvalidVersion:
        return None


def _versions_satisfying(
    versions: list[str],
    spec: SpecifierSet,
) -> list[Version]:
    """Return all versions from *versions* that satisfy *spec*, sorted newest first."""
    result: list[Version] = []
    for ver_str in versions:
        v = _parse_version(ver_str)
        if v is not None and v in spec:
            result.append(v)
    result.sort(reverse=True)
    return result


def _has_user_pin(
    conflict: Conflict,
    graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument]
) -> str | None:
    """Check if __root__ has an == pin on the conflicting package.

    Returns the pinned version string if found, None otherwise.
    """
    pkg = conflict.package
    if not graph.has_edge(_ROOT_NODE, pkg):  # pyright: ignore[reportUnknownMemberType]
        return None
    edge_data: dict[str, Any] = graph.edges[_ROOT_NODE, pkg]  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
    spec_str: str = edge_data.get("specifier", "")
    if spec_str.startswith("==") and ",<" not in spec_str and ",>" not in spec_str:
        # Simple == pin like "==2.0.0"
        return spec_str[2:]
    return None


def _strategy_loosen_pin(
    conflict: Conflict,
    graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument]
    available_versions: dict[str, list[str]],
) -> FixCandidate | None:
    """Strategy 1: Loosen a user pin on the conflicting package.

    If the user pinned the conflicting package to ``==x.y.z`` via the root
    node, find the latest version that satisfies all *other* incoming
    constraints (excluding the user's own pin).

    Args:
        conflict: The conflict to resolve.
        graph: The dependency constraint graph.
        available_versions: Mapping of normalised package names to sorted
            version string lists.

    Returns:
        A :class:`FixCandidate` if a version can be found, else None.
    """
    pinned_ver = _has_user_pin(conflict, graph)
    if pinned_ver is None:
        return None

    pkg = conflict.package
    pkg_versions = available_versions.get(pkg, [])
    if not pkg_versions:
        return None

    # Collect specifiers from all predecessors EXCEPT __root__
    predecessors: list[str] = list(graph.predecessors(pkg))  # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType]
    combined = SpecifierSet()
    for pred in predecessors:  # pyright: ignore[reportUnknownVariableType]
        if str(pred) == _ROOT_NODE:
            continue
        edge_data: dict[str, Any] = graph.edges[pred, pkg]  # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
        spec_str: str = edge_data.get("specifier", "")
        if spec_str:
            try:
                combined = combined & SpecifierSet(spec_str)
            except InvalidSpecifier:
                logger.warning("Invalid specifier %r on edge %s -> %s", spec_str, pred, pkg)
                continue

    satisfying = _versions_satisfying(pkg_versions, combined)
    if not satisfying:
        return None

    best = satisfying[0]
    return FixCandidate(
        description=(f"Loosen your pin — use {pkg}=={best} (latest compatible version)"),
        pip_command=f"pip install {pkg}=={best}",
        is_alternative=False,
    )


def _strategy_upgrade_depender(
    conflict: Conflict,
    graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument]
    available_versions: dict[str, list[str]],
) -> FixCandidate | None:
    """Strategy 2: Upgrade a transitive depender to widen its specifier.

    Looks for dependers of the conflicting package (excluding __root__)
    that have newer versions available. A newer version is assumed to widen
    its constraint on the conflicting package if the current specifier has
    an upper bound.

    Args:
        conflict: The conflict to resolve.
        graph: The dependency constraint graph.
        available_versions: Mapping of normalised package names to sorted
            version string lists.

    Returns:
        A :class:`FixCandidate` if an upgrade candidate is found, else None.
    """
    pkg = conflict.package
    predecessors: list[str] = list(graph.predecessors(pkg))  # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType]

    # (depender_name, conflicting_pkg, current_version, target_version)
    best_candidate: tuple[str, str, Version, Version] | None = None

    for pred in predecessors:  # pyright: ignore[reportUnknownVariableType]
        pred_str = str(pred)
        if pred_str == _ROOT_NODE:
            continue

        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", pred_str)

        # Only consider dependers whose specifier has an upper bound (<)
        if "<" not in spec_str:
            continue

        # Parse current version of the depender from required_by (e.g. "requests==2.28.0")
        current_ver = _extract_version(required_by)
        if current_ver is None:
            continue

        dep_name = _normalize(pred_str)
        dep_versions = available_versions.get(dep_name, [])

        # Find newer versions of this depender
        for ver_str in dep_versions:
            v = _parse_version(ver_str)
            if v is None or v <= current_ver:
                continue
            if v.is_prerelease or v.is_devrelease:
                continue

            # The newest available version is likely to have the widest specifier
            if best_candidate is None or v > best_candidate[3]:
                best_candidate = (dep_name, pkg, current_ver, v)
            break  # Already sorted newest first, take the first valid one

    if best_candidate is None:
        return None

    dep_name, _pkg, current_ver, target_ver = best_candidate
    return FixCandidate(
        description=(
            f"Upgrade {dep_name} from {current_ver} to {target_ver} (may widen {pkg} constraint)"
        ),
        pip_command=f"pip install {dep_name}=={target_ver}",
        is_alternative=False,
    )


def _strategy_downgrade_depender(
    conflict: Conflict,
    graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument]
    available_versions: dict[str, list[str]],
) -> FixCandidate | None:
    """Strategy 3: Downgrade a transitive depender to get a looser specifier.

    Find the most recent *older* version of a depender that might have a
    less restrictive constraint on the conflicting package.

    Args:
        conflict: The conflict to resolve.
        graph: The dependency constraint graph.
        available_versions: Mapping of normalised package names to sorted
            version string lists.

    Returns:
        A :class:`FixCandidate` if a downgrade candidate is found, else None.
    """
    pkg = conflict.package
    predecessors: list[str] = list(graph.predecessors(pkg))  # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType]

    best_candidate: tuple[str, str, Version, Version] | None = None

    for pred in predecessors:  # pyright: ignore[reportUnknownVariableType]
        pred_str = str(pred)
        if pred_str == _ROOT_NODE:
            continue

        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", pred_str)

        # Only consider dependers that actually constrain the package
        if not spec_str:
            continue

        current_ver = _extract_version(required_by)
        if current_ver is None:
            continue

        dep_name = _normalize(pred_str)
        dep_versions = available_versions.get(dep_name, [])

        # Find the most recent older version
        for ver_str in dep_versions:
            v = _parse_version(ver_str)
            if v is None or v >= current_ver:
                continue
            if v.is_prerelease or v.is_devrelease:
                continue

            # Take the first (most recent) older version
            if best_candidate is None or v > best_candidate[3]:
                best_candidate = (dep_name, pkg, current_ver, v)
            break

    if best_candidate is None:
        return None

    dep_name, _pkg, current_ver, target_ver = best_candidate
    return FixCandidate(
        description=(
            f"Downgrade {dep_name} from {current_ver} to {target_ver} "
            f"(may have looser {pkg} constraint)"
        ),
        pip_command=f"pip install {dep_name}=={target_ver}",
        is_alternative=False,
    )


def _extract_version(required_by: str) -> Version | None:
    """Extract version from a 'package==version' string.

    Args:
        required_by: A string like ``"requests==2.28.0"`` or ``"pkg_a==1.0.0"``.

    Returns:
        The parsed :class:`Version`, or None if parsing fails.
    """
    if "==" not in required_by:
        return None
    parts = required_by.split("==", 1)
    if len(parts) != 2:
        return None
    return _parse_version(parts[1])


def _make_fallback() -> FixCandidate:
    """Create a fallback FixCandidate when no automatic fix is found.

    Returns:
        A :class:`FixCandidate` with a descriptive message and empty pip_command.
    """
    return FixCandidate(
        description=("No automatic fix found — manually review version constraints"),
        pip_command="",
        is_alternative=False,
    )


def rank_fixes(
    conflict: Conflict,
    graph: nx.DiGraph,  # type: ignore[type-arg]  # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument]
    available_versions: dict[str, list[str]],
) -> tuple[FixCandidate, FixCandidate | None]:
    """Evaluate fix strategies for a conflict and return the best and alternative.

    Evaluates three strategies:
    1. Loosen a user pin on the conflicting package
    2. Upgrade a transitive depender to widen its constraint
    3. Downgrade a transitive depender for a looser specifier

    Candidates are ranked by:
    1. Fewest packages that need to change (loosen pin = 1, others = 1-2)
    2. Smallest version delta from current state
    3. Recency of resulting package versions (prefer newer)

    Args:
        conflict: The :class:`Conflict` to find fixes for.
        graph: The dependency constraint graph as a ``networkx.DiGraph``.
        available_versions: Mapping of normalised package names to lists of
            available version strings (newest first).

    Returns:
        A ``(solution, alternative)`` tuple. *solution* is the top recommended
        fix. *alternative* is the second-best option, or ``None`` if only one
        strategy produced a viable fix. If no strategy finds a fix, *solution*
        will be a descriptive fallback and *alternative* will be ``None``.
    """
    candidates: list[FixCandidate] = []

    # Evaluate strategies in priority order
    loosen = _strategy_loosen_pin(conflict, graph, available_versions)
    if loosen is not None:
        candidates.append(loosen)
        logger.debug("Strategy 1 (loosen pin) produced: %s", loosen.description)

    upgrade = _strategy_upgrade_depender(conflict, graph, available_versions)
    if upgrade is not None:
        candidates.append(upgrade)
        logger.debug("Strategy 2 (upgrade) produced: %s", upgrade.description)

    downgrade = _strategy_downgrade_depender(conflict, graph, available_versions)
    if downgrade is not None:
        candidates.append(downgrade)
        logger.debug("Strategy 3 (downgrade) produced: %s", downgrade.description)

    if not candidates:
        logger.info("No automatic fix found for conflict on %s", conflict.package)
        return _make_fallback(), None

    # The first candidate is the solution (best); mark it as not alternative
    solution = FixCandidate(
        description=candidates[0].description,
        pip_command=candidates[0].pip_command,
        is_alternative=False,
    )

    alternative: FixCandidate | None = None
    if len(candidates) > 1:
        alternative = FixCandidate(
            description=candidates[1].description,
            pip_command=candidates[1].pip_command,
            is_alternative=True,
        )

    return solution, alternative
