import contextlib
import dataclasses
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
import warnings
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union

from pipenv import environments, resolver
from pipenv.exceptions import ResolutionFailure
from pipenv.patched.pip._internal.cache import WheelCache
from pipenv.patched.pip._internal.commands.install import InstallCommand
from pipenv.patched.pip._internal.exceptions import InstallationError
from pipenv.patched.pip._internal.models.target_python import TargetPython
from pipenv.patched.pip._internal.operations.build.build_tracker import (
    get_build_tracker,
)
from pipenv.patched.pip._internal.req.constructors import (
    install_req_from_parsed_requirement,
)
from pipenv.patched.pip._internal.req.req_file import parse_requirements
from pipenv.patched.pip._internal.req.req_install import InstallRequirement
from pipenv.patched.pip._internal.utils.temp_dir import global_tempdir_manager
from pipenv.patched.pip._vendor.packaging.utils import canonicalize_name
from pipenv.utils import console, err
from pipenv.utils.dependencies import determine_vcs_revision_hash, normalize_vcs_url
from pipenv.utils.fileutils import create_tracked_tempdir
from pipenv.utils.requirements import normalize_name

from .dependencies import (
    HackedPythonVersion,
    convert_deps_to_pip,
    expansive_install_req_from_line,
    get_constraints_from_deps,
    get_lockfile_section_using_pipfile_category,
    is_pinned_requirement,
    prepare_constraint_file,
)
from .indexes import parse_indexes, prepare_pip_source_args
from .internet import is_pypi_url
from .locking import format_requirement_for_lockfile, prepare_lockfile
from .shell import make_posix, subprocess_run, temp_environ

if sys.version_info < (3, 10):
    from pipenv.vendor import importlib_metadata
else:
    import importlib.metadata as importlib_metadata


def _get_pipfile_python_override(project):
    """Determine python_version and python_full_version overrides from the Pipfile.

    When the Pipfile specifies ``python_version = "3.11"`` (major.minor only),
    the resolver should evaluate markers as if ``python_full_version`` were
    ``"3.11.0"`` — the lowest patch release for that minor series.  This ensures
    that dependencies guarded by markers like
    ``python_full_version <= "3.11.2"`` are *included* in the lock file so that
    the lock file is portable across all patch releases of that minor version.

    Returns a dict with ``python_version`` and ``python_full_version`` keys
    suitable for passing as an environment override, or *None* if no override
    is needed.
    """
    if not project.pipfile_exists:
        return None

    requires = project.parsed_pipfile.get("requires", {})
    python_full = requires.get("python_full_version")
    python_ver = requires.get("python_version")

    if python_full and python_full != "*":
        # Explicit full version — use it directly.
        parts = python_full.split(".")
        return {
            "python_full_version": python_full,
            "python_version": ".".join(parts[:2]),
        }

    if python_ver and python_ver != "*":
        # Only major.minor specified — assume .0 patch for inclusive resolution.
        return {
            "python_full_version": f"{python_ver}.0",
            "python_version": python_ver,
        }

    return None


@contextlib.contextmanager
def _patched_marker_environment(override):
    """Context manager that monkey-patches ``default_environment`` in pip's
    vendored ``packaging.markers`` so that marker evaluation during dependency
    resolution uses the Pipfile-specified Python version rather than the
    running interpreter's version.

    Only ``python_version`` and ``python_full_version`` are overridden; all
    other environment markers (``os_name``, ``sys_platform``, etc.) remain
    unchanged.
    """
    if not override:
        yield
        return

    import pipenv.patched.pip._vendor.packaging.markers as pip_markers

    _orig = pip_markers.default_environment

    def _patched_default_environment():
        env = _orig()
        env["python_version"] = override["python_version"]
        env["python_full_version"] = override["python_full_version"]
        return env

    pip_markers.default_environment = _patched_default_environment
    try:
        yield
    finally:
        pip_markers.default_environment = _orig


def get_package_finder(
    install_cmd=None,
    options=None,
    session=None,
    platform=None,
    python_versions=None,
    abi=None,
    implementation=None,
    ignore_requires_python=None,
):
    """Reduced Shim for compatibility to generate package finders."""
    py_version_info = None
    if python_versions:
        py_version_info_python = max(python_versions)
        py_version_info = tuple([int(part) for part in py_version_info_python])
    target_python = TargetPython(
        platforms=[platform] if platform else None,
        py_version_info=py_version_info,
        abis=[abi] if abi else None,
        implementation=implementation,
    )
    return install_cmd._build_package_finder(
        options=options,
        session=session,
        target_python=target_python,
        ignore_requires_python=ignore_requires_python,
    )


class HashCacheMixin:
    """Caches hashes of PyPI artifacts so we do not need to re-download them.

    Hashes are only cached when the URL appears to contain a hash in it and the
    cache key includes the hash value returned from the server). This ought to
    avoid issues where the location on the server changes.
    """

    def __init__(self, project, session):
        self.project = project
        self.session = session

    def get_hash(self, link):
        hash_value = self.project.get_file_hash(self.session, link).encode()
        return hash_value.decode("utf8")


