"""Unit tests for pipenv.routines.update helpers."""
from unittest.mock import MagicMock, patch

from pipenv.routines.context import RoutineContext
from pipenv.routines.update import (
    _clean_unused_dependencies,
    _process_package_args,
    do_update,
)


def _make_project(verbose=False):
    """Return a minimal project mock."""
    project = MagicMock()
    project.s.is_verbose.return_value = verbose
    return project


# ---------------------------------------------------------------------------
# _clean_unused_dependencies
# ---------------------------------------------------------------------------


def test_clean_unused_deps_removes_unused_package():
    """Packages absent from full_lock_resolution are removed from the lockfile."""
    project = _make_project()
    lockfile = {
        "default": {
            "django": {"version": "==4.2.7"},
            "sqlparse": {"version": "==0.4.4"},
            "pytz": {"version": "==2023.3"},  # no longer needed by django 4.2.7
        }
    }
    original_lockfile = {
        "default": {
            "django": {"version": "==3.2.10"},
            "sqlparse": {"version": "==0.4.4"},
            "pytz": {"version": "==2023.3"},
        }
    }
    full_lock_resolution = {
        "django": {"version": "==4.2.7"},
        "sqlparse": {"version": "==0.4.4"},
        # pytz is NOT here – it's no longer a transitive dep
    }

    _clean_unused_dependencies(
        project, lockfile, "default", full_lock_resolution, original_lockfile
    )

    assert "pytz" not in lockfile["default"]
    assert "django" in lockfile["default"]
    assert "sqlparse" in lockfile["default"]


def test_clean_unused_deps_keeps_packages_still_needed():
    """Packages present in full_lock_resolution are NOT removed."""
    project = _make_project()
    lockfile = {
        "default": {
            "requests": {"version": "==2.31.0"},
            "urllib3": {"version": "==2.0.0"},
        }
    }
    original_lockfile = {
        "default": {
            "requests": {"version": "==2.28.0"},
            "urllib3": {"version": "==1.26.0"},
        }
    }
    full_lock_resolution = {
        "requests": {"version": "==2.31.0"},
        "urllib3": {"version": "==2.0.0"},
    }

    _clean_unused_dependencies(
        project, lockfile, "default", full_lock_resolution, original_lockfile
    )

    assert "requests" in lockfile["default"]
    assert "urllib3" in lockfile["default"]


def test_clean_unused_deps_noop_when_category_missing_from_lockfile():
    """Returns immediately when category is not present in current lockfile."""
    project = _make_project()
    lockfile = {}  # no "default" key
    original_lockfile = {"default": {"pytz": {"version": "==2023.3"}}}
    full_lock_resolution = {}

    # Should not raise; lockfile is unchanged
    _clean_unused_dependencies(
        project, lockfile, "default", full_lock_resolution, original_lockfile
    )

    assert lockfile == {}


def test_clean_unused_deps_noop_when_category_missing_from_original():
    """Returns immediately when category is not present in original_lockfile."""
    project = _make_project()
    lockfile = {"default": {"django": {"version": "==4.2.7"}}}
    original_lockfile = {}  # no "default" key
    full_lock_resolution = {"django": {"version": "==4.2.7"}}

    _clean_unused_dependencies(
        project, lockfile, "default", full_lock_resolution, original_lockfile
    )

    # lockfile is unchanged
    assert "django" in lockfile["default"]


def test_clean_unused_deps_noop_when_full_resolution_is_empty():
    """Returns immediately when full_lock_resolution is empty (resolution failure guard).

    Without this guard an empty resolution would cause all packages to be
    incorrectly treated as unused and deleted.
    """
    project = _make_project()
    lockfile = {
        "default": {
            "django": {"version": "==4.2.7"},
            "sqlparse": {"version": "==0.4.4"},
        }
    }
    original_lockfile = {
        "default": {
            "django": {"version": "==3.2.10"},
            "sqlparse": {"version": "==0.4.4"},
        }
    }
    full_lock_resolution = {}  # empty – simulates a failed resolution

    _clean_unused_dependencies(
        project, lockfile, "default", full_lock_resolution, original_lockfile
    )

    # Nothing should have been removed
    assert "django" in lockfile["default"]
    assert "sqlparse" in lockfile["default"]



# ---------------------------------------------------------------------------
# _clean_unused_dependencies – reverse_deps integration (issue #6575)
# ---------------------------------------------------------------------------


