import datetime
from types import SimpleNamespace
from unittest import mock

import pytest

from pipenv.patched.pip._internal.index.package_finder import (
    LinkEvaluator,
    LinkType,
    PackageFinder,
)
from pipenv.patched.pip._internal.models.link import Link
from pipenv.patched.pip._internal.models.target_python import TargetPython
from pipenv.patched.pip._internal.req.constructors import parse_editable
from pipenv.patched.pip._internal.resolution.resolvelib.provider import PipProvider
from pipenv.patched.pip._vendor.resolvelib.structs import RequirementInformation
from pipenv.utils.resolver import Resolver, _get_cool_down_timedelta


def _conflict_info(name, parent=None):
    parent_obj = None if parent is None else SimpleNamespace(name=parent)
    return RequirementInformation(SimpleNamespace(name=name), parent_obj)


@pytest.mark.utils
def test_parse_editable_vcs_preserves_extras():
    name, url, extras = parse_editable(
        "git+https://example.invalid/requests.git#egg=requests[socks]"
    )

    assert name == "requests"
    assert url == "git+https://example.invalid/requests.git#egg=requests"
    assert extras == {"socks"}


@pytest.mark.utils
def test_pip_provider_promotes_repeated_conflict_identifiers():
    provider = PipProvider(
        factory=mock.sentinel.factory,
        constraints={},
        ignore_dependencies=False,
        only_dependencies=False,
        upgrade_strategy="to-satisfy-only",
        user_requested={},
    )

    for _ in range(5):
        selected = list(
            provider.narrow_requirement_selection(
                identifiers=["sentry-protos"],
                resolutions={},
                candidates={},
                information={},
                backtrack_causes=[_conflict_info("protobuf")],
            )
        )
        assert selected == ["sentry-protos"]

    selected = list(
        provider.narrow_requirement_selection(
            identifiers=["sentry-protos", "protobuf"],
            resolutions={},
            candidates={},
            information={},
            backtrack_causes=[],
        )
    )

    assert selected == ["protobuf"]


@pytest.mark.utils
def test_pip_provider_prefers_promoted_conflict_identifier():
    provider = PipProvider(
        factory=mock.sentinel.factory,
        constraints={},
        ignore_dependencies=False,
        only_dependencies=False,
        upgrade_strategy="to-satisfy-only",
        user_requested={},
    )

    for _ in range(5):
        list(
            provider.narrow_requirement_selection(
                identifiers=["sentry-protos"],
                resolutions={},
                candidates={},
                information={},
                backtrack_causes=[_conflict_info("protobuf")],
            )
        )

    information = {"protobuf": (), "sentry-protos": ()}
    protobuf_preference = provider.get_preference(
        "protobuf", {}, {}, information, []
    )
    sentry_preference = provider.get_preference(
        "sentry-protos", {}, {}, information, []
    )

    assert protobuf_preference < sentry_preference


def _make_hash_resolver():
    resolver = Resolver.__new__(Resolver)
    resolver.sources = [{"name": "pypi", "url": "https://pypi.org/simple"}]
    resolver.index_lookup = {}
    resolver._hash_cache = mock.sentinel.hash_cache
    resolver.project = mock.MagicMock()
    resolver.project.s.is_verbose.return_value = True
    return resolver


@pytest.mark.utils
def test_collect_hashes_does_not_warn_when_fallback_succeeds(monkeypatch):
    resolver = _make_hash_resolver()
    resolver.project.sources.get_hashes_from_pypi.return_value = None
    resolver.project.sources.get_hash_from_link.return_value = "sha256:abc123"

    candidate = SimpleNamespace(link=mock.sentinel.link)
    best_candidate_result = SimpleNamespace(applicable_candidates=[candidate])
    resolver.finder = mock.Mock(
        return_value=SimpleNamespace(
            find_best_candidate=mock.Mock(return_value=best_candidate_result)
        )
    )

    ireq = mock.MagicMock()
    ireq.link = None
    ireq.name = "regex"
    ireq.specifier = mock.sentinel.specifier

    monkeypatch.setattr("pipenv.utils.resolver.is_pinned_requirement", lambda _: True)

    with mock.patch("pipenv.utils.resolver.err.print") as err_print:
        hashes = resolver.collect_hashes(ireq)

    assert hashes == ["sha256:abc123"]
    err_print.assert_not_called()


