#!/usr/bin/python3
"""Tests for slpkg/runner.py — the Run class."""

import errno
import sys
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch

import pytest

from slpkg.runner import Run


# ── Fixtures ──────────────────────────────────────────────────────────────────


@pytest.fixture
def mock_config():
    """Patch slpkg.runner.config_load with safe defaults."""
    with patch("slpkg.runner.config_load") as mock:
        mock.prog_name = "slpkg"
        mock.tmp_slpkg = Path("/tmp/slpkg")
        mock.file_list_suffix = ".pkgs"
        mock.bootloader_command = ""
        mock.lock_file = Path("/tmp/slpkg_test.lock")
        mock.red = ""
        mock.green = ""
        mock.yellow = ""
        mock.endc = ""
        mock.bold = ""
        mock.ask_question = True
        mock.answer_yes = False
        yield mock


_RUNNER_DEPS = (
    "slpkg.runner.Repositories",
    "slpkg.runner.Utilities",
    "slpkg.runner.LoadData",
    "slpkg.runner.PackageValidator",
    "slpkg.runner.Choose",
    "slpkg.runner.View",
    "slpkg.runner.MultiProcess",
)


def _make_run(mock_config, options: dict, repository: str = "slack") -> Run:
    """Helper: build a Run instance with all heavy deps mocked.

    Uses try/finally so patches are always stopped even when Run.__init__
    raises SystemExit (e.g. lock-acquisition failure in AcquireLock tests).
    """
    patches = [patch(dep, autospec=True) for dep in _RUNNER_DEPS]
    for p in patches:
        p.start()
    try:
        return Run(options, repository)
    finally:
        for p in patches:
            p.stop()


@pytest.fixture
def run_inst(mock_config):
    """Run instance — lock disabled, all heavy deps mocked."""
    return _make_run(mock_config, {"option_quiet": True, "option_no_lock": True})


# ── __init__ ──────────────────────────────────────────────────────────────────


class TestInit:
    def test_uses_specified_repository(self, mock_config):
        inst = _make_run(mock_config, {"option_no_lock": True}, "slack")
        assert inst.repository == "slack"

    def test_falls_back_to_default_repository_when_empty(self, mock_config):
        with (
            patch("slpkg.runner.Repositories", autospec=True) as mock_repos_cls,
            patch("slpkg.runner.Utilities", autospec=True),
            patch("slpkg.runner.LoadData", autospec=True),
            patch("slpkg.runner.PackageValidator", autospec=True),
            patch("slpkg.runner.Choose", autospec=True),
            patch("slpkg.runner.View", autospec=True),
            patch("slpkg.runner.MultiProcess", autospec=True),
        ):
            mock_repos_cls.return_value.default_repository = "slack"
            inst = Run({"option_no_lock": True}, "")
        assert inst.repository == "slack"


# ── _acquire_lock ─────────────────────────────────────────────────────────────


class TestAcquireLock:
    def _make(self, mock_config, options: dict) -> Run:
        return _make_run(mock_config, options)

    def test_no_lock_skips_open(self, mock_config):
        with patch("builtins.open") as mock_open_fn:
            self._make(mock_config, {"option_no_lock": True})
        mock_open_fn.assert_not_called()

    def test_lock_calls_flock(self, mock_config):
        with patch("builtins.open", mock_open()), patch("fcntl.flock") as mock_flock:
            self._make(mock_config, {"option_no_lock": False})
        mock_flock.assert_called_once()

    def test_eagain_prints_already_running_and_exits(self, mock_config, capsys):
        err = IOError()
        err.errno = errno.EAGAIN
        with patch("builtins.open", side_effect=err), pytest.raises(SystemExit) as exc:
            self._make(mock_config, {"option_no_lock": False})
        assert exc.value.code == 1
        assert "already running" in capsys.readouterr().out

    def test_other_oserror_prints_system_error_and_exits(self, mock_config, capsys):
        err = OSError()
        err.errno = errno.ENOENT
        with patch("builtins.open", side_effect=err), pytest.raises(SystemExit) as exc:
            self._make(mock_config, {"option_no_lock": False})
        assert exc.value.code == 1
        assert "System error" in capsys.readouterr().out


# ── is_file_list_packages ─────────────────────────────────────────────────────


class TestIsFileListPackages:
    def test_detects_pkgs_file_and_reads_it(self, run_inst):
        run_inst.file_list_suffix = ".pkgs"
        run_inst.utils.read_packages_from_file.return_value = ["pkg1", "pkg2"]
        assert run_inst.is_file_list_packages(["list.pkgs"]) == ["pkg1", "pkg2"]
        run_inst.utils.read_packages_from_file.assert_called_once()

    def test_normal_packages_passed_through_unchanged(self, run_inst):
        run_inst.file_list_suffix = ".pkgs"
        assert run_inst.is_file_list_packages(["pkg1", "pkg2"]) == ["pkg1", "pkg2"]
        run_inst.utils.read_packages_from_file.assert_not_called()

    def test_empty_list_returns_empty_list(self, run_inst):
        assert run_inst.is_file_list_packages([]) == []