def _format_resolution_error(install_error):
    """Extract and format conflict details from an InstallationError exception chain.

    When pip raises an InstallationError due to a ResolutionImpossible, the original
    ResolutionImpossible exception (with its ``causes`` list) is attached as
    ``install_error.__cause__``. This helper traverses that chain and builds a
    human-readable summary of the conflicting requirements so users can diagnose
    the problem without needing to re-run with ``--verbose``.
    """
    base_msg = str(install_error)

    # Walk the exception chain to find a ResolutionImpossible cause
    cause = getattr(install_error, "__cause__", None)
    resolution_impossible = None
    while cause is not None:
        # Import lazily to avoid circular imports at module level
        try:
            from pipenv.patched.pip._vendor.resolvelib import ResolutionImpossible

            if isinstance(cause, ResolutionImpossible):
                resolution_impossible = cause
                break
        except ImportError:
            break
        cause = getattr(cause, "__cause__", None)

    if resolution_impossible is None or not getattr(
        resolution_impossible, "causes", None
    ):
        return base_msg

    lines = [base_msg, "\nThe conflict is caused by:"]
    for req_info in resolution_impossible.causes:
        requirement = getattr(req_info, "requirement", None)
        parent = getattr(req_info, "parent", None)
        if requirement is None:
            continue
        req_str = (
            requirement.format_for_error()
            if hasattr(requirement, "format_for_error")
            else str(requirement)
        )
        if parent is not None:
            parent_name = getattr(parent, "name", str(parent))
            parent_version = getattr(parent, "version", "")
            prefix = f"    {parent_name} {parent_version} depends on "
        else:
            prefix = "    The user requested "
        lines.append(prefix + req_str)

    return "\n".join(lines)