def test_clean_unused_deps_preserves_dep_of_pinned_package():
    """Regression test for #6575.

    When a requiring package (e.g. google-auth) was NOT upgraded its version
    in the lockfile is identical to the original.  full_lock_resolution was
    computed against the *latest* versions so it may resolve google-auth to a
    newer release that no longer needs cachetools/rsa.  Those transitive deps
    must be kept because the lockfile still pins the old google-auth that
    needs them.
    """
    project = _make_project()
    # google-auth stays at 2.43.0 – it was not part of this upgrade
    lockfile = {
        "default": {
            "google-auth": {"version": "==2.43.0"},
            "cachetools": {"version": "==6.2.2"},
            "rsa": {"version": "==4.9.1"},
            # requests was the target of the upgrade and is already updated
            "requests": {"version": "==2.31.0"},
        }
    }
    original_lockfile = {
        "default": {
            "google-auth": {"version": "==2.43.0"},
            "cachetools": {"version": "==6.2.2"},
            "rsa": {"version": "==4.9.1"},
            "requests": {"version": "==2.28.0"},
        }
    }
    # full_lock_resolution resolved google-auth to 2.49.1 which no longer
    # needs cachetools or rsa – so they are absent from this dict.
    full_lock_resolution = {
        "google-auth": {"version": "==2.49.1"},
        "requests": {"version": "==2.31.0"},
    }
    # pipdeptree reverse map: cachetools and rsa are required by google-auth
    reverse_deps = {
        "cachetools": {("google-auth", ">=2.0.0,<3.0.0")},
        "rsa": {("google-auth", ">=4.0.0")},
    }

    _clean_unused_dependencies(
        project,
        lockfile,
        "default",
        full_lock_resolution,
        original_lockfile,
        reverse_deps,
    )

    # google-auth is still at 2.43.0 in the lockfile (unchanged), so its
    # transitive deps must be preserved.
    assert "cachetools" in lockfile["default"], "cachetools should not be removed"
    assert "rsa" in lockfile["default"], "rsa should not be removed"
    assert "requests" in lockfile["default"]
    assert "google-auth" in lockfile["default"]


def test_clean_unused_deps_removes_dep_when_requiring_package_was_upgraded():
    """Transitive dep IS removable once its requiring package was upgraded.

    When google-auth is updated from 2.43.0 → 2.49.1 (version changed in the
    lockfile), the old transitive deps cachetools/rsa are genuinely unused and
    should be cleaned up.
    """
    project = _make_project()
    # google-auth was upgraded to 2.49.1 in this upgrade cycle
    lockfile = {
        "default": {
            "google-auth": {"version": "==2.49.1"},
            "cachetools": {"version": "==6.2.2"},
            "rsa": {"version": "==4.9.1"},
        }
    }
    original_lockfile = {
        "default": {
            "google-auth": {"version": "==2.43.0"},
            "cachetools": {"version": "==6.2.2"},
            "rsa": {"version": "==4.9.1"},
        }
    }
    # latest resolution also has google-auth 2.49.1 without cachetools/rsa
    full_lock_resolution = {
        "google-auth": {"version": "==2.49.1"},
    }
    reverse_deps = {
        "cachetools": {("google-auth", ">=2.0.0,<3.0.0")},
        "rsa": {("google-auth", ">=4.0.0")},
    }

    _clean_unused_dependencies(
        project,
        lockfile,
        "default",
        full_lock_resolution,
        original_lockfile,
        reverse_deps,
    )

    # google-auth version changed → its old deps should be removed
    assert "cachetools" not in lockfile["default"], "cachetools should be removed"
    assert "rsa" not in lockfile["default"], "rsa should be removed"
    assert "google-auth" in lockfile["default"]


def test_clean_unused_deps_without_reverse_deps_behaves_as_before():
    """Omitting reverse_deps (None) falls back to the original behaviour."""
    project = _make_project()
    lockfile = {
        "default": {
            "google-auth": {"version": "==2.43.0"},
            "cachetools": {"version": "==6.2.2"},
        }
    }
    original_lockfile = {
        "default": {
            "google-auth": {"version": "==2.43.0"},
            "cachetools": {"version": "==6.2.2"},
        }
    }
    full_lock_resolution = {
        "google-auth": {"version": "==2.49.1"},
        # cachetools absent → was removed in latest resolution
    }

    # No reverse_deps → original logic, cachetools gets removed
    _clean_unused_dependencies(
        project,
        lockfile,
        "default",
        full_lock_resolution,
        original_lockfile,
        # reverse_deps omitted (defaults to None)
    )

    assert "cachetools" not in lockfile["default"]
    assert "google-auth" in lockfile["default"]