@pytest.mark.utils
def test_collect_hashes_warns_once_when_all_strategies_fail(monkeypatch):
    resolver = _make_hash_resolver()
    resolver.project.sources.get_hashes_from_pypi.return_value = None
    best_candidate_result = SimpleNamespace(applicable_candidates=[])
    resolver.finder = mock.Mock(
        return_value=SimpleNamespace(
            find_best_candidate=mock.Mock(return_value=best_candidate_result)
        )
    )

    ireq = mock.MagicMock()
    ireq.link = None
    ireq.name = "regex"
    ireq.specifier = mock.sentinel.specifier

    monkeypatch.setattr("pipenv.utils.resolver.is_pinned_requirement", lambda _: True)

    with mock.patch("pipenv.utils.resolver.err.print") as err_print:
        hashes = resolver.collect_hashes(ireq)

    assert hashes == set()
    err_print.assert_called_once_with(
        "[bold][red]Warning[/red][/bold]: Error generating hash for regex."
    )


@pytest.mark.utils
def test_parsed_constraints_are_cached(monkeypatch, tmp_path):
    project = mock.MagicMock()
    project.s.PIPENV_CACHE_DIR = str(tmp_path / "cache")
    project.settings.get.return_value = False
    project.packages = {}

    resolver = Resolver(set(), str(tmp_path), project, sources=[])
    pip_options = SimpleNamespace(extra_index_urls=[], build_isolation=False)
    calls = {"prepare": 0, "parse": 0}

    def fake_prepare_constraint_file(*args, **kwargs):
        calls["prepare"] += 1
        constraint_file = tmp_path / f"constraints-{calls['prepare']}.txt"
        constraint_file.write_text("")
        return str(constraint_file)

    def fake_parse_requirements(*args, **kwargs):
        calls["parse"] += 1
        return [SimpleNamespace(requirement=f"req-{calls['parse']}")]

    monkeypatch.setattr(Resolver, "pip_options", property(lambda self: pip_options))
    monkeypatch.setattr(Resolver, "session", property(lambda self: mock.sentinel.session))
    monkeypatch.setattr(
        Resolver,
        "finder",
        lambda self, ignore_compatibility=False: mock.sentinel.finder,
    )
    monkeypatch.setattr(
        "pipenv.utils.resolver.prepare_constraint_file",
        fake_prepare_constraint_file,
    )
    monkeypatch.setattr(
        "pipenv.utils.resolver.parse_requirements",
        fake_parse_requirements,
    )

    first = resolver.parsed_constraints
    second = resolver.parsed_constraints

    assert first is second
    assert calls == {"prepare": 1, "parse": 1}


class _ResolvedRequirement:
    def __init__(self, name):
        self.name = name
        self.specifier = ""
        self.comes_from = None
        self.markers = None
        self.req = None

    def __hash__(self):
        return hash(self.name)

    def __eq__(self, other):
        return isinstance(other, _ResolvedRequirement) and self.name == other.name