class Resolver:
    def __init__(
        self,
        constraints,
        req_dir,
        project,
        sources,
        index_lookup=None,
        markers_lookup=None,
        skipped=None,
        clear=False,
        pre=False,
        lockfile_category=None,
        original_deps=None,
        install_reqs=None,
        pipfile_entries=None,
    ):
        self.initial_constraints = constraints
        self.req_dir = req_dir
        self.project = project
        self.sources = sources
        self.resolved_tree = set()
        self.hashes = {}
        self.clear = clear
        self.pre = pre
        self.category = lockfile_category
        self.results = None
        self.markers_lookup = markers_lookup if markers_lookup is not None else {}
        self.index_lookup = index_lookup if index_lookup is not None else {}
        self.skipped = skipped if skipped is not None else {}
        self.markers = {}
        self.requires_python_markers = {}
        self.original_deps = original_deps if original_deps is not None else {}
        self.install_reqs = install_reqs if install_reqs is not None else {}
        self.pipfile_entries = pipfile_entries
        self._retry_attempts = 0
        self._hash_cache = None

    def __repr__(self):
        return (
            f"<Resolver (constraints={self.initial_constraints}, req_dir={self.req_dir}, "
            f"sources={self.sources})>"
        )

    @staticmethod
    def _get_pip_command():
        return InstallCommand(name="InstallCommand", summary="pip Install command.")

    @property
    def hash_cache(self):
        if not self._hash_cache:
            self._hash_cache = HashCacheMixin(self.project, self.session)
        return self._hash_cache

    def check_if_package_req_skipped(
        self,
        req: InstallRequirement,
    ) -> bool:
        if req.markers and not req.markers.evaluate():
            err.print(
                f"Could not find a matching version of {req}; {req.markers} for your environment, "
                "its dependencies will be skipped.",
            )
            return True
        return False

    @classmethod
    def create(
        cls,
        deps: Dict[str, str],
        project,
        index_lookup: Dict[str, str] = None,
        markers_lookup: Dict[str, str] = None,
        sources: List[str] = None,
        req_dir: str = None,
        clear: bool = False,
        pre: bool = False,
        pipfile_category: str = None,
    ) -> "Resolver":
        if not req_dir:
            req_dir = create_tracked_tempdir(suffix="-requirements", prefix="pipenv-")
        if index_lookup is None:
            index_lookup = {}
        if markers_lookup is None:
            markers_lookup = {}
        original_deps = {}
        install_reqs = {}
        pipfile_entries = project.get_pipfile_section(pipfile_category)
        skipped = {}
        if sources is None:
            sources = project.sources
        packages = project.get_pipfile_section(pipfile_category)
        constraints = set()
        for package_name, dep in deps.items():  # Build up the index and markers lookups
            if not dep:
                continue
            canonical_package_name = canonicalize_name(package_name)
            is_constraint = True
            install_req, _ = expansive_install_req_from_line(dep, expand_env=True)
            original_deps[package_name] = dep
            install_reqs[package_name] = install_req
            index, extra_index, trust_host, remainder = parse_indexes(dep)
            if package_name in packages:
                pipfile_entry = pipfile_entries.get(package_name)
                if isinstance(pipfile_entry, dict):
                    if packages[package_name].get("index"):
                        index_lookup[canonical_package_name] = packages[package_name].get(
                            "index"
                        )
                    if packages[package_name].get("skip_resolver"):
                        is_constraint = False
                        skipped[package_name] = dep
                elif index:
                    index_lookup[canonical_package_name] = index
                else:
                    index_lookup[canonical_package_name] = project.get_default_index()[
                        "name"
                    ]
            if install_req.markers:
                markers_lookup[package_name] = install_req.markers
            if is_constraint:
                constraints.add(dep)
        lockfile_category = get_lockfile_section_using_pipfile_category(pipfile_category)
        resolver = Resolver(
            set(),
            req_dir,
            project,
            sources,
            index_lookup=index_lookup,
            markers_lookup=markers_lookup,
            skipped=skipped,
            clear=clear,
            pre=pre,
            lockfile_category=lockfile_category,
            original_deps=original_deps,
            install_reqs=install_reqs,
            pipfile_entries=pipfile_entries,
        )
        for package_name, dep in original_deps.items():
            install_req = install_reqs[package_name]
            if resolver.check_if_package_req_skipped(install_req):
                resolver.skipped[package_name] = dep
        resolver.initial_constraints = constraints
        resolver.index_lookup = index_lookup
        resolver.markers_lookup = markers_lookup
        return resolver

    @property
    def pip_command(self):
        return self._get_pip_command()

    def prepare_pip_args(self, use_pep517=None, build_isolation=True):
        pip_args = []
        if self.sources:
            pip_args = prepare_pip_source_args(self.sources, pip_args)
        if use_pep517 is False:
            pip_args.append("--no-use-pep517")
        if build_isolation is False:
            pip_args.append("--no-build-isolation")
        if self.pre:
            pip_args.append("--pre")
        pip_args.extend(["--cache-dir", self.project.s.PIPENV_CACHE_DIR])
        extra_pip_args = os.environ.get("PIPENV_EXTRA_PIP_ARGS")
        if extra_pip_args:
            extra_pip_args = json.loads(extra_pip_args)
            pip_args.extend(extra_pip_args)
        return pip_args

    @property  # cached_property breaks authenticated private indexes
    def pip_args(self):
        use_pep517 = environments.get_from_env("USE_PEP517", prefix="PIP")
        build_isolation = environments.get_from_env("BUILD_ISOLATION", prefix="PIP")
        return self.prepare_pip_args(
            use_pep517=use_pep517, build_isolation=build_isolation
        )

    def prepare_constraint_file(self):
        constraint_filename = prepare_constraint_file(
            self.initial_constraints,
            directory=self.req_dir,
            sources=self.sources,
            pip_args=self.pip_args,
        )
        return constraint_filename

    @property
    def default_constraint_file(self):
        default_constraints = get_constraints_from_deps(self.project.packages)
        default_constraint_filename = prepare_constraint_file(
            default_constraints,
            directory=self.req_dir,
            sources=None,
            pip_args=None,
        )
        return default_constraint_filename

    @property  # cached_property breaks authenticated private indexes
    def pip_options(self):
        pip_options, _ = self.pip_command.parser.parse_args(self.pip_args)
        pip_options.cache_dir = self.project.s.PIPENV_CACHE_DIR
        pip_options.no_python_version_warning = True
        pip_options.no_input = self.project.settings.get("disable_pip_input", True)
        pip_options.progress_bar = "off"
        pip_options.ignore_requires_python = True
        pip_options.pre = self.pre or self.project.settings.get(
            "allow_prereleases", False
        )
        return pip_options

    @property  # Remove cached_property to prevent stale sessions and authentication issues
    def session(self):
        return self.pip_command._build_session(self.pip_options)

    def prepare_index_lookup(self):
        index_mapping = {}
        for source in self.sources:
            if source.get("name"):
                index_mapping[source["name"]] = source["url"]
        alt_index_lookup = {}
        for req_name, index in self.index_lookup.items():
            if index_mapping.get(index):
                alt_index_lookup[req_name] = [index_mapping[index]]
        return alt_index_lookup

    @property
    def package_finder(self):
        finder = get_package_finder(
            install_cmd=self.pip_command,
            options=self.pip_options,
            session=self.session,
        )
        return finder

    def finder(self, ignore_compatibility=False):
        finder = self.package_finder
        index_lookup = self.prepare_index_lookup()
        finder._link_collector.index_lookup = index_lookup
        # SearchScope is a frozen dataclass, so we need to use replace() to create
        # a new instance with the updated fields
        finder._link_collector.search_scope = dataclasses.replace(
            finder._link_collector.search_scope,
            index_restricted=True,
            index_lookup=index_lookup,
        )
        finder._ignore_compatibility = ignore_compatibility
        return finder

    @property
    def parsed_default_constraints(self):
        pip_options = self.pip_options
        pip_options.extra_index_urls = []
        # Convert Path object to string to avoid 'PosixPath' has no attribute 'decode' error
        constraint_file = (
            str(self.default_constraint_file)
            if isinstance(self.default_constraint_file, Path)
            else self.default_constraint_file
        )
        parsed_default_constraints = parse_requirements(
            constraint_file,
            constraint=True,
            finder=self.finder(),
            session=self.session,
            options=pip_options,
        )
        return list(parsed_default_constraints)

    @property
    def parsed_constraints(self):
        """Get parsed constraints including those from default packages if needed."""
        pip_options = self.pip_options
        pip_options.extra_index_urls = []
        # Convert Path object to string to avoid 'PosixPath' has no attribute 'decode' error
        constraint_file = (
            str(self.prepare_constraint_file())
            if isinstance(self.prepare_constraint_file(), Path)
            else self.prepare_constraint_file()
        )
        constraints = list(
            parse_requirements(
                constraint_file,
                finder=self.finder(),
                session=self.session,
                options=pip_options,
            )
        )

        # Only add default constraints for dev packages if setting allows
        if self.category != "default" and self.project.settings.get(
            "use_default_constraints", True
        ):
            constraints.extend(self.parsed_default_constraints)

        return constraints

    @property
    def default_constraints(self):
        """Get constraints from default section when installing dev packages."""
        if not self.project.settings.get("use_default_constraints", True):
            return set()

        possible_default_constraints = [
            install_req_from_parsed_requirement(
                c,
                isolated=self.pip_options.build_isolation,
                user_supplied=False,
            )
            for c in self.parsed_default_constraints
        ]
        return set(possible_default_constraints)

    @property
    def possible_constraints(self):
        possible_constraints_list = [
            install_req_from_parsed_requirement(
                c,
                isolated=self.pip_options.build_isolation,
                user_supplied=True,
            )
            for c in self.parsed_constraints
        ]
        return possible_constraints_list

    @property
    def constraints(self):
        """Get all applicable constraints."""
        possible_constraints_list = self.possible_constraints
        constraints_list = set()
        for c in possible_constraints_list:
            constraints_list.add(c)

        # Always use default_constraints when installing dev-packages
        if self.category != "default" and self.project.settings.get(
            "use_default_constraints", True
        ):
            constraints_list |= self.default_constraints

        return constraints_list

    @contextlib.contextmanager
    def get_resolver(self, clear=False):
        from pipenv.patched.pip._internal.utils.temp_dir import TempDirectory

        with global_tempdir_manager():
            with get_build_tracker() as build_tracker:
                with TempDirectory(globally_managed=True) as directory:
                    pip_options = self.pip_options
                    finder = self.finder()
                    wheel_cache = WheelCache(pip_options.cache_dir)
                    preparer = self.pip_command.make_requirement_preparer(
                        temp_build_dir=directory,
                        options=pip_options,
                        build_tracker=build_tracker,
                        session=self.session,
                        finder=finder,
                        use_user_site=False,
                    )
                    resolver = self.pip_command.make_resolver(
                        preparer=preparer,
                        finder=finder,
                        options=pip_options,
                        wheel_cache=wheel_cache,
                        use_user_site=False,
                        ignore_installed=True,
                        ignore_requires_python=pip_options.ignore_requires_python,
                        force_reinstall=pip_options.force_reinstall,
                        upgrade_strategy="to-satisfy-only",
                    )
                    yield resolver

    def resolve(self):
        with temp_environ(), self.get_resolver() as resolver:
            try:
                results = resolver.resolve(self.constraints, check_supported_wheels=False)
            except InstallationError as e:
                raise ResolutionFailure(message=_format_resolution_error(e)) from e
            else:
                self.results = set(results.all_requirements)
                self.resolved_tree.update(self.results)
        return self.resolved_tree

    def _get_pipfile_markers(self, pipfile_entry):
        sys_platform = pipfile_entry.get("sys_platform")
        platform_machine = pipfile_entry.get("platform_machine")
        markers = pipfile_entry.get("markers")

        if sys_platform:
            sys_platform = f"sys_platform {sys_platform}"
        if platform_machine:
            platform_machine = f"platform_machine {platform_machine}"

        combined_markers = [
            f"({marker})"
            for marker in (sys_platform, markers, platform_machine)
            if marker
        ]

        return " and ".join(combined_markers).strip()

    def _fold_markers(self, dependency_tree, install_req, checked_dependencies=None):
        if checked_dependencies is None:
            checked_dependencies = set()

        if install_req.name is None:
            return None  # Or handle this edge case differently

        comes_from = dependency_tree.get(install_req.name)
        if comes_from is None:
            return None  # Or handle this edge case differently

        # Check for recursion loop
        if install_req.name in checked_dependencies:
            return None  # Or raise an error or handle cyclic dependencies differently

        checked_dependencies.add(install_req.name)

        if comes_from == "Pipfile":
            pipfile_entry = self.pipfile_entries.get(install_req.name)
            if pipfile_entry and isinstance(pipfile_entry, dict):
                return self._get_pipfile_markers(pipfile_entry)
        else:
            markers = self._fold_markers(
                dependency_tree, comes_from, checked_dependencies
            )
            if markers:
                self.markers_lookup[install_req.name] = markers
            return markers

    def resolve_constraints(self):
        from .markers import marker_from_specifier

        # Build mapping of package origins and Python requirements
        comes_from = {}
        python_requirements = {}

        for result in self.resolved_tree:
            # Track package origin
            if isinstance(result.comes_from, InstallRequirement):
                comes_from[result.name] = result.comes_from
            else:
                comes_from[result.name] = "Pipfile"

            # Collect Python requirements from package metadata
            candidate = (
                self.finder()
                .find_best_candidate(result.name, result.specifier)
                .best_candidate
            )
            if candidate and candidate.link.requires_python:
                try:
                    marker = marker_from_specifier(candidate.link.requires_python)
                    python_requirements[result.name] = marker
                except TypeError:
                    continue

        # Build the results tree with markers
        new_tree = set()
        for result in self.resolved_tree:
            # Start with any Python requirement markers
            if result.name in python_requirements:
                marker = python_requirements[result.name]
                self.markers[result.name] = marker
                result.markers = marker
                if result.req:
                    result.req.marker = marker
            elif result.markers:
                self.markers[result.name] = result.markers
                if result.req:
                    result.req.marker = result.markers

            new_tree.add(result)

        # Use existing fold_markers to properly combine all constraints
        for result in new_tree:
            folded_markers = self._fold_markers(comes_from, result)
            if folded_markers:
                self.markers[result.name] = folded_markers
                result.markers = folded_markers
                if result.req:
                    result.req.marker = folded_markers

        self.resolved_tree = new_tree

    def collect_hashes(self, ireq):
        link = ireq.link  # Handle VCS and file links first
        if link and (link.is_vcs or (link.is_file and link.is_existing_dir())):
            return set()
        if not is_pinned_requirement(ireq):
            return set()

        sources = self.sources  # Enforce index restrictions
        canonical_ireq_name = canonicalize_name(ireq.name)
        if canonical_ireq_name in self.index_lookup:
            sources = list(
                filter(
                    lambda s: s.get("name") == self.index_lookup[canonical_ireq_name],
                    sources,
                )
            )
        source = sources[0] if sources else None
        if source:
            if is_pypi_url(source["url"]):
                hashes = self.project.get_hashes_from_pypi(ireq, source)
                if hashes:
                    return hashes
            else:
                hashes = self.project.get_hashes_from_remote_index_urls(ireq, source)
                if hashes:
                    return hashes

        # Updated section to use applicable_candidates directly
        best_candidate_result = self.finder(
            ignore_compatibility=True
        ).find_best_candidate(ireq.name, ireq.specifier)
        if best_candidate_result.applicable_candidates:
            return sorted(
                {
                    self.project.get_hash_from_link(self.hash_cache, candidate.link)
                    for candidate in best_candidate_result.applicable_candidates
                }
            )
        if link:
            return {self.project.get_hash_from_link(self.hash_cache, link)}
        return set()

    @property
    def resolve_hashes(self):
        if self.results is not None:
            for ireq in self.results:
                self.hashes[ireq] = self.collect_hashes(ireq)
        return self.hashes

    def clean_skipped_result(
        self,
        req_name: str,
        ireq: InstallRequirement,
        pipfile_entry: Union[str, Dict[str, Any]],
    ) -> Tuple[str, Dict[str, Any]]:
        """Clean up skipped requirements with better VCS handling."""
        # Start with pipfile entry if it's a dict, otherwise create new dict
        entry = pipfile_entry.copy() if isinstance(pipfile_entry, dict) else {}
        entry["name"] = req_name

        # Handle VCS references
        if ireq.link and ireq.link.is_vcs:
            vcs = ireq.link.scheme.split("+", 1)[0]

            # Try to get reference from multiple sources
            ref = determine_vcs_revision_hash(ireq, vcs, ireq.link)

            if ref:
                entry["ref"] = ref
            elif ireq.link.hash:
                entry["ref"] = ireq.link.hash

            # Ensure VCS URL is present
            if vcs not in entry:
                vcs_url, _ = normalize_vcs_url(ireq.link.url)
                entry[vcs] = vcs_url

        # Remove version if editable
        if entry.get("editable", False) and entry.get("version"):
            del entry["version"]

        # Add hashes
        collected_hashes = self.collect_hashes(ireq)
        if collected_hashes:
            entry["hashes"] = sorted(set(collected_hashes))

        return req_name, entry

    def clean_results(self) -> List[Dict[str, Any]]:
        """Clean all results including both resolved and skipped packages."""
        results = {}

        # Handle resolved packages
        for ireq in self.resolved_tree:
            if normalize_name(ireq.name) in self.skipped:
                continue

            collected_hashes = self.hashes.get(ireq, set())
            if collected_hashes:
                collected_hashes = sorted(collected_hashes)

            name, entry = format_requirement_for_lockfile(
                ireq,
                self.markers_lookup,
                self.index_lookup,
                self.original_deps,
                self.pipfile_entries,
                collected_hashes,
            )

            if name in results:
                results[name].update(entry)
            else:
                results[name] = entry

        # Handle skipped packages
        for req_name in self.skipped:
            install_req = self.install_reqs[req_name]
            pipfile_entry = self.pipfile_entries.get(req_name, {})

            name, entry = self.clean_skipped_result(req_name, install_req, pipfile_entry)

            if name in results:
                results[name].update(entry)
            else:
                results[name] = entry

        return list(results.values())