def test_clean_unused_deps_verbose_prints_removed_package(capsys):
    """In verbose mode a message is printed for each removed package."""
    project = _make_project(verbose=True)
    lockfile = {
        "default": {
            "pytz": {"version": "==2023.3"},
        }
    }
    original_lockfile = {
        "default": {
            "pytz": {"version": "==2023.3"},
        }
    }
    # Use a non-empty resolution to actually trigger a removal
    full_lock_resolution_with_removal = {"other-pkg": {"version": "==1.0"}}
    _clean_unused_dependencies(
        project,
        lockfile,
        "default",
        full_lock_resolution_with_removal,
        original_lockfile,
    )

    assert "pytz" not in lockfile["default"]
    # project.s.is_verbose() was called and err.print was invoked through the mock
    project.s.is_verbose.assert_called()


# ---------------------------------------------------------------------------
# _process_package_args – cross-category contamination guard (issue #5914)
# ---------------------------------------------------------------------------


def _make_project_with_pipfile(packages_section=None, dev_packages_section=None):
    """Return a project mock pre-configured with fake Pipfile sections."""
    project = MagicMock()
    project.s.is_verbose.return_value = False

    # Map from (package_name, category) -> entry (or None if not present)
    pipfile = {}
    if packages_section:
        for name, entry in packages_section.items():
            pipfile[(name, "packages")] = entry
    if dev_packages_section:
        for name, entry in dev_packages_section.items():
            pipfile[(name, "dev-packages")] = entry

    def fake_get_pipfile_entry(pkg_name, category):
        return pipfile.get((pkg_name, category))

    def fake_get_package_categories():
        return ["packages", "dev-packages"]

    project.pipfile.get_entry.side_effect = fake_get_pipfile_entry
    project.pipfile.get_package_categories.return_value = ["packages", "dev-packages"]
    return project


def test_process_package_args_does_not_cross_contaminate_categories():
    """Running `upgrade mypy==1.5.1` (no --dev) must not add mypy to [packages]
    when mypy already lives in [dev-packages]. (issue #5914)
    """
    # mypy lives only in [dev-packages]
    project = _make_project_with_pipfile(
        packages_section={},
        dev_packages_section={"mypy": "==1.4.1"},
    )
    project.pipfile.generate_entry.return_value = ("mypy", "mypy", "==1.5.1")

    requested_packages = {"packages": {}}
    explicitly_requested = {"mypy": ["default"]}  # user said no --dev

    with patch("pipenv.routines.update.expansive_install_req_from_line") as mock_irl:
        mock_install_req = MagicMock()
        mock_irl.return_value = (mock_install_req, "mypy==1.5.1")

        _process_package_args(
            project,
            RoutineContext.from_cli(lock_only=False),
            package_args=["mypy==1.5.1"],
            pipfile_category="packages",  # default – no --dev
            index_name=None,
            reverse_deps={},
            explicitly_requested=explicitly_requested,
            category="default",
            has_package_args=True,
            requested_packages=requested_packages,
        )

    # The lockfile resolution dict should be populated…
    assert "mypy" in requested_packages["packages"]
    # …but the Pipfile should NOT have been written to [packages]
    project.pipfile.add_entry.assert_not_called()


def test_process_package_args_writes_to_pipfile_when_package_in_correct_category():
    """When upgrading a package that already lives in [packages], the Pipfile
    entry IS updated. (normal upgrade path)
    """
    project = _make_project_with_pipfile(
        packages_section={"requests": "==2.28.0"},
        dev_packages_section={},
    )
    project.pipfile.generate_entry.return_value = (
        "requests",
        "requests",
        "==2.31.0",
    )

    requested_packages = {"packages": {}}
    explicitly_requested = {"requests": ["default"]}

    with patch("pipenv.routines.update.expansive_install_req_from_line") as mock_irl:
        mock_install_req = MagicMock()
        mock_irl.return_value = (mock_install_req, "requests==2.31.0")

        _process_package_args(
            project,
            RoutineContext.from_cli(lock_only=False),
            package_args=["requests==2.31.0"],
            pipfile_category="packages",
            index_name=None,
            reverse_deps={},
            explicitly_requested=explicitly_requested,
            category="default",
            has_package_args=True,
            requested_packages=requested_packages,
        )

    assert "requests" in requested_packages["packages"]
    project.pipfile.add_entry.assert_called_once_with(
        "requests", "requests", "==2.31.0", category="packages"
    )