@pytest.mark.utils
def test_resolve_constraints_reads_requires_python_off_resolved_link():
    """``resolve_constraints`` must read ``result.link.requires_python``
    directly from the resolved tree — NOT re-query pip's
    ``PackageFinder.find_best_candidate``.

    Pre-2026-05 perf-cut the method called ``find_best_candidate`` once
    per resolved package solely to extract ``requires-python`` off the
    winning candidate's link.  That was an ~8.9 s second-pass index
    walk on the 100-pkg bench.  pip's resolvelib already stores the
    chosen candidate on every InstallRequirement it returns, so the
    same data is one attribute lookup away.  Pin the new behaviour:
    no ``finder()`` call, no ``find_best_candidate`` call.
    """
    resolver = Resolver.__new__(Resolver)

    # Two resolved entries, each carrying a link with the marker we
    # expect to flow into self.markers.  Only the second package has a
    # requires-python advert.
    req_a = _ResolvedRequirement("requests")
    req_a.link = SimpleNamespace(requires_python=None)
    req_b = _ResolvedRequirement("certifi")
    req_b.link = SimpleNamespace(requires_python=">=3.8")
    resolver.resolved_tree = {req_a, req_b}
    resolver.markers = {}
    resolver.markers_lookup = {}
    resolver.pipfile_entries = {}

    # Guard rail: any call to .finder() OR finder.find_best_candidate
    # would mean we slipped back into the old slow path.  Make the
    # finder explosive so the test fails loudly if that regression
    # ever reappears.
    resolver.finder = mock.Mock(
        side_effect=AssertionError(
            "resolve_constraints must not call self.finder(); read "
            "result.link.requires_python directly"
        )
    )

    Resolver.resolve_constraints(resolver)

    # certifi's marker landed on the resolver state; requests has no
    # requires-python advert and therefore no marker.
    assert "certifi" in resolver.markers
    assert "requests" not in resolver.markers


@pytest.mark.utils
def test_resolve_constraints_marker_for_ignore_compatibility_link():
    """``resolve_constraints`` extracts the ``requires-python`` marker
    even when the resolved item's link came from a lenient-finder path.

    This pins the post-2026-05 behaviour change.  Before commit
    ``cf53eb17``, ``resolve_constraints`` called
    ``self.finder().find_best_candidate(name, specifier)`` with the
    *strict* finder (default ``ignore_compatibility=False``).  For a
    resolved item whose link came from the
    ``pip_finder_ignore_compatability`` patched-pip flag — i.e., a
    cross-platform locking workflow where the chosen wheel wouldn't
    pass strict compatibility checks — the strict finder's
    ``find_best_candidate`` returned ``None`` and the marker was
    silently dropped from the lockfile entry.

    After the perf fix, we read ``result.link.requires_python``
    directly off the resolved item.  The link's advertised
    ``requires-python`` is the same string regardless of which finder
    produced the resolve, so cross-compat packages now get their
    markers in the lockfile.

    This test simulates the ignore_compatibility path by attaching a
    link whose ``requires_python`` is advertised but which would not
    pass strict-finder compatibility filtering.  The marker MUST land
    in ``resolver.markers``.
    """
    resolver = Resolver.__new__(Resolver)
    cross_compat = _ResolvedRequirement("torch")
    cross_compat.link = SimpleNamespace(
        requires_python=">=3.10",
        # Mark explicitly so a future reader knows this represents
        # a lenient-finder-resolved candidate.
        _resolved_via_ignore_compatibility=True,
    )
    resolver.resolved_tree = {cross_compat}
    resolver.markers = {}
    resolver.markers_lookup = {}
    resolver.pipfile_entries = {}

    # The new code path must NEVER call self.finder() — strict or
    # lenient — so make the finder explosive.
    resolver.finder = mock.Mock(
        side_effect=AssertionError(
            "resolve_constraints must not call self.finder(); the marker "
            "is read directly from result.link.requires_python regardless "
            "of which finder produced the resolve"
        )
    )

    Resolver.resolve_constraints(resolver)

    # The marker landed in resolver.markers despite this being a
    # link that wouldn't have passed strict-finder filtering.
    assert "torch" in resolver.markers, (
        "Cross-compat link's requires-python advert was dropped — this "
        "regresses commit cf53eb17"
    )
    resolver.finder.assert_not_called()