# Global cache for resolution results to avoid repeated expensive subprocess calls
_resolution_cache = {}
_resolution_cache_timestamp = {}


def _generate_resolution_cache_key(
    deps, project, pipfile_category, pre, clear, allow_global, pypi_mirror, extra_pip_args
):
    """Generate a cache key for resolution results."""
    # Get lockfile and pipfile modification times
    lockfile_mtime = "no-lock"
    if project.lockfile_location:
        lockfile_path = Path(project.lockfile_location)
        if lockfile_path.exists():
            lockfile_mtime = str(lockfile_path.stat().st_mtime)

    pipfile_mtime = "no-pipfile"
    if project.pipfile_location:
        pipfile_path = Path(project.pipfile_location)
        if pipfile_path.exists():
            pipfile_mtime = str(pipfile_path.stat().st_mtime)

    # Include environment variables that affect resolution
    env_factors = [
        os.environ.get("PIPENV_CACHE_VERSION", "1"),
        os.environ.get("PIPENV_PYPI_MIRROR", ""),
        os.environ.get("PIP_INDEX_URL", ""),
        str(pypi_mirror) if pypi_mirror else "",
        json.dumps(extra_pip_args, sort_keys=True) if extra_pip_args else "",
    ]

    # Create a deterministic representation of dependencies
    deps_str = json.dumps(deps, sort_keys=True) if isinstance(deps, dict) else str(deps)

    key_components = [
        str(project.project_directory),
        lockfile_mtime,
        pipfile_mtime,
        deps_str,
        str(pipfile_category),
        str(pre),
        str(clear),
        str(allow_global),
        "|".join(env_factors),
    ]

    key_string = "|".join(key_components)
    return hashlib.md5(key_string.encode(), usedforsecurity=False).hexdigest()