# ---------------------------------------------------------------------------
# do_update routing – `pipenv update` (no args) must do lock + sync (issue #6672)
# ---------------------------------------------------------------------------


def _do_update_project_mock():
    """Project mock for exercising do_update routing decisions."""
    project = MagicMock()
    project.s.PIPENV_USE_SYSTEM = False
    project.any_lockfile_exists = True
    return project


def test_do_update_no_args_goes_through_do_lock():
    """Regression for gh-6672.

    `pipenv update` with no package arguments must re-resolve every package
    against the Pipfile (documented behavior: ``lock + sync``). The partial
    ``upgrade()`` path only re-resolves Pipfile entries whose locked version
    no longer satisfies the spec, which silently skips picking up newer
    allowed releases (e.g. relaxing ``urllib3 = "<2.7.0"`` to
    ``urllib3 = "*"`` would not bump the locked 2.6.3).
    """
    project = _do_update_project_mock()
    with patch("pipenv.routines.update.do_lock") as mock_lock, patch(
        "pipenv.routines.update.upgrade"
    ) as mock_upgrade, patch("pipenv.routines.update.do_sync") as mock_sync, patch(
        "pipenv.routines.update.ensure_project"
    ):
        do_update(project, RoutineContext.from_cli())

    mock_lock.assert_called_once()
    mock_upgrade.assert_not_called()
    # Lock runs once, then a single sync to install what was locked.
    assert mock_sync.call_count == 1


def test_do_update_with_packages_uses_upgrade_path():
    """`pipenv update <pkg>` keeps the targeted ``upgrade()`` flow."""
    project = _do_update_project_mock()
    with patch("pipenv.routines.update.do_lock") as mock_lock, patch(
        "pipenv.routines.update.upgrade"
    ) as mock_upgrade, patch("pipenv.routines.update.do_sync") as mock_sync, patch(
        "pipenv.routines.update.ensure_project"
    ):
        do_update(project, RoutineContext.from_cli(packages=["requests"]))

    mock_lock.assert_not_called()
    mock_upgrade.assert_called_once()
    # Pre-sync (lockfile already exists) + post-sync.
    assert mock_sync.call_count == 2


def test_do_update_no_args_with_dev_locks_default_and_develop():
    """`pipenv update --dev` (no args) must lock both default and develop."""
    project = _do_update_project_mock()
    with patch("pipenv.routines.update.do_lock") as mock_lock, patch(
        "pipenv.routines.update.upgrade"
    ), patch("pipenv.routines.update.do_sync"), patch(
        "pipenv.routines.update.ensure_project"
    ):
        do_update(project, RoutineContext.from_cli(dev=True))

    (_, lock_ctx), _ = mock_lock.call_args
    assert list(lock_ctx.package_selection.categories) == ["default", "develop"]


def test_do_update_no_args_default_locks_default_only():
    """`pipenv update` (no args, no --dev) locks the default category only."""
    project = _do_update_project_mock()
    with patch("pipenv.routines.update.do_lock") as mock_lock, patch(
        "pipenv.routines.update.upgrade"
    ), patch("pipenv.routines.update.do_sync"), patch(
        "pipenv.routines.update.ensure_project"
    ):
        do_update(project, RoutineContext.from_cli())

    (_, lock_ctx), _ = mock_lock.call_args
    assert list(lock_ctx.package_selection.categories) == ["default"]


def test_do_update_outdated_skips_both_lock_and_upgrade():
    """`pipenv update --outdated` routes to do_outdated, not lock/upgrade."""
    project = _do_update_project_mock()
    with patch("pipenv.routines.update.do_lock") as mock_lock, patch(
        "pipenv.routines.update.upgrade"
    ) as mock_upgrade, patch("pipenv.routines.update.do_outdated") as mock_outdated, patch(
        "pipenv.routines.update.do_sync"
    ), patch("pipenv.routines.update.ensure_project"):
        do_update(project, RoutineContext.from_cli(dry_run=True))

    mock_lock.assert_not_called()
    mock_upgrade.assert_not_called()
    mock_outdated.assert_called_once()