@pytest.mark.utils
def test_resolve_hashes_runs_in_parallel():
    """resolve_hashes should dispatch collect_hashes concurrently and still
    populate resolver.hashes with every ireq -> hashes pair."""
    import threading

    resolver = Resolver.__new__(Resolver)

    class _HashableIreq:
        def __init__(self, name):
            self.name = name

        def __hash__(self):
            return hash(self.name)

        def __eq__(self, other):
            return isinstance(other, _HashableIreq) and self.name == other.name

    ireqs = [_HashableIreq(f"pkg-{i}") for i in range(32)]
    resolver.results = ireqs
    resolver.hashes = {}
    # Pre-stub _hash_finder so the eager initialization in resolve_hashes
    # does not attempt to build a real PackageFinder (which requires
    # resolver.project and other live state that the stub resolver lacks).
    resolver._hash_finder = mock.Mock()

    concurrent = 0
    peak = 0
    started = 0
    lock = threading.Lock()
    overlap_barrier = threading.Barrier(2, timeout=5)

    def slow_collect(ireq):
        nonlocal concurrent, peak, started
        should_wait_for_overlap = False
        with lock:
            concurrent += 1
            peak = max(peak, concurrent)
            started += 1
            should_wait_for_overlap = started <= 2
        try:
            # Deterministically require the first two calls to overlap instead of
            # relying on short real-time waits that can be flaky on slow CI.
            if should_wait_for_overlap:
                overlap_barrier.wait()
        except threading.BrokenBarrierError:
            pytest.fail(
                "Timed out waiting for two collect_hashes calls to overlap; "
                "expected resolver hash collection to run in parallel."
            )
        finally:
            with lock:
                concurrent -= 1
        return {f"sha256:hash-for-{ireq.name}"}

    resolver.collect_hashes = slow_collect
    result = Resolver.resolve_hashes.fget(resolver)

    assert peak >= 2, "collect_hashes should have run concurrently"
    assert result is resolver.hashes
    assert len(resolver.hashes) == len(ireqs)
    for ireq in ireqs:
        assert resolver.hashes[ireq] == {f"sha256:hash-for-{ireq.name}"}


@pytest.mark.utils
def test_resolve_constraints_does_not_walk_package_finder():
    """``resolve_constraints`` must complete without invoking pip's
    ``PackageFinder`` at all — the requires-python marker is read off
    each resolved item's ``link`` attribute.

    Replaces the pre-2026-05 parallelism-overlap test that pinned the
    old ``ThreadPoolExecutor.find_best_candidate`` fan-out.  The new
    contract is "no second-pass network walk"; concurrency is
    irrelevant because there is no I/O to overlap.
    """
    resolver = Resolver.__new__(Resolver)

    # Sixteen resolved entries, none carrying a requires-python advert.
    # The body of resolve_constraints should walk them all without
    # touching the finder.
    items = set()
    for i in range(16):
        item = _ResolvedRequirement(f"pkg-{i}")
        item.link = SimpleNamespace(requires_python=None)
        items.add(item)
    resolver.resolved_tree = items
    resolver.markers = {}
    resolver.markers_lookup = {}
    resolver.pipfile_entries = {}

    # Explosive finder catches any regression back to the old slow path.
    resolver.finder = mock.Mock(
        side_effect=AssertionError("resolve_constraints must not call self.finder()")
    )

    Resolver.resolve_constraints(resolver)

    # No markers landed (none of the items advertised requires-python),
    # and resolver.finder was never invoked.
    resolver.finder.assert_not_called()
    assert resolver.markers == {}


@pytest.mark.utils
def test_prepare_index_lookup_is_cached():
    resolver = Resolver.__new__(Resolver)
    resolver._prepared_index_lookup = None
    resolver.sources = [
        {"name": "pypi", "url": "https://pypi.org/simple"},
        {"name": "internal", "url": "https://example.com/simple"},
    ]
    resolver.index_lookup = {"foo": "internal"}

    first = resolver.prepare_index_lookup()
    # Mutate inputs after the first call — cached result should be returned.
    resolver.sources.append({"name": "other", "url": "https://other"})
    resolver.index_lookup["bar"] = "pypi"
    second = resolver.prepare_index_lookup()

    assert first is second
    assert "bar" not in first