def _should_use_resolution_cache(cache_key, clear):
    """Check if we should use cached resolution results."""
    if clear:
        return False

    if cache_key not in _resolution_cache:
        return False

    if cache_key not in _resolution_cache_timestamp:
        return False

    # Cache is valid for 10 minutes
    current_time = time.time()
    cache_age = current_time - _resolution_cache_timestamp[cache_key]
    return cache_age < 600  # 10 minutes


def _show_warning(message, category, filename, lineno, line):
    warnings.showwarning(
        message=message,
        category=category,
        filename=filename,
        lineno=lineno,
        file=sys.stderr,
        line=line,
    )
    sys.stderr.flush()


def actually_resolve_deps(
    deps,
    index_lookup,
    markers_lookup,
    project,
    sources,
    clear,
    pre,
    pipfile_category,
    req_dir,
):
    with warnings.catch_warnings(record=True) as warning_list:
        resolver = Resolver.create(
            deps,
            project,
            index_lookup,
            markers_lookup,
            sources,
            req_dir,
            clear,
            pre,
            pipfile_category,
        )
        resolver.resolve()
        hashes = resolver.resolve_hashes
        resolver.resolve_constraints()
        results = resolver.clean_results()
    for warning in warning_list:
        _show_warning(
            warning.message,
            warning.category,
            warning.filename,
            warning.lineno,
            warning.line,
        )
    return (results, hashes, resolver)