# ── update ────────────────────────────────────────────────────────────────────


class TestUpdate:
    def test_full_update_calls_update_repositories(self, run_inst):
        run_inst.options["option_check"] = False
        with patch("slpkg.runner.UpdateRepositories", autospec=True) as mock_upd, pytest.raises(SystemExit) as exc:
            run_inst.update()
        assert exc.value.code == 0
        mock_upd.return_value.repositories.assert_called_once()

    def test_check_only_calls_check_updates(self, run_inst):
        run_inst.options["option_check"] = True
        with patch("slpkg.runner.CheckUpdates", autospec=True) as mock_chk, pytest.raises(SystemExit) as exc:
            run_inst.update()
        assert exc.value.code == 0
        mock_chk.return_value.updates.assert_called_once()


# ── upgrade ───────────────────────────────────────────────────────────────────


class TestUpgrade:
    def _setup_repo(self, run_inst, sbo="sbo", ponce="ponce"):
        run_inst.repos.sbo_repo_name = sbo
        run_inst.repos.ponce_repo_name = ponce
        run_inst.load_data.load.return_value = {}

    def test_check_mode_specific_repo_calls_check_packages(self, run_inst):
        self._setup_repo(run_inst)
        run_inst.options["option_check"] = True
        with patch("slpkg.runner.Upgrade", autospec=True) as mock_upg, pytest.raises(SystemExit) as exc:
            run_inst.upgrade()
        assert exc.value.code == 0
        mock_upg.return_value.check_packages.assert_called_once()

    def test_check_mode_wildcard_uses_all_repos(self, run_inst):
        run_inst.repository = "*"
        run_inst.options["option_check"] = True
        with (
            patch("slpkg.runner.AllRepos", autospec=True) as mock_all,
            patch("slpkg.runner.Upgrade", autospec=True) as mock_upg,
            pytest.raises(SystemExit) as exc,
        ):
            mock_all.return_value.priority_repos.return_value = ["sbo"]
            run_inst.load_data.load.return_value = {}
            run_inst.upgrade()
        assert exc.value.code == 0
        mock_upg.return_value.check_packages.assert_called_once()

    def test_upgrade_binary_repo(self, run_inst):
        self._setup_repo(run_inst)
        run_inst.options["option_check"] = False
        run_inst.choose.packages.return_value = ["pkg1"]
        with (
            patch("slpkg.runner.Upgrade", autospec=True) as mock_upg,
            patch("slpkg.runner.Packages", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
            pytest.raises(SystemExit) as exc,
        ):
            mock_upg.return_value.packages.return_value = ["pkg1"]
            run_inst.upgrade()
        assert exc.value.code == 0

    def test_upgrade_sbo_repo(self, run_inst):
        self._setup_repo(run_inst)
        run_inst.repository = "sbo"
        run_inst.options["option_check"] = False
        run_inst.choose.packages.return_value = ["pkg1"]
        with (
            patch("slpkg.runner.Upgrade", autospec=True) as mock_upg,
            patch("slpkg.runner.Slackbuilds", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
            pytest.raises(SystemExit) as exc,
        ):
            mock_upg.return_value.packages.return_value = ["pkg1"]
            run_inst.upgrade()
        assert exc.value.code == 0

    def test_upgrade_nothing_to_upgrade_exits_cleanly(self, run_inst, capsys):
        self._setup_repo(run_inst)
        run_inst.options["option_check"] = False
        run_inst.choose.packages.return_value = []
        with patch("slpkg.runner.Upgrade", autospec=True) as mock_upg, pytest.raises(SystemExit) as exc:
            mock_upg.return_value.packages.return_value = []
            run_inst.upgrade()
        assert exc.value.code == 0
        assert "up-to-date" in capsys.readouterr().out

    def test_upgrade_with_removed_packages(self, run_inst):
        self._setup_repo(run_inst)
        run_inst.options["option_check"] = False
        run_inst.choose.packages.return_value = ["pkg1"]
        with (
            patch("slpkg.runner.Upgrade", autospec=True) as mock_upg,
            patch("slpkg.runner.RemovePackages", autospec=True) as mock_rm,
            patch("slpkg.runner.Packages", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
            pytest.raises(SystemExit) as exc,
        ):
            mock_upg.return_value.packages.return_value = ["old_Removed.", "pkg1"]
            run_inst.upgrade()
        assert exc.value.code == 0
        mock_rm.return_value.remove.assert_called_once()

    def test_upgrade_with_added_packages(self, run_inst):
        self._setup_repo(run_inst)
        run_inst.options["option_check"] = False
        run_inst.choose.packages.return_value = ["new-pkg", "pkg1"]
        with (
            patch("slpkg.runner.Upgrade", autospec=True) as mock_upg,
            patch("slpkg.runner.Packages", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
            pytest.raises(SystemExit) as exc,
        ):
            mock_upg.return_value.packages.return_value = ["new-pkg_Added.", "pkg1"]
            run_inst.upgrade()
        assert exc.value.code == 0

    def test_upgrade_wildcard_calls_all_repos(self, run_inst):
        run_inst.repository = "*"
        run_inst.options["option_check"] = False
        with (
            patch("slpkg.runner.AllRepos", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
            pytest.raises(SystemExit) as exc,
        ):
            run_inst.upgrade()
        assert exc.value.code == 0


# ── _kernel_image_message ─────────────────────────────────────────────────────


class TestKernelImageMessage:
    def test_prints_warning(self, run_inst, capsys):
        run_inst._kernel_image_message()
        assert "Warning" in capsys.readouterr().out


# ── _reboot_prompt ────────────────────────────────────────────────────────────


class TestRebootPrompt:
    def test_user_answers_yes_triggers_reboot(self, run_inst, mock_config):
        mock_config.ask_question = True
        mock_config.answer_yes = False
        run_inst.multi_process = MagicMock()
        with patch("builtins.input", return_value="y"):
            run_inst._reboot_prompt()
        run_inst.multi_process.process.assert_called_once_with("reboot")

    def test_user_answers_no_skips_reboot(self, run_inst, mock_config):
        mock_config.ask_question = True
        mock_config.answer_yes = False
        run_inst.multi_process = MagicMock()
        with patch("builtins.input", return_value="n"):
            run_inst._reboot_prompt()
        run_inst.multi_process.process.assert_not_called()

    def test_answer_yes_option_reboots_without_input(self, run_inst, mock_config):
        mock_config.ask_question = True
        mock_config.answer_yes = True
        run_inst.multi_process = MagicMock()
        run_inst._reboot_prompt()
        run_inst.multi_process.process.assert_called_once_with("reboot")

    def test_ask_question_false_skips_reboot_entirely(self, run_inst, mock_config):
        mock_config.ask_question = False
        run_inst.multi_process = MagicMock()
        run_inst._reboot_prompt()
        run_inst.multi_process.process.assert_not_called()

    def test_keyboard_interrupt_during_input_skips_reboot(self, run_inst, mock_config):
        mock_config.ask_question = True
        mock_config.answer_yes = False
        run_inst.multi_process = MagicMock()
        with patch("builtins.input", side_effect=KeyboardInterrupt):
            run_inst._reboot_prompt()
        run_inst.multi_process.process.assert_not_called()


# ── _bootloader_update ────────────────────────────────────────────────────────


class TestBootloaderUpdate:
    def test_calls_question_and_process(self, run_inst):
        run_inst.bootloader_command = "lilo"
        run_inst.multi_process = MagicMock()
        run_inst.views = MagicMock()
        run_inst._bootloader_update()
        run_inst.views.question.assert_called_once()
        run_inst.multi_process.process.assert_called_once_with("lilo")


# ── _is_kernel_upgrade ────────────────────────────────────────────────────────


class TestIsKernelUpgrade:
    def test_kernel_changed_calls_reboot_prompt(self, run_inst):
        run_inst.utils.is_package_installed.return_value = "kernel-generic-6.6.72-x86_64-1"
        run_inst.bootloader_command = ""
        with patch.object(run_inst, "_kernel_image_message"), patch.object(run_inst, "_reboot_prompt") as mock_reboot:
            run_inst._is_kernel_upgrade("kernel-generic-6.6.63-x86_64-1")
        mock_reboot.assert_called_once()

    def test_kernel_unchanged_no_reboot_prompt(self, run_inst):
        run_inst.utils.is_package_installed.return_value = "kernel-generic-6.6.63-x86_64-1"
        with patch.object(run_inst, "_reboot_prompt") as mock_reboot:
            run_inst._is_kernel_upgrade("kernel-generic-6.6.63-x86_64-1")
        mock_reboot.assert_not_called()

    def test_kernel_changed_with_bootloader_calls_bootloader_update(self, run_inst):
        run_inst.utils.is_package_installed.return_value = "kernel-generic-6.6.72-x86_64-1"
        run_inst.bootloader_command = "lilo"
        with (
            patch.object(run_inst, "_bootloader_update") as mock_boot,
            patch.object(run_inst, "_reboot_prompt"),
        ):
            run_inst._is_kernel_upgrade("kernel-generic-6.6.63-x86_64-1")
        mock_boot.assert_called_once()


# ── repo_info ─────────────────────────────────────────────────────────────────


class TestRepoInfo:
    def test_calls_repo_info_and_exits(self, run_inst):
        with patch("slpkg.runner.RepoInfo", autospec=True) as mock_ri, pytest.raises(SystemExit) as exc:
            run_inst.repo_info()
        assert exc.value.code == 0
        mock_ri.return_value.info.assert_called_once()


# ── repo ──────────────────────────────────────────────────────────────────────


class TestRepo:
    def _run_repo(self, run_inst, options_override: dict):
        run_inst.options.update(options_override)
        with patch("slpkg.runner.RepoManager", autospec=True) as mock_mgr, pytest.raises(SystemExit) as exc:
            run_inst.repo()
        return exc, mock_mgr.return_value

    def test_enable(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_enable": ["slack"]})
        assert exc.value.code == 0
        mgr.enable.assert_called_once_with(["slack"])

    def test_disable(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_disable": ["slack"]})
        assert exc.value.code == 0
        mgr.disable.assert_called_once_with(["slack"])

    def test_set_mirror(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_mirror": ["slack", "https://mirror.example.com"]})
        assert exc.value.code == 0
        mgr.set_mirror.assert_called_once_with("slack", "https://mirror.example.com")

    def test_set_default(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_default": "slack"})
        assert exc.value.code == 0
        mgr.set_default.assert_called_once_with("slack")

    def test_clear_priority(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_clear_priority": True})
        assert exc.value.code == 0
        mgr.clear_priority.assert_called_once()

    def test_set_priority(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_priority": ["sbo", "alien"]})
        assert exc.value.code == 0
        mgr.set_priority.assert_called_once_with(["sbo", "alien"])

    def test_show_priority_when_empty_list(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {"option_repo_priority": []})
        assert exc.value.code == 0
        mgr.show_priority.assert_called_once()

    def test_list_repos_when_no_option_set(self, run_inst):
        exc, mgr = self._run_repo(run_inst, {})
        assert exc.value.code == 0
        mgr.list_repos.assert_called_once()


# ── stats ─────────────────────────────────────────────────────────────────────


class TestStats:
    def test_calls_stats_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Stats", autospec=True) as mock_st, pytest.raises(SystemExit) as exc:
            run_inst.stats()
        assert exc.value.code == 0
        mock_st.return_value.run.assert_called_once()


# ── rollback ──────────────────────────────────────────────────────────────────


class TestRollback:
    def test_calls_rollback_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Rollback", autospec=True) as mock_rb, pytest.raises(SystemExit) as exc:
            run_inst.rollback()
        assert exc.value.code == 0
        mock_rb.return_value.run.assert_called_once()


# ── edit_configs ──────────────────────────────────────────────────────────────


class TestEditConfigs:
    def test_without_validate_runs_form_configs(self, run_inst):
        run_inst.options["option_validate"] = False
        with (
            patch("slpkg.runner.ConfigValidator", autospec=True) as mock_cv,
            patch("slpkg.runner.FormConfigs", autospec=True) as mock_fc,
            pytest.raises(SystemExit) as exc,
        ):
            run_inst.edit_configs()
        assert exc.value.code == 0
        mock_cv.return_value.run.assert_not_called()
        mock_fc.return_value.run.assert_called_once()

    def test_with_validate_runs_config_validator_first(self, run_inst):
        run_inst.options["option_validate"] = True
        with (
            patch("slpkg.runner.ConfigValidator", autospec=True) as mock_cv,
            patch("slpkg.runner.FormConfigs", autospec=True),
            pytest.raises(SystemExit) as exc,
        ):
            run_inst.edit_configs()
        assert exc.value.code == 0
        mock_cv.return_value.run.assert_called_once()


# ── self_check ────────────────────────────────────────────────────────────────


class TestSelfCheck:
    def test_calls_check_self_update_and_exits(self):
        with patch("slpkg.runner.check_self_update") as mock_csu, pytest.raises(SystemExit) as exc:
            Run.self_check()
        assert exc.value.code == 0
        mock_csu.assert_called_once()


# ── build ─────────────────────────────────────────────────────────────────────


class TestBuild:
    def _setup(self, run_inst):
        run_inst.load_data.load.return_value = {}
        run_inst.utils.case_insensitive_pattern_matching.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_exists.return_value = None

    def test_build_dispatches_to_slackbuilds(self, run_inst):
        self._setup(run_inst)
        with patch("slpkg.runner.Slackbuilds", autospec=True) as mock_sbo, pytest.raises(SystemExit) as exc:
            run_inst.build(["pkg1"])
        assert exc.value.code == 0
        mock_sbo.return_value.execute.assert_called_once()

    def test_build_with_option_select_calls_choose(self, run_inst):
        self._setup(run_inst)
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        with patch("slpkg.runner.Slackbuilds", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.build(["pkg1"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── rebuild ───────────────────────────────────────────────────────────────────


class TestRebuild:
    def test_calls_rebuild_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Rebuild", autospec=True) as mock_rb, pytest.raises(SystemExit) as exc:
            run_inst.rebuild()
        assert exc.value.code == 0
        mock_rb.return_value.run.assert_called_once()


# ── reinstall ─────────────────────────────────────────────────────────────────


class TestReinstall:
    def test_calls_reinstall_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Reinstall", autospec=True) as mock_ri, pytest.raises(SystemExit) as exc:
            run_inst.reinstall(["pkg1"])
        assert exc.value.code == 0
        mock_ri.return_value.run.assert_called_once()


# ── install ───────────────────────────────────────────────────────────────────


class TestInstall:
    def _setup(self, run_inst, repo: str):
        run_inst.repository = repo
        run_inst.repos.sbo_repo_name = "sbo"
        run_inst.repos.ponce_repo_name = "ponce"
        run_inst.load_data.load.return_value = {}
        run_inst.utils.case_insensitive_pattern_matching.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_exists.return_value = None

    def test_binary_repo_calls_packages(self, run_inst):
        self._setup(run_inst, "slack")
        with patch("slpkg.runner.Packages", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.install(["pkg1"])
        assert exc.value.code == 0

    def test_sbo_repo_calls_slackbuilds(self, run_inst):
        self._setup(run_inst, "sbo")
        with patch("slpkg.runner.Slackbuilds", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.install(["pkg1"])
        assert exc.value.code == 0

    def test_wildcard_repo_calls_all_repos_and_exits(self, run_inst):
        run_inst.repository = "*"
        with (
            patch("slpkg.runner.AllRepos", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
            pytest.raises(SystemExit) as exc,
        ):
            run_inst.install(["pkg1"])
        assert exc.value.code == 0

    def test_wildcard_repo_with_exit_after_false_returns(self, run_inst):
        """install(exit_after=False) on wildcard must return instead of sys.exit."""
        run_inst.repository = "*"
        with (
            patch("slpkg.runner.AllRepos", autospec=True),
            patch.object(run_inst, "_is_kernel_upgrade"),
        ):
            run_inst.install(["pkg1"], exit_after=False)  # must not raise SystemExit

    def test_option_select_calls_choose(self, run_inst):
        self._setup(run_inst, "slack")
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        with patch("slpkg.runner.Packages", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.install(["pkg1"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── install_pkg ───────────────────────────────────────────────────────────────


class TestInstallPkg:
    def test_calls_local_install_and_exits(self, run_inst):
        with patch("slpkg.runner.LocalInstall", autospec=True) as mock_li, pytest.raises(SystemExit) as exc:
            run_inst.install_pkg(["pkg.txz"])
        assert exc.value.code == 0
        mock_li.return_value.run.assert_called_once()


# ── remove ────────────────────────────────────────────────────────────────────


class TestRemove:
    def test_remove_calls_remove_packages(self, run_inst):
        run_inst.pkg_validator.is_package_installed.return_value = None
        with patch("slpkg.runner.RemovePackages", autospec=True) as mock_rm, pytest.raises(SystemExit) as exc:
            run_inst.remove(["pkg1"])
        assert exc.value.code == 0
        mock_rm.return_value.remove.assert_called_once()

    def test_remove_with_option_select_calls_choose(self, run_inst):
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_installed.return_value = None
        with patch("slpkg.runner.RemovePackages", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.remove(["pkg1"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── download ──────────────────────────────────────────────────────────────────


class TestDownload:
    def _setup(self, run_inst):
        run_inst.load_data.load.return_value = {}
        run_inst.utils.case_insensitive_pattern_matching.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_exists.return_value = None

    def test_download_with_explicit_directory(self, run_inst):
        self._setup(run_inst)
        with patch("slpkg.runner.DownloadOnly", autospec=True) as mock_dl, pytest.raises(SystemExit) as exc:
            run_inst.download(["pkg1"], "/tmp/downloads")
        assert exc.value.code == 0
        mock_dl.return_value.packages.assert_called_once()

    def test_download_without_directory_uses_tmp(self, run_inst):
        self._setup(run_inst)
        with patch("slpkg.runner.DownloadOnly", autospec=True) as mock_dl, pytest.raises(SystemExit) as exc:
            run_inst.download(["pkg1"], "")
        assert exc.value.code == 0
        mock_dl.return_value.packages.assert_called_once()

    def test_download_with_option_select_calls_choose(self, run_inst):
        self._setup(run_inst)
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        with patch("slpkg.runner.DownloadOnly", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.download(["pkg1"], "/tmp")
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── export ────────────────────────────────────────────────────────────────────


class TestExport:
    def test_calls_export_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Export", autospec=True) as mock_ex, pytest.raises(SystemExit) as exc:
            run_inst.export("packages.pkgs")
        assert exc.value.code == 0
        mock_ex.return_value.run.assert_called_once_with("packages.pkgs")


# ── import_packages ───────────────────────────────────────────────────────────


class TestImportPackages:
    def test_no_plan_exits_immediately(self, run_inst):
        with patch("slpkg.runner.ImportPackages", autospec=True) as mock_imp, pytest.raises(SystemExit) as exc:
            mock_imp.return_value.run.return_value = None
            run_inst.import_packages("packages.pkgs")
        assert exc.value.code == 0

    def test_with_plan_calls_install_per_repo(self, run_inst):
        with (
            patch("slpkg.runner.ImportPackages", autospec=True) as mock_imp,
            patch.object(run_inst, "install") as mock_install,
            pytest.raises(SystemExit) as exc,
        ):
            mock_imp.return_value.run.return_value = [("slack", ["pkg1"]), ("sbo", ["pkg2"])]
            run_inst.import_packages("packages.pkgs")
        assert exc.value.code == 0
        assert mock_install.call_count == 2
        mock_install.assert_any_call(["pkg1"], exit_after=False)
        mock_install.assert_any_call(["pkg2"], exit_after=False)


# ── list_installed ────────────────────────────────────────────────────────────


class TestListInstalled:
    def test_calls_list_installed_and_exits(self, run_inst):
        with patch("slpkg.runner.ListInstalled", autospec=True) as mock_ls, pytest.raises(SystemExit) as exc:
            run_inst.list_installed(["python"])
        assert exc.value.code == 0
        mock_ls.return_value.installed.assert_called_once()

    def test_with_option_select_calls_choose(self, run_inst):
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["python"]
        with patch("slpkg.runner.ListInstalled", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.list_installed(["python"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── info_package ──────────────────────────────────────────────────────────────


class TestInfoPackage:
    def _setup(self, run_inst, repo: str):
        run_inst.repository = repo
        run_inst.repos.sbo_repo_name = "sbo"
        run_inst.repos.ponce_repo_name = "ponce"
        run_inst.load_data.load.return_value = {}
        run_inst.utils.case_insensitive_pattern_matching.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_exists.return_value = None

    def test_binary_repo_calls_view_package(self, run_inst):
        self._setup(run_inst, "slack")
        with patch("slpkg.runner.InfoPackage", autospec=True) as mock_ip, pytest.raises(SystemExit) as exc:
            run_inst.info_package(["pkg1"])
        assert exc.value.code == 0
        mock_ip.return_value.package.assert_called_once()

    def test_sbo_repo_calls_view_slackbuild(self, run_inst):
        self._setup(run_inst, "sbo")
        with patch("slpkg.runner.InfoPackage", autospec=True) as mock_ip, pytest.raises(SystemExit) as exc:
            run_inst.info_package(["pkg1"])
        assert exc.value.code == 0
        mock_ip.return_value.slackbuild.assert_called_once()

    def test_with_option_select_calls_choose(self, run_inst):
        self._setup(run_inst, "slack")
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        with patch("slpkg.runner.InfoPackage", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.info_package(["pkg1"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── search ────────────────────────────────────────────────────────────────────


class TestSearch:
    def test_search_calls_search_package(self, run_inst):
        run_inst.load_data.load.return_value = {}
        with patch("slpkg.runner.SearchPackage", autospec=True) as mock_sp, pytest.raises(SystemExit) as exc:
            run_inst.search(["query"])
        assert exc.value.code == 0
        mock_sp.return_value.search.assert_called_once()

    def test_search_with_option_select_calls_choose(self, run_inst):
        run_inst.load_data.load.return_value = {}
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["query"]
        with patch("slpkg.runner.SearchPackage", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.search(["query"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── file_search ───────────────────────────────────────────────────────────────


class TestFileSearch:
    def test_calls_file_search_run_and_exits(self, run_inst):
        with patch("slpkg.runner.FileSearch", autospec=True) as mock_fs, pytest.raises(SystemExit) as exc:
            run_inst.file_search(["/usr/bin/python"])
        assert exc.value.code == 0
        mock_fs.return_value.run.assert_called_once()


# ── list_files ────────────────────────────────────────────────────────────────


class TestListFiles:
    def test_calls_list_files_run_and_exits(self, run_inst):
        with patch("slpkg.runner.ListFiles", autospec=True) as mock_lf, pytest.raises(SystemExit) as exc:
            run_inst.list_files(["python"])
        assert exc.value.code == 0
        mock_lf.return_value.run.assert_called_once()


# ── reverse ───────────────────────────────────────────────────────────────────


class TestReverse:
    def _setup(self, run_inst):
        run_inst.load_data.load.return_value = {}
        run_inst.utils.case_insensitive_pattern_matching.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_exists.return_value = None

    def test_calls_reverse_find_and_exits(self, run_inst):
        self._setup(run_inst)
        with patch("slpkg.runner.Reverse", autospec=True) as mock_rv, pytest.raises(SystemExit) as exc:
            run_inst.reverse(["pkg1"])
        assert exc.value.code == 0
        mock_rv.return_value.find.assert_called_once()

    def test_with_option_select_calls_choose(self, run_inst):
        self._setup(run_inst)
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        with patch("slpkg.runner.Reverse", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.reverse(["pkg1"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── depends ───────────────────────────────────────────────────────────────────


class TestDepends:
    def _setup(self, run_inst):
        run_inst.load_data.load.return_value = {}
        run_inst.utils.case_insensitive_pattern_matching.return_value = ["pkg1"]
        run_inst.pkg_validator.is_package_exists.return_value = None

    def test_calls_depends_package_and_exits(self, run_inst):
        self._setup(run_inst)
        with patch("slpkg.runner.Depends", autospec=True) as mock_dep, pytest.raises(SystemExit) as exc:
            run_inst.depends(["pkg1"])
        assert exc.value.code == 0
        mock_dep.return_value.package.assert_called_once()

    def test_with_option_select_calls_choose(self, run_inst):
        self._setup(run_inst)
        run_inst.options["option_select"] = True
        run_inst.choose.packages.return_value = ["pkg1"]
        with patch("slpkg.runner.Depends", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.depends(["pkg1"])
        assert exc.value.code == 0
        run_inst.choose.packages.assert_called_once()


# ── changelog_print ───────────────────────────────────────────────────────────


class TestChangelogPrint:
    def test_calls_changelog_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Changelog", autospec=True) as mock_cl, pytest.raises(SystemExit) as exc:
            run_inst.changelog_print("kernel")
        assert exc.value.code == 0
        mock_cl.return_value.run.assert_called_once()


# ── version ───────────────────────────────────────────────────────────────────


class TestVersion:
    def test_version_static_calls_view_and_exits(self):
        with patch("slpkg.runner.Version", autospec=True) as mock_ver, pytest.raises(SystemExit) as exc:
            Run.version()
        assert exc.value.code == 0
        mock_ver.return_value.view.assert_called_once()


# ── verify ────────────────────────────────────────────────────────────────────


class TestVerify:
    def test_no_broken_packages_exits_zero(self, run_inst):
        with patch("slpkg.runner.VerifyFiles", autospec=True) as mock_vf, pytest.raises(SystemExit) as exc:
            mock_vf.return_value.check.return_value = []
            run_inst.verify([])
        assert exc.value.code == 0

    def test_with_packages_validates_they_are_installed(self, run_inst):
        with patch("slpkg.runner.VerifyFiles", autospec=True) as mock_vf, pytest.raises(SystemExit) as exc:
            mock_vf.return_value.check.return_value = []
            run_inst.verify(["pkg1"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_called_once_with(["pkg1"])

    def test_broken_with_fix_option_calls_reinstall(self, run_inst):
        run_inst.options["option_fix"] = True
        with (
            patch("slpkg.runner.VerifyFiles", autospec=True) as mock_vf,
            patch("slpkg.runner.Reinstall", autospec=True) as mock_ri,
            pytest.raises(SystemExit) as exc,
        ):
            mock_vf.return_value.check.return_value = ["broken-pkg"]
            run_inst.verify([])
        assert exc.value.code == 0
        mock_ri.assert_called_once_with(["broken-pkg"], run_inst.options)
        mock_ri.return_value.run.assert_called_once()

    def test_broken_without_fix_option_skips_reinstall(self, run_inst):
        run_inst.options["option_fix"] = False
        with (
            patch("slpkg.runner.VerifyFiles", autospec=True) as mock_vf,
            patch("slpkg.runner.Reinstall", autospec=True) as mock_ri,
            pytest.raises(SystemExit) as exc,
        ):
            mock_vf.return_value.check.return_value = ["broken-pkg"]
            run_inst.verify([])
        assert exc.value.code == 0
        mock_ri.assert_not_called()


# ── show_history ──────────────────────────────────────────────────────────────


class TestShowHistory:
    def test_clear_history_option_calls_clear(self, run_inst):
        run_inst.options["option_clear_history"] = True
        with patch("slpkg.runner.History", autospec=True) as mock_hist, pytest.raises(SystemExit) as exc:
            run_inst.show_history()
        assert exc.value.code == 0
        mock_hist.return_value.clear.assert_called_once()

    def test_view_history_calls_view(self, run_inst):
        run_inst.options.update({"option_clear_history": False, "option_lines": 10, "option_history_package": "", "option_json": False})
        with patch("slpkg.runner.History", autospec=True) as mock_hist, pytest.raises(SystemExit) as exc:
            run_inst.show_history()
        assert exc.value.code == 0
        mock_hist.return_value.view.assert_called_once()


# ── find_orphans ──────────────────────────────────────────────────────────────


class TestFindOrphans:
    def test_view_mode_calls_orphans_view(self, run_inst):
        run_inst.options["option_autoremove"] = False
        with patch("slpkg.runner.Orphans", autospec=True) as mock_or, pytest.raises(SystemExit) as exc:
            run_inst.find_orphans()
        assert exc.value.code == 0
        mock_or.return_value.view.assert_called_once()

    def test_autoremove_with_orphans_calls_remove(self, run_inst):
        run_inst.options["option_autoremove"] = True
        with (
            patch("slpkg.runner.Orphans", autospec=True) as mock_or,
            patch("slpkg.runner.RemovePackages", autospec=True) as mock_rm,
            pytest.raises(SystemExit) as exc,
        ):
            mock_or.return_value.find.return_value = ["orphan1"]
            run_inst.find_orphans()
        assert exc.value.code == 0
        mock_rm.return_value.remove.assert_called_once()

    def test_autoremove_with_no_orphans_exits_cleanly(self, run_inst, capsys):
        run_inst.options["option_autoremove"] = True
        with patch("slpkg.runner.Orphans", autospec=True) as mock_or, pytest.raises(SystemExit) as exc:
            mock_or.return_value.find.return_value = []
            run_inst.find_orphans()
        assert exc.value.code == 0
        assert "No orphaned" in capsys.readouterr().out


# ── mark ──────────────────────────────────────────────────────────────────────


class TestMark:
    def test_mark_validates_installed_and_calls_view(self, run_inst):
        run_inst.options["option_unmark"] = False
        with patch("slpkg.runner.Mark", autospec=True) as mock_mk, pytest.raises(SystemExit) as exc:
            run_inst.mark(["pkg1"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_called_once_with(["pkg1"])
        mock_mk.return_value.view.assert_called_once()

    def test_unmark_skips_installed_validation(self, run_inst):
        run_inst.options["option_unmark"] = True
        with patch("slpkg.runner.Mark", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.mark(["pkg1"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_not_called()

    def test_empty_packages_skips_validation(self, run_inst):
        run_inst.options["option_unmark"] = False
        with patch("slpkg.runner.Mark", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.mark([])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_not_called()


# ── why ───────────────────────────────────────────────────────────────────────


class TestWhy:
    def test_validates_package_is_installed(self, run_inst):
        with patch("slpkg.runner.Why", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.why(["python"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_called_once()

    def test_wildcard_skips_installed_validation(self, run_inst):
        with patch("slpkg.runner.Why", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.why(["*"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_not_called()


# ── blacklist ─────────────────────────────────────────────────────────────────


class TestBlacklist:
    def test_add_packages(self, run_inst):
        run_inst.options["option_blacklist_remove"] = False
        with patch("slpkg.runner.BlacklistCmd", autospec=True) as mock_bl, pytest.raises(SystemExit) as exc:
            run_inst.blacklist(["pkg1"])
        assert exc.value.code == 0
        mock_bl.return_value.add.assert_called_once_with(["pkg1"])

    def test_remove_packages(self, run_inst):
        run_inst.options["option_blacklist_remove"] = True
        with patch("slpkg.runner.BlacklistCmd", autospec=True) as mock_bl, pytest.raises(SystemExit) as exc:
            run_inst.blacklist(["pkg1"])
        assert exc.value.code == 0
        mock_bl.return_value.remove.assert_called_once_with(["pkg1"])

    def test_view_when_no_packages(self, run_inst):
        run_inst.options["option_blacklist_remove"] = False
        with patch("slpkg.runner.BlacklistCmd", autospec=True) as mock_bl, pytest.raises(SystemExit) as exc:
            run_inst.blacklist([])
        assert exc.value.code == 0
        mock_bl.return_value.view.assert_called_once()


# ── pin ───────────────────────────────────────────────────────────────────────


class TestPin:
    def test_pin_validates_installed_and_calls_view(self, run_inst):
        run_inst.options["option_unpin"] = False
        with patch("slpkg.runner.Pin", autospec=True) as mock_pin, pytest.raises(SystemExit) as exc:
            run_inst.pin(["pkg1"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_called_once_with(["pkg1"])
        mock_pin.return_value.view.assert_called_once()

    def test_unpin_skips_installed_validation(self, run_inst):
        run_inst.options["option_unpin"] = True
        with patch("slpkg.runner.Pin", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.pin(["pkg1"])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_not_called()

    def test_empty_packages_skips_validation(self, run_inst):
        run_inst.options["option_unpin"] = False
        with patch("slpkg.runner.Pin", autospec=True), pytest.raises(SystemExit) as exc:
            run_inst.pin([])
        assert exc.value.code == 0
        run_inst.pkg_validator.is_package_installed.assert_not_called()


# ── check_broken ──────────────────────────────────────────────────────────────


class TestCheckBroken:
    def test_calls_check_broken_run_and_exits(self, run_inst):
        with patch("slpkg.runner.CheckBroken", autospec=True) as mock_cb, pytest.raises(SystemExit) as exc:
            run_inst.check_broken()
        assert exc.value.code == 0
        mock_cb.return_value.run.assert_called_once()


# ── clean_system ──────────────────────────────────────────────────────────────


class TestCleanSystem:
    def test_calls_clean_system_run_and_exits(self, run_inst):
        with patch("slpkg.runner.CleanSystem", autospec=True) as mock_cs, pytest.raises(SystemExit) as exc:
            run_inst.clean_system()
        assert exc.value.code == 0
        mock_cs.return_value.run.assert_called_once()


# ── health_check ──────────────────────────────────────────────────────────────


class TestHealthCheck:
    def test_calls_health_check_run_and_exits(self, run_inst):
        with patch("slpkg.runner.HealthCheck", autospec=True) as mock_hc, pytest.raises(SystemExit) as exc:
            run_inst.health_check(["pkg1"])
        assert exc.value.code == 0
        mock_hc.return_value.run.assert_called_once()


# ── audit ─────────────────────────────────────────────────────────────────────


class TestAudit:
    def test_calls_audit_run_and_exits(self, run_inst):
        with patch("slpkg.runner.Audit", autospec=True) as mock_au, pytest.raises(SystemExit) as exc:
            run_inst.audit(["pkg1"])
        assert exc.value.code == 0
        mock_au.return_value.run.assert_called_once()


# ── clean_tmp ─────────────────────────────────────────────────────────────────


class TestCleanTmp:
    def test_calls_cleanings_tmp_and_exits(self, run_inst):
        with patch("slpkg.runner.Cleanings", autospec=True) as mock_cl, pytest.raises(SystemExit) as exc:
            run_inst.clean_tmp()
        assert exc.value.code == 0
        mock_cl.return_value.tmp.assert_called_once()