# T_F.3 Wave B1: ``test_process_resolver_results_does_not_scan_reverse_dependencies``
# previously pinned ``pipenv.resolver.process_resolver_results``, which
# constructed legacy ``Entry`` dataclasses and could (incorrectly) probe
# ``project.environment.reverse_dependencies``.  Both ``Entry`` and
# ``process_resolver_results`` were deleted in Wave B1; the typed
# resolve pipeline never reaches ``project.environment`` at all, so the
# regression the test guarded is structurally impossible in the new
# world.  Replacement coverage at the schema level lives in
# ``tests/unit/test_resolver_schema.py``.


# ---------------------------------------------------------------------------
# cool-down-period / --uploaded-prior-to tests
# ---------------------------------------------------------------------------


def _cool_down_link_evaluator():
    return LinkEvaluator(
        project_name="demo",
        canonical_name="demo",
        formats=frozenset({"binary", "source"}),
        target_python=TargetPython(),
        allow_yanked=True,
        uploaded_prior_to=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
    )


@pytest.mark.utils
def test_cool_down_accepts_index_links_without_upload_time():
    evaluator = _cool_down_link_evaluator()
    link = Link(
        "https://example.org/demo-1.0-py3-none-any.whl",
        comes_from="https://example.org/simple/demo/",
    )

    candidate = PackageFinder.get_install_candidate(None, evaluator, link)

    assert candidate is not None
    assert str(candidate.version) == "1.0"


@pytest.mark.utils
def test_cool_down_still_rejects_recent_index_links():
    evaluator = _cool_down_link_evaluator()
    link = Link(
        "https://example.org/demo-2.0-py3-none-any.whl",
        comes_from="https://example.org/simple/demo/",
        upload_time=datetime.datetime(2026, 2, 1, tzinfo=datetime.timezone.utc),
    )

    result, _ = evaluator.evaluate_link(link)

    assert result == LinkType.upload_too_late

def _make_project(cool_down_period):
    """Return a mock project whose [pipenv] section contains cool-down-period."""
    project = mock.MagicMock()
    settings = {}
    if cool_down_period is not None:
        settings["cool-down-period"] = cool_down_period
    project.settings = settings
    return project


@pytest.mark.utils
@pytest.mark.parametrize("value,expected_days", [
    ("30d",  30),
    ("1d",   1),
    ("365d", 365),
])
def test_get_cool_down_timedelta_valid(value, expected_days):
    import datetime
    project = _make_project(value)
    result = _get_cool_down_timedelta(project)
    assert result == datetime.timedelta(days=expected_days)


@pytest.mark.utils
@pytest.mark.parametrize("value", [None, "", "30days", "30h", "P30D", "1 d", "d"])
def test_get_cool_down_timedelta_invalid_or_absent(value):
    project = _make_project(value)
    assert _get_cool_down_timedelta(project) is None


@pytest.mark.utils
def test_pip_options_sets_uploaded_prior_to_from_cool_down_period(monkeypatch):
    """Resolver.pip_options sets uploaded_prior_to when cool-down-period is configured."""
    import datetime

    project = _make_project("30d")
    project.s.PIPENV_CACHE_DIR = "/tmp/cache"
    project.s.PIPENV_KEYRING_PROVIDER = None
    project.settings = {"cool-down-period": "30d"}

    # Stub parse_args to return a bare namespace so we don't need a real pip command.
    fake_options = SimpleNamespace(pre=False, force_reinstall=False)
    pip_cmd = mock.MagicMock()
    pip_cmd.parser.parse_args.return_value = (fake_options, [])

    resolver = Resolver.__new__(Resolver)
    resolver.project = project
    resolver.pre = False

    monkeypatch.setattr(Resolver, "pip_command", property(lambda self: pip_cmd))
    monkeypatch.setattr(Resolver, "pip_args", property(lambda self: []))
    monkeypatch.setattr(
        "pipenv.utils.resolver.check_release_control_exclusive", lambda opts: None
    )

    before = datetime.datetime.now(datetime.timezone.utc)
    pip_options = resolver.pip_options
    after = datetime.datetime.now(datetime.timezone.utc)

    assert hasattr(pip_options, "uploaded_prior_to"), "uploaded_prior_to not set on pip_options"
    cutoff = pip_options.uploaded_prior_to
    assert before - datetime.timedelta(days=30, seconds=1) < cutoff < after - datetime.timedelta(days=30) + datetime.timedelta(seconds=1)