def _is_download_status_line(line: str) -> bool:
    """Return True if the pip stderr line reports a file download.

    pip emits lines like::

        Downloading torch-2.0.0-cp311-cp311-linux_x86_64.whl (726.8 MB)

    to stderr during the resolution/hash-gathering phase.  We surface these
    even in non-verbose mode so that users can see *why* pipenv appears to be
    doing nothing for a long time instead of assuming it is frozen.
    """
    stripped = line.strip()
    # Match "Downloading <name>.whl (X MB)" style messages.
    if stripped.startswith("Downloading ") and (
        " MB)" in stripped
        or " kB)" in stripped
        or " KB)" in stripped
        or " GB)" in stripped
    ):
        return True
    return False


def resolve(cmd, st, project):
    import threading

    # cmd is a pre-tokenized list (not a TOML sequence); pass it directly.
    c = subprocess_run([str(x) for x in cmd], block=False, env=os.environ.copy())
    is_verbose = project.s.is_verbose()

    # Use threading to read from both stdout and stderr concurrently.
    # This prevents deadlocks when the subprocess writes a lot of data to stdout,
    # which would fill the pipe buffer and block if we only read stderr first.
    # Threading works reliably on all platforms (Windows, Linux, macOS).
    stdout_chunks = []
    stderr_lines = []

    def read_stdout():
        """Read all stdout data in chunks."""
        while True:
            chunk = c.stdout.read(4096)
            if not chunk:
                break
            stdout_chunks.append(chunk)

    def read_stderr():
        """Read stderr line by line, printing verbose output or download notices."""
        for line in iter(c.stderr.readline, ""):
            if line.rstrip():
                stderr_lines.append(line)
                if is_verbose:
                    st.console.print(line.rstrip())
                elif _is_download_status_line(line):
                    # Always show download progress so users know pipenv is not
                    # frozen when pip is fetching a large package (issue #5718).
                    err.print(f"  [dim]{line.rstrip()}[/dim]")

    # Start reader threads
    stdout_thread = threading.Thread(target=read_stdout, daemon=True)
    stderr_thread = threading.Thread(target=read_stderr, daemon=True)
    stdout_thread.start()
    stderr_thread.start()

    # Wait for both threads to complete
    stdout_thread.join()
    stderr_thread.join()

    c.wait()
    returncode = c.poll()

    out = "".join(stdout_chunks)
    errors = "".join(stderr_lines)

    if returncode != 0:
        st.console.print(environments.PIPENV_SPINNER_FAIL_TEXT.format("Locking Failed!"))
        if not is_verbose:
            err.print(errors)
            if errors and "ResolutionImpossible" in errors:
                err.print(
                    "[cyan]Hint:[/cyan] Re-run with [yellow]--verbose[/yellow] "
                    "to see the full dependency resolution output and identify "
                    "which packages are in conflict."
                )
        raise ResolutionFailure("Failed to lock Pipfile.lock!")
    if is_verbose:
        err.print(out.strip())
    return subprocess.CompletedProcess(c.args, returncode, out, errors)


def venv_resolve_deps(
    deps,
    which,
    project,
    pipfile_category,
    pre=False,
    clear=False,
    allow_global=False,
    pypi_mirror=None,
    pipfile=None,
    lockfile=None,
    old_lock_data=None,
    extra_pip_args=None,
):
    """
    Resolve dependencies for a pipenv project, acts as a portal to the target environment.

    Regardless of whether a virtual environment is present or not, this will spawn
    a subprocess which is isolated to the target environment and which will perform
    dependency resolution.  This function reads the output of that call and mutates
    the provided lockfile accordingly, returning nothing.

    :param List[:class:`~pip.InstallRequirement`] deps: A list of dependencies to resolve.
    :param Callable which: [description]
    :param project: The pipenv Project instance to use during resolution
    :param Optional[bool] pre: Whether to resolve pre-release candidates, defaults to False
    :param Optional[bool] clear: Whether to clear the cache during resolution, defaults to False
    :param Optional[bool] allow_global: Whether to use *sys.executable* as the python binary, defaults to False
    :param Optional[str] pypi_mirror: A URL to substitute any time *pypi.org* is encountered, defaults to None
    :param Optional[bool] dev: Whether to target *dev-packages* or not, defaults to False
    :param pipfile: A Pipfile section to operate on, defaults to None
    :type pipfile: Optional[Dict[str, Union[str, Dict[str, bool, List[str]]]]]
    :param Dict[str, Any] lockfile: A project lockfile to mutate, defaults to None
    :raises RuntimeError: Raised on resolution failure
    :return: The lock data
    :rtype: dict
    """
    lockfile_category = get_lockfile_section_using_pipfile_category(pipfile_category)

    if not deps:
        if not project.pipfile_exists:
            return {}
        deps = project.parsed_pipfile.get(pipfile_category, {})
    if not deps:
        return {}

    if not pipfile:
        pipfile = getattr(project, pipfile_category, {})
    if lockfile is None:
        lockfile = project.lockfile(categories=[pipfile_category])
    if old_lock_data is None:
        old_lock_data = lockfile.get(lockfile_category, {})

    # Check cache before expensive resolution
    cache_key = _generate_resolution_cache_key(
        deps,
        project,
        pipfile_category,
        pre,
        clear,
        allow_global,
        pypi_mirror,
        extra_pip_args,
    )

    if _should_use_resolution_cache(cache_key, clear):
        if project.s.is_verbose():
            err.print("[dim]Using cached resolution results...[/dim]")
        cached_results = _resolution_cache[cache_key]
        return prepare_lockfile(
            project,
            cached_results,
            pipfile,
            lockfile.get(lockfile_category, {}),
            old_lock_data,
        )

    req_dir = create_tracked_tempdir(prefix="pipenv", suffix="requirements")
    results = []
    with temp_environ():
        os.environ.update({k: str(val) for k, val in os.environ.items()})
        if pypi_mirror:
            os.environ["PIPENV_PYPI_MIRROR"] = str(pypi_mirror)
        os.environ["PIP_NO_INPUT"] = "1"
        pipenv_site_dir = get_pipenv_sitedir()
        if pipenv_site_dir is not None:
            os.environ["PIPENV_SITE_DIR"] = pipenv_site_dir
        else:
            os.environ.pop("PIPENV_SITE_DIR", None)
        if extra_pip_args:
            os.environ["PIPENV_EXTRA_PIP_ARGS"] = json.dumps(extra_pip_args)
        # Pass the Pipfile-required Python version to the resolver subprocess
        # so that marker evaluation uses it instead of the running interpreter's
        # version.  See https://github.com/pypa/pipenv/issues/5908
        python_override = _get_pipfile_python_override(project)
        if python_override:
            os.environ["PIPENV_RESOLVER_PYTHON_VERSION"] = python_override[
                "python_full_version"
            ]
        with console.status(
            f"Locking {pipfile_category}...", spinner=project.s.PIPENV_SPINNER
        ) as st:
            # This conversion is somewhat slow on local and file-type requirements since
            # we now download those requirements / make temporary folders to perform
            # dependency resolution on them, so we are including this step inside the
            # spinner context manager for the UX improvement
            st.console.print("Building requirements...")
            deps = convert_deps_to_pip(
                deps, project.pipfile_sources(), include_index=True
            )

            # Useful for debugging and hitting breakpoints in the resolver
            if project.s.PIPENV_RESOLVER_PARENT_PYTHON:
                try:
                    with _patched_marker_environment(python_override):
                        results = resolver.resolve_packages(
                            pre,
                            clear,
                            project.s.is_verbose(),
                            system=allow_global,
                            write=False,
                            requirements_dir=req_dir,
                            packages=deps,
                            pipfile_category=pipfile_category,
                            constraints=deps,
                        )
                    if results:
                        st.console.print(
                            environments.PIPENV_SPINNER_OK_TEXT.format("Success!")
                        )
                except Exception:
                    st.console.print(
                        environments.PIPENV_SPINNER_FAIL_TEXT.format("Locking Failed!")
                    )
                    raise  # maybe sys.exit(1) here?
            else:  # Default/Production behavior is to use project python's resolver
                cmd = [
                    which("python", allow_global=allow_global),
                    Path(resolver.__file__.rstrip("co")).as_posix(),
                ]
                if pre:
                    cmd.append("--pre")
                if clear:
                    cmd.append("--clear")
                if allow_global:
                    cmd.append("--system")
                if pipfile_category:
                    cmd.append("--category")
                    cmd.append(pipfile_category)
                if project.s.is_verbose():
                    cmd.append("--verbose")
                target_file = tempfile.NamedTemporaryFile(
                    prefix="resolver", suffix=".json", delete=False
                )
                target_file.close()
                cmd.extend(["--write", make_posix(target_file.name)])

                with tempfile.NamedTemporaryFile(
                    mode="w+", prefix="pipenv", suffix="constraints.txt", delete=False
                ) as constraints_file:
                    # Write the current category dependencies
                    for dep_name, pip_line in deps.items():
                        constraints_file.write(f"{dep_name}, {pip_line}\n")

                cmd.append("--constraints-file")
                cmd.append(constraints_file.name)
                st.console.print("Resolving dependencies...")
                c = resolve(cmd, st, project=project)
                if c.returncode == 0:
                    try:
                        with open(target_file.name) as fh:
                            results = json.load(fh)
                    except (IndexError, json.JSONDecodeError):
                        err.print(c.stdout.strip())
                        err.print(c.stderr.strip())
                        if os.path.exists(target_file.name):
                            os.unlink(target_file.name)
                        raise RuntimeError("There was a problem with locking.")
                    if os.path.exists(target_file.name):
                        os.unlink(target_file.name)
                    st.console.print(
                        environments.PIPENV_SPINNER_OK_TEXT.format("Success!")
                    )
                    if not project.s.is_verbose() and c.stderr.strip():
                        err.print(
                            f"Warning: {c.stderr.strip()}", overflow="ignore", crop=False
                        )
                else:
                    st.console.print(
                        environments.PIPENV_SPINNER_FAIL_TEXT.format("Locking Failed!")
                    )
                    err.print(f"Output: {c.stdout.strip()}")
                    err.print(f"Error: {c.stderr.strip()}")
                    # Provide helpful hints for common build errors
                    # See: https://github.com/pypa/pipenv/issues/6058
                    combined_output = (c.stdout + c.stderr).lower()
                    if "getting requirements to build wheel" in combined_output:
                        err.print(
                            "\n[cyan]Hint:[/cyan] The error 'Getting requirements to build wheel' often indicates:\n"
                            "  • Invalid pyproject.toml syntax or configuration\n"
                            "  • Encoding issues in files referenced by pyproject.toml (e.g., README.md with special characters)\n"
                            "  • Missing or incompatible build dependencies\n"
                            "Try running [yellow]$ pip install . -v[/yellow] in your project directory for more detailed error output."
                        )

    # Cache the results for future use
    if results:
        _resolution_cache[cache_key] = results
        _resolution_cache_timestamp[cache_key] = time.time()

        # Clean old cache entries (keep only last 5 projects)
        if len(_resolution_cache) > 5:
            oldest_key = min(
                _resolution_cache_timestamp.keys(),
                key=lambda k: _resolution_cache_timestamp[k],
            )
            _resolution_cache.pop(oldest_key, None)
            _resolution_cache_timestamp.pop(oldest_key, None)

    if lockfile_category not in lockfile:
        lockfile[lockfile_category] = {}
    return prepare_lockfile(
        project, results, pipfile, lockfile[lockfile_category], old_lock_data
    )