# ---------------------------------------------------------------------------
# PIPENV_RESOLVER_TIMEOUT_S subprocess hang protection
# ---------------------------------------------------------------------------


@pytest.mark.utils
def test_resolve_raises_on_subprocess_timeout(monkeypatch):
    """When the resolver subprocess exceeds PIPENV_RESOLVER_TIMEOUT_S, resolve()
    must kill the child and raise ResolutionFailure with a message that names
    the env var so the user knows how to extend the timeout.
    """
    import subprocess as _subprocess

    from pipenv.exceptions import ResolutionFailure
    from pipenv.utils import resolver as resolver_mod

    # Fake Popen that never finishes: wait() always raises TimeoutExpired,
    # poll() reports the process as still running, and kill() is recorded.
    class _HangingPopen:
        def __init__(self):
            self.args = ["python", "-m", "pipenv.resolver"]
            self.killed = False
            self.wait_calls = 0
            # Empty pipes -- reader threads see EOF and exit immediately so the
            # test doesn't deadlock on join().
            self.stdout = _ClosedStream()
            self.stderr = _ClosedStream()

        def wait(self, timeout=None):
            self.wait_calls += 1
            if not self.killed:
                # First call (the real wait) hangs past the configured timeout.
                raise _subprocess.TimeoutExpired(cmd=self.args, timeout=timeout)
            # Post-kill drain wait succeeds.
            return -9

        def kill(self):
            self.killed = True

        def poll(self):
            return None if not self.killed else -9

    class _ClosedStream:
        def read(self, _n):
            return ""

        def readline(self):
            return ""

    popen = _HangingPopen()
    monkeypatch.setattr(resolver_mod, "subprocess_run", lambda *a, **kw: popen)

    # Minimal state + project doubles.
    st = SimpleNamespace(console=mock.MagicMock())
    project = mock.MagicMock()
    project.s.is_verbose.return_value = False
    project.s.PIPENV_RESOLVER_TIMEOUT_S = 1  # value is opaque -- subprocess hangs anyway

    with pytest.raises(ResolutionFailure) as exc_info:
        resolver_mod.resolve(["python", "-m", "pipenv.resolver"], st, project)

    assert popen.killed, "Resolver should kill the hung subprocess on timeout"
    # User-facing message must name the env var so users can extend the timeout.
    assert "PIPENV_RESOLVER_TIMEOUT_S" in str(exc_info.value)
    assert "timed out" in str(exc_info.value).lower()


@pytest.mark.utils
def test_resolver_timeout_setting_defaults_when_env_invalid(monkeypatch):
    """PIPENV_RESOLVER_TIMEOUT_S falls back to the default when unset or
    when the user provides a value int() can't parse / a non-positive value.
    """
    from pipenv.environments import Setting

    # Strip any inherited setting before each construction.
    for var in ("PIPENV_RESOLVER_TIMEOUT_S",):
        monkeypatch.delenv(var, raising=False)

    # Unset -> default
    s = Setting()
    assert s.PIPENV_RESOLVER_TIMEOUT_S == 1800

    # Garbage value -> default
    monkeypatch.setenv("PIPENV_RESOLVER_TIMEOUT_S", "not-an-int")
    s = Setting()
    assert s.PIPENV_RESOLVER_TIMEOUT_S == 1800

    # Non-positive -> default
    monkeypatch.setenv("PIPENV_RESOLVER_TIMEOUT_S", "0")
    s = Setting()
    assert s.PIPENV_RESOLVER_TIMEOUT_S == 1800

    # Valid override is honoured.
    monkeypatch.setenv("PIPENV_RESOLVER_TIMEOUT_S", "60")
    s = Setting()
    assert s.PIPENV_RESOLVER_TIMEOUT_S == 60