def resolve_deps(
    deps,
    which,
    project,
    sources=None,
    python=False,
    clear=False,
    pre=False,
    pipfile_category=None,
    allow_global=False,
    req_dir=None,
):
    """Given a list of dependencies, return a resolved list of dependencies,
    and their hashes, using the warehouse API / pip.
    """
    index_lookup = {}
    markers_lookup = {}
    if not os.environ.get("PIP_SRC"):
        os.environ["PIP_SRC"] = str(project.virtualenv_src_location)
    results = []
    resolver = None
    if not deps:
        return results, resolver
    # First (proper) attempt:
    if not req_dir:
        req_dir = create_tracked_tempdir(prefix="pipenv-", suffix="-requirements")
    python_override = _get_pipfile_python_override(project)
    with HackedPythonVersion(python_path=project.python(system=allow_global)):
        with _patched_marker_environment(python_override):
            try:
                results, hashes, internal_resolver = actually_resolve_deps(
                    deps,
                    index_lookup,
                    markers_lookup,
                    project,
                    sources,
                    clear,
                    pre,
                    pipfile_category,
                    req_dir=req_dir,
                )
            except RuntimeError:
                # Don't exit here, like usual.
                results = None
    # Second (last-resort) attempt:
    if results is None:
        with HackedPythonVersion(
            python_path=project.python(system=allow_global),
        ):
            with _patched_marker_environment(python_override):
                try:
                    # Attempt to resolve again, with different Python version
                    # information, particularly for particularly particular
                    # packages.
                    (
                        results,
                        hashes,
                        internal_resolver,
                    ) = actually_resolve_deps(
                        deps,
                        index_lookup,
                        markers_lookup,
                        project,
                        sources,
                        clear,
                        pre,
                        pipfile_category,
                        req_dir=req_dir,
                    )
                except RuntimeError:
                    sys.exit(1)
    return results, internal_resolver


def get_pipenv_sitedir() -> Optional[str]:
    for dist in importlib_metadata.distributions():
        if dist.metadata.get("Name", "").lower() == "pipenv":
            return str(dist.locate_file(""))
    return None
