import logging

import pytest
from invoke.exceptions import AuthFailure

from exosphere.data import Update
from exosphere.errors import DataRefreshError
from exosphere.providers import Apt, Dnf, Pkg, PkgAdd, PkgManagerFactory, Yum
from exosphere.providers.api import requires_sudo


class TestRequiresSudoDecorator:
    """
    Tests for the requires_sudo decorator in isolation.
    """

    def test_sets_requires_sudo_attribute(self):
        """
        Ensure the decorator sets the __requires_sudo attribute on the wrapped function.
        """

        @requires_sudo
        def some_method():
            pass

        assert getattr(some_method, "__requires_sudo", False) is True

    def test_passthrough_on_success(self):
        """
        Ensure normal execution passes the return value through unchanged.
        """

        @requires_sudo
        def some_method():
            return "ok"

        assert some_method() == "ok"

    def test_auth_failure_raises_data_refresh_error(self, mocker):
        """
        Ensure AuthFailure raised by cx.sudo() is caught and re-raised as
        DataRefreshError with an actionable message about sudoers configuration.
        """
        mock_result = mocker.MagicMock()

        @requires_sudo
        def some_method():
            raise AuthFailure(result=mock_result, prompt="[sudo] password: ")

        with pytest.raises(DataRefreshError) as exc_info:
            some_method()

        assert "sudo" in str(exc_info.value).lower()

    def test_auth_failure_preserves_cause(self, mocker):
        """
        Ensure the original AuthFailure is chained as __cause__ on the
        DataRefreshError so that tracebacks remain useful.
        """
        mock_result = mocker.MagicMock()
        original = AuthFailure(result=mock_result, prompt="[sudo] password: ")

        @requires_sudo
        def some_method():
            raise original

        with pytest.raises(DataRefreshError) as exc_info:
            some_method()

        assert exc_info.value.__cause__ is original

    def test_other_exceptions_are_not_intercepted(self):
        """
        Ensure unrelated exceptions propagate as-is.
        """

        @requires_sudo
        def some_method():
            raise ValueError("unrelated error")

        with pytest.raises(ValueError, match="unrelated error"):
            some_method()


class TestPkgManagerFactory:
    @pytest.mark.parametrize(
        "name, expected_class",
        [("apt", Apt), ("pkg", Pkg), ("pkg_add", PkgAdd), ("dnf", Dnf), ("yum", Yum)],
        ids=["apt", "pkg", "pkg_add", "dnf", "yum"],
    )
    def test_create(self, name, expected_class):
        """
        Test the PkgManagerFactory to create package manager instances.
        """
        pkg_manager = PkgManagerFactory.create(name)
        assert isinstance(pkg_manager, expected_class)

    def test_create_invalid(self):
        """
        Test the PkgManagerFactory with an invalid package manager name.
        """
        with pytest.raises(ValueError):
            PkgManagerFactory.create("invalid_pkg_manager")

    def test_get_registry(self):
        """
        Test the ability to get available package managers.
        """
        registry = PkgManagerFactory.get_registry()

        assert isinstance(registry, dict)
        assert len(registry) == 5
        assert "apt" in registry
        assert "pkg" in registry
        assert "dnf" in registry
        assert "yum" in registry
        assert "pkg_add" in registry

        # Ensure we got a copy and not the original
        registry["apt"] = "modified"  # type: ignore
        assert PkgManagerFactory.get_registry()["apt"] != "modified"


class TestHostLogBinding:
    """
    Tests for binding host context onto a provider's logger, so log
    lines can be traced back to the host that produced them.
    """

    def test_create_with_host_prefixes_messages(self, caplog):
        """A provider created for a host prefixes its log lines with [host]."""
        pkg = PkgManagerFactory.create("apt", host_name="web1")

        with caplog.at_level(logging.WARNING, logger="exosphere.providers.apt"):
            pkg.logger.warning("Spaghetti is falling out of pockets!")

        assert (
            caplog.records[-1].getMessage()
            == "[web1] Spaghetti is falling out of pockets!"
        )

    def test_create_without_host_has_no_prefix(self, caplog):
        """A provider created without a host name logs unprefixed."""
        pkg = PkgManagerFactory.create("apt")

        with caplog.at_level(logging.WARNING, logger="exosphere.providers.apt"):
            pkg.logger.warning("Spaghetti successfully pocketed")

        assert caplog.records[-1].getMessage() == "Spaghetti successfully pocketed"

    def test_bind_host_wraps_existing_instance(self, caplog):
        """bind_host attaches the prefix to an already-constructed provider."""
        pkg = Apt()
        pkg.bind_host("compute3")

        with caplog.at_level(logging.WARNING, logger="exosphere.providers.apt"):
            pkg.logger.warning("The spaghetti was late to the party")

        assert (
            caplog.records[-1].getMessage()
            == "[compute3] The spaghetti was late to the party"
        )

    def test_bound_logger_keeps_per_class_name(self):
        """None of this affects or changes the logger name"""
        pkg = PkgManagerFactory.create("apt", host_name="web1")

        # Outer logger is the logging adapter, inner is logger
        assert isinstance(pkg.logger, logging.LoggerAdapter)
        assert pkg.logger.logger.name == "exosphere.providers.apt"


class TestAptProvider:
    @pytest.fixture
    def mock_connection(self, mocker):
        """
        Fixture to mock the Fabric Connection object.
        """
        mock_cx_class = mocker.patch(
            "exosphere.providers.debian.Connection", autospec=True
        )
        mock_cx = mock_cx_class.return_value

        # Context manager behavior should return the same mock
        mock_cx.__enter__.return_value = mock_cx
        mock_cx.__exit__.return_value = False  # Don't suppress exceptions

        # Default to successful run
        mock_cx.run.return_value.failed = False
        mock_cx.sudo.return_value.failed = False

        return mock_cx

    @pytest.fixture
    def mock_connection_failed(self, mock_connection):
        """
        Fixture to mock the Fabric Connection object with a failed run.
        """
        mock_connection.run.return_value.failed = True
        mock_connection.sudo.return_value.failed = True
        return mock_connection

    @pytest.fixture
    def mock_pkg_output(self, mock_connection):
        """
        Fixture to mock the output of the apt command enumerating packages.
        """
        output = """
        
        Inst base-files [12.4+deb12u10] (12.4+deb12u11 Debian:12.11/stable [arm64])
        Inst bash [5.2.15-2+b7] (5.2.15-2+b8 Debian:12.11/stable [arm64])
        Inst login [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u1 Debian:12.11/stable [arm64])
        Inst libdtovl0 (20250514-1~bookworm Raspberry Pi Foundation:stable [arm64])
        Inst libgpiolib0 (20250514-1~bookworm Raspberry Pi Foundation:stable [arm64])

        Inst passwd [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u1 Debian:12.11/stable [arm64])
        Inst initramfs-tools [0.142+rpt3+deb12u1] (0.142+rpt3+deb12u3 Raspberry Pi Foundation:stable [all])
        Inst big-patch [1.0-1] (1.0-2 Debian:12.11/bookworm-security [arm64])
        """
        mock_connection.run.return_value.stdout = output
        return mock_connection

    @pytest.fixture
    def mock_pkg_output_no_updates(self, mock_connection):
        """
        Fixture to mock the output of the apt command when no updates are available.
        """
        mock_connection.run.return_value.stdout = ""
        mock_connection.run.return_value.failed = True  # grep will fail!
        mock_connection.run.return_value.stderr = ""  # but no error message

        return mock_connection

    @pytest.mark.parametrize(
        "connection_fixture, expected",
        [
            ("mock_connection", True),
            ("mock_connection_failed", False),
        ],
        ids=["success", "failure"],
    )
    def test_reposync(self, request, connection_fixture, expected):
        """
        Test the reposync method of the Apt provider.
        """
        mock_connection = request.getfixturevalue(connection_fixture)

        apt = Apt()
        result = apt.reposync(mock_connection)

        mock_connection.sudo.assert_called_once_with(
            "/usr/bin/apt-get update", hide=True, warn=True
        )
        assert result is expected

    def test_get_updates(self, mock_pkg_output):
        """
        Test the get_updates method of the Apt provider.
        """
        apt = Apt()
        updates: list[Update] = apt.get_updates(mock_pkg_output)

        assert len(updates) == 8
        assert updates[0].name == "base-files"
        assert updates[0].current_version == "12.4+deb12u10"
        assert updates[0].new_version == "12.4+deb12u11"
        assert updates[0].source == "Debian:12.11/stable"
        assert not updates[0].security

        # Ensure new package updates are correctly identified
        assert updates[3].name == "libdtovl0"
        assert updates[3].current_version is None
        assert updates[3].new_version == "20250514-1~bookworm"
        assert updates[3].source == "Raspberry Pi Foundation:stable"
        assert not updates[3].security

        # Ensure security updates are correctly identified
        assert updates[7].name == "big-patch"
        assert updates[7].security

    def test_get_updates_logs_apt_warnings(self, mock_connection, caplog):
        """
        Test that non-fatal APT stderr output is logged as a warning.
        """
        apt = Apt()
        mock_connection.run.return_value.stdout = (
            "Inst dovecot-core [1:2.3.19.1+dfsg1-2.1+deb12u1] "
            "(1:2.3.19.1+dfsg1-2.1+deb12u2 Debian:12.11/stable [amd64])"
        )
        mock_connection.run.return_value.stderr = (
            "W: Unable to read /etc/apt/preferences.d/pin-dovecot - "
            "open (13: Permission denied)"
        )

        with caplog.at_level(logging.WARNING, logger="exosphere.providers.apt"):
            updates = apt.get_updates(mock_connection)

        assert len(updates) == 1
        assert any("APT: W:" in message for message in caplog.messages)
        assert any(
            "Unable to read /etc/apt/preferences.d/pin-dovecot" in message
            for message in caplog.messages
        )

    def test_get_updates_no_updates(self, mock_pkg_output_no_updates):
        """
        Test the get_updates method of the Apt provider when no updates are available.
        """
        apt = Apt()

        updates: list[Update] = apt.get_updates(mock_pkg_output_no_updates)

        assert updates == []

    def test_get_updates_query_failed(self, mock_connection):
        """
        Test the get_updates method of the Apt provider when the query fails.
        """
        apt = Apt()
        mock_connection.run.return_value.failed = True
        mock_connection.run.return_value.stderr = "Generic error"

        with pytest.raises(DataRefreshError):
            apt.get_updates(mock_connection)

    def test_get_updates_invalid_output(self, mock_connection):
        """
        Test the get_updates method of the Apt provider with invalid output.
        Unparsable output in lines should be ignored.
        """
        apt = Apt()
        mock_connection.run.return_value.stdout = "Invalid output"

        results = apt.get_updates(mock_connection)

        assert results == []

    @pytest.mark.parametrize(
        "file_present, expected",
        [(True, True), (False, False)],
        ids=["reboot-required", "no-reboot"],
    )
    def test_get_reboot_status(self, mock_connection, file_present, expected):
        """
        Reboot status is driven by the presence of /var/run/reboot-required.
        """
        apt = Apt()
        mock_connection.run.return_value.ok = file_present

        assert apt.get_reboot_status(mock_connection) is expected
        mock_connection.run.assert_called_with(
            "test -f /var/run/reboot-required", hide=True, warn=True
        )


class TestPkgProvider:
    @pytest.fixture
    def mock_connection(self, mocker):
        """
        Fixture to mock the Fabric Connection object.
        """
        mock_cx_class = mocker.patch(
            "exosphere.providers.freebsd.Connection", autospec=True
        )
        mock_cx = mock_cx_class.return_value

        # Context manager behavior should return the same mock
        mock_cx.__enter__.return_value = mock_cx
        mock_cx.__exit__.return_value = False  # Don't suppress exceptions

        # Default to successful run
        mock_cx.run.return_value.failed = False

        return mock_cx

    @pytest.fixture
    def mock_connection_failed(self, mock_connection):
        """
        Fixture to mock the Fabric Connection object with a failed run.
        """
        mock_connection.run.return_value.failed = True
        mock_connection.run.return_value.stderr = "Generic error"
        return mock_connection

    @pytest.fixture
    def mock_connection_sudo(self, mock_connection):
        """
        Fixture to mock the Fabric Connection object with sudo.
        """
        mock_connection.sudo.return_value.failed = False
        return mock_connection

    @pytest.fixture
    def mock_connection_sudo_failed(self, mock_connection):
        """
        Fixture to mock the Fabric Connection object with sudo and a failed run.
        """
        mock_connection.sudo.return_value.failed = True
        return mock_connection

    @pytest.fixture
    def mock_connection_sudo_audit_failed(self, mocker, mock_connection):
        """
        Fixture to mock a connection where 'pkg update -q' succeeds but
        'pkg audit -qF' fails with a non-empty stderr.
        """
        mock_update = mocker.MagicMock()
        mock_update.failed = False

        mock_audit = mocker.MagicMock()
        mock_audit.failed = True
        mock_audit.stderr = "pkg: Unable to fetch vulnerability database"

        def side_effect(cmd, *args, **kwargs):
            if "pkg audit" in cmd:
                return mock_audit
            return mock_update

        mock_connection.sudo.side_effect = side_effect
        return mock_connection

    @pytest.fixture
    def mock_pkg_output(self, mocker, mock_connection):
        """
        Fixture to mock the output of the pkg command enumerating packages.
        """
        output = """
        The following 19 package(s) will be affected (of 0 checked):

        Installed packages to be UPGRADED:
                bash-completion-zfs: 2.3.1
                btop: 1.4.1 -> 1.4.3
                cmake: 3.31.6 -> 3.31.7
                cmake-core: 3.31.6 -> 3.31.7
                cmake-doc: 3.31.6 -> 3.31.7
                cmake-man: 3.31.6 -> 3.31.7
                curl: 8.13.0 -> 8.13.0_2
                en-freebsd-doc: 20250425,1 -> 20250509,1
                libgcrypt: 1.11.0 -> 1.11.1
                mpdecimal: 4.0.0 -> 4.0.1
                p5-URI: 5.31 -> 5.32
                pciids: 20250309 -> 20250415
                py311-cryptography: 44.0.1,1 -> 44.0.2,1
                py311-h11: 0.14.0_1 -> 0.16.0
                py311-httpcore: 1.0.7 -> 1.0.9
                py311-markdown: 3.6 -> 3.7
                py311-typing-extensions: 4.13.1 -> 4.13.2
                smartmontools: 7.4_2 -> 7.5
                vim: 9.1.1265 -> 9.1.1378
                xxd: 9.1.1265 -> 9.1.1378
                autoconf-2.72 (direct dependency changed: perl5)
                net-snmp-5.9.4_6,1 (direct dependency changed: perl5)

        Number of packages to be upgraded: 19

        77 MiB to be downloaded.

        """
        output_vulnerable = "py311-h11-0.14.0_1"

        mock_audit = mocker.MagicMock()
        mock_audit.failed = True  # Audit returns non-zero exit code on match
        mock_audit.stdout = output_vulnerable
        mock_audit.stderr = ""  # No error message for successful audit

        mock_packages = mocker.MagicMock()
        mock_packages.failed = False
        mock_packages.stdout = output

        def side_effect(cmd, *args, **kwargs):
            if "pkg audit" in cmd:
                return mock_audit
            elif "pkg upgrade" in cmd:
                return mock_packages
            else:
                # Default empty response
                result = mocker.MagicMock()
                result.stdout = ""
                result.failed = False
                result.return_code = 0
                return result

        mock_connection.run.side_effect = side_effect

        return mock_connection

    @pytest.fixture
    def mock_pkg_output_with_repo(self, mocker, mock_connection):
        """
        Fixture to mock the output of recent pkg with repo tag in output
        """
        output = """
        The following 19 package(s) will be affected (of 0 checked):

        Installed packages to be UPGRADED:
                bash-completion-zfs: 2.3.1 [FreeBSD]
                btop: 1.4.1 -> 1.4.3 [FreeBSD]
                cmake: 3.31.6 -> 3.31.7 [FreeBSD]
                cmake-core: 3.31.6 -> 3.31.7 [FreeBSD]
                cmake-doc: 3.31.6 -> 3.31.7 [FreeBSD]
                cmake-man: 3.31.6 -> 3.31.7 [FreeBSD]
                curl: 8.13.0 -> 8.13.0_2 [FreeBSD]
                en-freebsd-doc: 20250425,1 -> 20250509,1 [FreeBSD]
                libgcrypt: 1.11.0 -> 1.11.1 [FreeBSD]
                mpdecimal: 4.0.0 -> 4.0.1 [FreeBSD]
                p5-URI: 5.31 -> 5.32 [FreeBSD]
                pciids: 20250309 -> 20250415 [FreeBSD]
                py311-cryptography: 44.0.1,1 -> 44.0.2,1 [FreeBSD]
                py311-h11: 0.14.0_1 -> 0.16.0 [FreeBSD]
                py311-httpcore: 1.0.7 -> 1.0.9 [FreeBSD]
                py311-markdown: 3.6 -> 3.7 [FreeBSD]
                py311-typing-extensions: 4.13.1 -> 4.13.2 [FreeBSD]
                smartmontools: 7.4_2 -> 7.5 [FreeBSD]
                vim: 9.1.1265 -> 9.1.1378 [FreeBSD]
                xxd: 9.1.1265 -> 9.1.1378 [FreeBSD]
                autoconf-2.72 [FreeBSD] (direct dependency changed: perl5)
                net-snmp-5.9.4_6,1 [FreeBSD] (direct dependency changed: perl5)

        Number of packages to be upgraded: 19

        77 MiB to be downloaded.

        """
        output_vulnerable = "py311-h11-0.14.0_1"

        mock_audit = mocker.MagicMock()
        mock_audit.failed = True  # Audit returns non-zero exit code on match
        mock_audit.stdout = output_vulnerable
        mock_audit.stderr = ""  # No error message for successful audit

        mock_packages = mocker.MagicMock()
        mock_packages.failed = False
        mock_packages.stdout = output

        def side_effect(cmd, *args, **kwargs):
            if "pkg audit" in cmd:
                return mock_audit
            elif "pkg upgrade" in cmd:
                return mock_packages
            else:
                # Default empty response
                result = mocker.MagicMock()
                result.stdout = ""
                result.failed = False
                result.return_code = 0
                return result

        mock_connection.run.side_effect = side_effect

        return mock_connection

    @pytest.fixture
    def mock_pkg_output_audit_failed(self, mocker, mock_connection):
        """
        Fixture to mock the output of the pkg command when the audit fails.
        This simulates a non-zero exit code with an error message.
        """

        mock_audit = mocker.MagicMock()
        mock_audit.failed = True
        mock_audit.stdout = ""
        mock_audit.stderr = "Generic error"

        mock_packages = mocker.MagicMock()
        mock_packages.failed = False
        mock_packages.stdout = ""

        def side_effect(cmd, *args, **kwargs):
            if "pkg audit" in cmd:
                return mock_audit
            elif "pkg upgrade" in cmd:
                return mock_packages
            else:
                # Default empty response
                result = mocker.MagicMock()
                result.stdout = ""
                result.failed = False
                result.return_code = 0
                return result

        mock_connection.run.side_effect = side_effect
        return mock_connection

    @pytest.fixture
    def mock_pkg_output_no_updates(self, mocker, mock_connection):
        """
        Fixture to mock the output of the pkg command when no updates are available.
        """
        mock_audit = mocker.MagicMock()
        mock_audit.failed = False
        mock_audit.stdout = ""
        mock_audit.stderr = ""

        mock_packages = mocker.MagicMock()
        mock_packages.failed = True
        mock_packages.stderr = ""

        def side_effect(cmd, *args, **kwargs):
            if "pkg audit" in cmd:
                return mock_audit
            elif "pkg upgrade" in cmd:
                return mock_packages
            else:
                # Default empty response
                result = mocker.MagicMock()
                result.stdout = ""
                result.failed = False
                result.return_code = 0
                return result

        mock_connection.run.side_effect = side_effect
        return mock_connection

    @pytest.mark.parametrize(
        "connection_fixture, expected, expected_sudo_calls",
        [
            ("mock_connection_sudo", True, 2),
            ("mock_connection_sudo_failed", False, 1),
            ("mock_connection_sudo_audit_failed", False, 2),
        ],
        ids=["success", "pkg_update_failure", "pkg_audit_failure"],
    )
    def test_reposync(self, request, connection_fixture, expected, expected_sudo_calls):
        """
        Test the reposync method of the Pkg provider.

        Covers:
        - success: both sudo commands succeed
        - pkg_update_failure: 'pkg update -q' fails, short-circuits after 1 call.
        - pkg_audit_failure: 'pkg update -q' succeeds but 'pkg audit -qF' fails
        """
        mock_connection = request.getfixturevalue(connection_fixture)

        pkg = Pkg()
        result = pkg.reposync(mock_connection)

        # The first sudo call is always 'pkg update -q'
        mock_connection.sudo.assert_any_call(
            "/usr/sbin/pkg update -q", hide=True, warn=True
        )

        # When both calls are made, assert the second one too
        if expected_sudo_calls == 2:
            mock_connection.sudo.assert_any_call(
                "/usr/sbin/pkg audit -qF", hide=True, warn=True
            )

        assert mock_connection.sudo.call_count == expected_sudo_calls
        assert result is expected

    @pytest.mark.parametrize(
        "fixture_name,expected_repo_name",
        [
            ("mock_pkg_output", "Packages Mirror"),
            ("mock_pkg_output_with_repo", "FreeBSD"),
        ],
        ids=["legacy_format", "new_pkg_format"],
    )
    def test_get_updates(self, request, fixture_name, expected_repo_name):
        """
        Test the get_updates method of the Pkg provider.
        Tests both legacy format and new format with repository tags.

        Note: Closely coupled with implementation due to use
        of side effect to mock to two separate calls to the
        mock_connection.run method.
        """
        # Get the fixture dynamically
        mock_output = request.getfixturevalue(fixture_name)

        pkg = Pkg()

        try:
            updates: list[Update] = pkg.get_updates(mock_output)
        except DataRefreshError as e:
            pytest.fail(f"DataRefreshError should not be raised, got: {e}")

        assert len(updates) == 20

        # new package
        assert updates[0].name == "bash-completion-zfs"
        assert updates[0].current_version is None
        assert updates[0].new_version == "2.3.1"
        assert updates[0].source == expected_repo_name
        assert not updates[0].security

        # normal package update
        assert updates[1].name == "btop"
        assert updates[1].current_version == "1.4.1"
        assert updates[1].new_version == "1.4.3"
        assert updates[1].source == expected_repo_name
        assert not updates[1].security

        # Ensure security updates are correctly identified
        assert updates[13].name == "py311-h11"
        assert updates[13].source == expected_repo_name
        assert updates[13].security

    def test_get_updates_no_updates(self, mock_pkg_output_no_updates):
        """
        Test the get_updates method of the Pkg provider when no updates are available.
        """
        pkg = Pkg()

        updates: list[Update] = pkg.get_updates(mock_pkg_output_no_updates)

        assert updates == []

    def test_get_updates_query_failed(self, mock_connection_failed):
        """
        Test the get_updates method of the Pkg provider when the query fails.
        """
        pkg = Pkg()

        with pytest.raises(DataRefreshError):
            pkg.get_updates(mock_connection_failed)

    @pytest.mark.parametrize(
        "output",
        ["Invalid output", "Updates are being launched in space", "->", ""],
        ids=[
            "invalid_output_1",
            "invalid_output_2",
            "invalid_output_3",
            "empty_output",
        ],
    )
    def test_get_updates_invalid_output(self, mock_connection, output):
        """
        Test the get_updates method of the Pkg provider with invalid output.
        Unparsable output in lines should be ignored.
        """
        pkg = Pkg()
        mock_connection.run.return_value.stdout = output

        results = pkg.get_updates(mock_connection)

        assert results == []

    def test_get_updates_nonzero_exit_audit(self, mock_pkg_output_audit_failed):
        """
        Test the get_updates method of the Pkg provider when the audit command fails.

        We test this by simulating the combination of a non-zero exit code
        and a non-empty stderr, which is the only way to figure out if the
        audit command genuinely failed.
        """
        pkg = Pkg()

        with pytest.raises(DataRefreshError) as e:
            pkg.get_updates(mock_pkg_output_audit_failed)
            assert (
                str(e.value)
                == "Failed to get vulnerable packages from pkg: Generic error"
            )

    @pytest.mark.parametrize(
        "installed_kernel, running_kernel, expected",
        [
            ("14.1-RELEASE-p6\n", "14.1-RELEASE-p6\n", False),
            ("14.1-RELEASE-p6\n", "14.1-RELEASE-p3\n", True),
        ],
        ids=["matching", "diverging"],
    )
    def test_get_reboot_status(
        self, mock_connection, mocker, installed_kernel, running_kernel, expected
    ):
        """
        Reboot status compares the installed kernel (freebsd-version -k)
        with the running kernel (freebsd-version -r).
        """
        pkg = Pkg()

        mock_connection.run.side_effect = [
            mocker.Mock(failed=False, stdout=installed_kernel),
            mocker.Mock(failed=False, stdout=running_kernel),
        ]

        assert pkg.get_reboot_status(mock_connection) is expected

    @pytest.mark.parametrize(
        "installed, running",
        [
            (
                {"failed": True, "stdout": "", "stderr": "it's dead jim"},
                {"failed": False, "stdout": "14.1-RELEASE-p6\n"},
            ),
            (
                {"failed": False, "stdout": "14.1-RELEASE-p6\n"},
                {"failed": True, "stdout": "", "stderr": "it died"},
            ),
            (
                {"failed": False, "stdout": "\n"},
                {"failed": False, "stdout": "14.1-RELEASE-p6\n"},
            ),
        ],
        ids=["installed-failed", "running-failed", "empty-output"],
    )
    def test_get_reboot_status_unknown(
        self, mock_connection, mocker, installed, running
    ):
        """
        Reboot status degrades to unknown (None) when freebsd-version fails
        or returns an empty kernel version string.
        """
        pkg = Pkg()

        mock_connection.run.side_effect = [
            mocker.Mock(**installed),
            mocker.Mock(**running),
        ]

        assert pkg.get_reboot_status(mock_connection) is None


class TestPkgAddProvider:
    @pytest.fixture
    def mock_connection(self, mocker):
        """
        Fixture to mock the Fabric Connection object.
        """
        mock_cx_class = mocker.patch(
            "exosphere.providers.openbsd.Connection", autospec=True
        )
        mock_cx = mock_cx_class.return_value

        # Context manager behavior should return the same mock
        mock_cx.__enter__.return_value = mock_cx
        mock_cx.__exit__.return_value = False  # Don't suppress exceptions

        # Default to successful run
        mock_cx.run.return_value.failed = False

        return mock_cx

    @pytest.fixture
    def mock_pkg_add_output(self, mocker, mock_connection):
        """
        Fixture to mock the output of the pkg_add command enumerating packages.

        From OpenBSD 7.7, only upgradable packages are: curl, htop, isc-bind, libxml
        """
        output = """
        Update candidates: curl-8.13.0 -> curl-8.13.1
        Update candidates: ngtcp2-1.11.0 -> ngtcp2-1.11.0
        Update candidates: nghttp2-1.65.0 -> nghttp2-1.65.0
        Update candidates: nghttp3-1.8.0 -> nghttp3-1.8.0
        Update candidates: desktop-file-utils-0.28p0 -> desktop-file-utils-0.28p0
        Update candidates: glib2-2.82.5 -> glib2-2.82.5
        Update candidates: pcre2-10.44 -> pcre2-10.44
        Update candidates: fio-3.38 -> fio-3.38
        Update candidates: libnfs-5.0.2 -> libnfs-5.0.2
        Update candidates: htop-3.4.0 -> htop-3.4.2
        Update candidates: isc-bind-9.20.11v3 -> isc-bind-9.20.13v3
        Update candidates: liburcu-0.15.1 -> liburcu-0.15.1
        Update candidates: libuv-1.50.0p0 -> libuv-1.50.0p0
        Update candidates: libxml-2.13.8 -> libxml-2.13.9
        Update candidates: json-c-0.18 -> json-c-0.18
        Update candidates: libidn2-2.3.0p0 -> libidn2-2.3.0p0
        Update candidates: libunistring-0.9.7 -> libunistring-0.9.7
        Update candidates: libsodium-1.0.20 -> libsodium-1.0.20
        Update candidates: py3-pyrsistent-0.20.0p0 -> py3-pyrsistent-0.20.0p0
        Update candidates: qemu-ga-9.2.2 -> qemu-ga-9.2.2
        Update candidates: sudo-1.9.17.1p0-gettext -> sudo-1.9.17.1p0-gettext
        Update candidates: updatedb-0p0 -> updatedb-0p0
        Update candidates: vim-9.1.1265-no_x11-python3 -> vim-9.1.1265-no_x11-python3
        """

        mock_packages = mocker.MagicMock()
        mock_packages.failed = False
        mock_packages.stdout = output
        mock_packages.stderr = "pkg_add should be run as root\n"

        return mock_packages

    @pytest.fixture
    def mock_pkg_add_output_no_updates(self, mocker, mock_connection):
        """
        Fixture to mock the output of the pkg_add command when no updates are available.
        """
        mock_output = mocker.MagicMock()
        mock_output.failed = False
        mock_output.stdout = """
        Update candidates: ngtcp2-1.11.0 -> ngtcp2-1.11.0
        Update candidates: nghttp2-1.65.0 -> nghttp2-1.65.0
        Update candidates: nghttp3-1.8.0 -> nghttp3-1.8.0
        Update candidates: desktop-file-utils-0.28p0 -> desktop-file-utils-0.28p0
        Update candidates: glib2-2.82.5 -> glib2-2.82.5
        Update candidates: pcre2-10.44 -> pcre2-10.44
        Update candidates: fio-3.38 -> fio-3.38
        Update candidates: libnfs-5.0.2 -> libnfs-5.0.2
        """
        mock_output.stderr = "pkg_add should be run as root\n"

        return mock_output

    @pytest.fixture
    def mock_pkg_add_output_no_packages(self, mocker, mock_connection):
        """
        Fixture to mock the output of pkg_add when no packages have updates
        and grep returns no matches.
        """
        mock_output = mocker.MagicMock()
        mock_output.failed = True
        mock_output.stdout = ""
        mock_output.stderr = "pkg_add should be run as root\n"
        mock_output.return_code = 1

        return mock_output

    @pytest.fixture
    def mock_system_stable_or_release(self, mocker):
        """
        Fixture to mock the output of syspatch to return a stable or release version
        """
        mock_version = mocker.MagicMock()
        mock_version.failed = False
        mock_version.stdout = ""
        mock_version.stderr = ""

        return mock_version

    @pytest.fixture
    def mock_system_current(self, mocker):
        """
        Fixture to mock the output of syspatch to return a current version
        """
        mock_version = mocker.MagicMock()
        mock_version.failed = True
        mock_version.stdout = ""
        mock_version.stderr = "syspatch: Unsupported release: 7.8-beta"

        return mock_version

    @pytest.fixture
    def mock_pkg_add_output_connection_stable(
        self, mock_connection, mock_pkg_add_output, mock_system_stable_or_release
    ):
        """
        Fixture to mock the output of pkg_add add with a stable or release
        version of OpenBSD where all updates should be security updates.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            else:
                return mock_pkg_add_output

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_pkg_add_output_connection_current(
        self, mock_connection, mock_pkg_add_output, mock_system_current
    ):
        """
        Fixture to mock the output of pkg_add add with a current
        version of OpenBSD where no updates should be security updates.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_current
            else:
                return mock_pkg_add_output

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_pkg_add_no_updates_connection_stable(
        self,
        mock_connection,
        mock_pkg_add_output_no_updates,
        mock_system_stable_or_release,
    ):
        """
        Fixture to mock the output of pkg_add add with a stable or release
        version of OpenBSD where no updates are available.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            else:
                return mock_pkg_add_output_no_updates

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_pkg_add_no_updates_connection_current(
        self, mock_connection, mock_pkg_add_output_no_updates, mock_system_current
    ):
        """
        Fixture to mock the output of pkg_add add with a current
        version of OpenBSD where no updates are available.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_current
            else:
                return mock_pkg_add_output_no_updates

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_pkg_add_no_packages_connection_stable(
        self,
        mock_connection,
        mock_pkg_add_output_no_packages,
        mock_system_stable_or_release,
    ):
        """
        Fixture to mock the output of pkg_add when no packages have updates.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            else:
                return mock_pkg_add_output_no_packages

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_pkg_add_no_packages_connection_current(
        self, mock_connection, mock_pkg_add_output_no_packages, mock_system_current
    ):
        """
        Fixture to mock the output of pkg_add when no packages have updates.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_current
            else:
                return mock_pkg_add_output_no_packages

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_connection_uname_failed(
        self, mocker, mock_connection, mock_pkg_add_output
    ):
        """
        Fixture to mock the Fabric Connection object with a failed uname command.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                value = mocker.MagicMock()
                value.failed = True
                value.stderr = "Generic error"
                return value
            else:
                return mock_pkg_add_output

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_connection_pkg_add_query_failed(
        self, mocker, mock_connection, mock_system_stable_or_release
    ):
        """
        Fixture to mock the Fabric Connection object with a failed pkg_add command.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            else:
                value = mocker.MagicMock()
                value.failed = True
                value.stderr = "Generic error"
                return value

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.fixture
    def mock_connection_pkg_add_invalid_output(
        self, mocker, mock_connection, mock_system_stable_or_release
    ):
        """
        Fixture to mock the Fabric Connection object with invalid pkg_add output.
        """

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            else:
                value = mocker.MagicMock()
                value.failed = False
                value.stdout = "Invalid output"
                return value

        mock_connection.run.side_effect = side_effects
        return mock_connection

    @pytest.mark.parametrize(
        "connection_fixture, expected_security",
        [
            ("mock_pkg_add_output_connection_stable", True),
            ("mock_pkg_add_output_connection_current", False),
        ],
        ids=["stable_or_release", "current"],
    )
    def test_get_updates(self, request, connection_fixture, expected_security):
        """
        Test the get_updates method of the PkgAdd provider.
        """
        mock_pkg_add_output = request.getfixturevalue(connection_fixture)

        pkg_add = PkgAdd()
        updates: list[Update] = pkg_add.get_updates(mock_pkg_add_output)

        assert len(updates) == 4

        assert updates[0].name == "curl"
        assert updates[0].current_version == "8.13.0"
        assert updates[0].new_version == "8.13.1"

        assert updates[1].name == "htop"
        assert updates[1].current_version == "3.4.0"
        assert updates[1].new_version == "3.4.2"

        assert updates[2].name == "isc-bind"
        assert updates[2].current_version == "9.20.11v3"
        assert updates[2].new_version == "9.20.13v3"

        assert updates[3].name == "libxml"
        assert updates[3].current_version == "2.13.8"
        assert updates[3].new_version == "2.13.9"

        for u in updates:
            assert u.security == expected_security

    def test_get_current_generates_warning(
        self, mock_pkg_add_output_connection_current, caplog
    ):
        """
        Test that a warning is logged if the system is tracking -current
        """
        pkg_add = PkgAdd()

        with caplog.at_level(logging.WARNING):
            updates: list[Update] = pkg_add.get_updates(
                mock_pkg_add_output_connection_current
            )

        assert len(updates) == 4
        assert "Host is running unsupported OpenBSD release" in caplog.text
        assert "Security status will not be tracked" in caplog.text

    @pytest.mark.parametrize(
        "connection_fixture",
        [
            "mock_pkg_add_no_updates_connection_stable",
            "mock_pkg_add_no_updates_connection_current",
        ],
        ids=["stable_or_release", "current"],
    )
    def test_get_updates_no_updates(self, request, connection_fixture):
        """
        Test the get_updates method of the PkgAdd provider when no updates are available.
        """
        mock_connection = request.getfixturevalue(connection_fixture)
        pkg_add = PkgAdd()
        updates: list[Update] = pkg_add.get_updates(mock_connection)

        assert updates == []

    @pytest.mark.parametrize(
        "connection_fixture",
        [
            "mock_pkg_add_no_packages_connection_stable",
            "mock_pkg_add_no_packages_connection_current",
        ],
        ids=["stable_or_release", "current"],
    )
    def test_get_updates_no_packages(self, request, connection_fixture, caplog):
        """
        Test the get_updates method of the PkgAdd provider when no upgradable
        packages are found in the output.
        """
        mock_connection = request.getfixturevalue(connection_fixture)

        pkg_add = PkgAdd()
        with caplog.at_level(logging.DEBUG):
            updates: list[Update] = pkg_add.get_updates(mock_connection)

        assert updates == []
        assert "No OpenBSD packages with updates" in caplog.text

    @pytest.mark.parametrize(
        "connection_fixture, exception_expected",
        [
            ("mock_connection_uname_failed", "Failed to query OpenBSD version"),
            ("mock_connection_pkg_add_query_failed", "Failed to query OpenBSD pkg_add"),
        ],
        ids=["uname_failure", "pkg_add_failure"],
    )
    def test_get_updates_command_failure(
        self, request, connection_fixture, exception_expected
    ):
        """
        Test the get_updates method of the PkgAdd provider when the query fails.
        """
        mock_connection = request.getfixturevalue(connection_fixture)

        pkg_add = PkgAdd()

        with pytest.raises(DataRefreshError, match=exception_expected):
            pkg_add.get_updates(mock_connection)

    def test_get_updates_name_change(
        self, mocker, mock_connection, mock_system_stable_or_release, caplog
    ):
        """
        Test the get_updates method of the PkgAdd provider with a package
        that changes names between versions (e.g. a quirks-driven rename).

        The new stem is not installed under that name, so we report it as a
        new install (current_version=None) and log a debug note.
        """

        output = "Update candidates: oldname-1.0 -> newname-2.0"

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            else:
                mock_packages = mocker.MagicMock()
                mock_packages.failed = False
                mock_packages.stdout = output
                return mock_packages

        mock_connection.run.side_effect = side_effects

        pkg_add = PkgAdd()

        with caplog.at_level(logging.DEBUG):
            results = pkg_add.get_updates(mock_connection)

        assert len(results) == 1
        assert results[0].name == "newname"
        assert results[0].current_version is None
        assert results[0].new_version == "2.0"

        assert "changed name to newname" in caplog.text

    def test_get_updates_flavored_version_bump(
        self, mocker, mock_connection, mock_system_stable_or_release, caplog
    ):
        """
        Test that flavored packages with an actual version bump are reported.

        Regression test: We previous accidentally swallowed the last
        hyphen in the parser, which caused flavored packages to be
        dropped from the update list entirely.
        """

        output = (
            "Update candidates: vim-9.2.699-no_x11 -> vim-9.2.738-no_x11\n"
            "Update candidates: sudo-1.9.17.1p0-gettext -> sudo-1.9.17.2p0-gettext\n"
        )

        def side_effects(cmd, *args, **kwargs):
            if "syspatch" in cmd:
                return mock_system_stable_or_release
            mock_packages = mocker.MagicMock()
            mock_packages.failed = False
            mock_packages.stdout = output
            return mock_packages

        mock_connection.run.side_effect = side_effects

        pkg_add = PkgAdd()

        with caplog.at_level(logging.WARNING):
            results = pkg_add.get_updates(mock_connection)

        assert "Unexpected package name change" not in caplog.text

        assert len(results) == 2

        assert results[0].name == "vim"
        assert results[0].current_version == "9.2.699"
        assert results[0].new_version == "9.2.738"

        assert results[1].name == "sudo"
        assert results[1].current_version == "1.9.17.1p0"
        assert results[1].new_version == "1.9.17.2p0"

    def test_get_updates_invalid_output(
        self, mock_connection_pkg_add_invalid_output, caplog
    ):
        """
        Test the get_updates method of the PkgAdd provider with invalid output.
        Unparsable output in lines should be ignored.
        """
        pkg_add = PkgAdd()

        with caplog.at_level(logging.DEBUG):
            results = pkg_add.get_updates(mock_connection_pkg_add_invalid_output)

        assert results == []
        assert "Could not parse" in caplog.text

    def test_reposync(self, mock_connection):
        """
        Test the reposync method of the PkgAdd provider.
        It's a big no-op for pkg_add.
        """
        pkg_add = PkgAdd()
        result = pkg_add.reposync(mock_connection)

        assert result is True
        mock_connection.run.assert_not_called()
        mock_connection.sudo.assert_not_called()

    def test_get_reboot_status(self, mock_connection):
        """
        Test the get_reboot_status method for the PkgAdd provider.
        It is, once more, a big no-op that returns None
        """
        pkg_add = PkgAdd()
        result = pkg_add.get_reboot_status(mock_connection)

        assert result is None
        mock_connection.run.assert_not_called()
        mock_connection.sudo.assert_not_called()


class TestDnfProvider:
    @pytest.fixture
    def mock_connection(self, mocker):
        """
        Fixture to mock the Fabric Connection object.
        """
        mock_cx_class = mocker.patch(
            "exosphere.providers.redhat.Connection", autospec=True
        )
        mock_cx = mock_cx_class.return_value

        # Context manager behavior should return the same mock
        mock_cx.__enter__.return_value = mock_cx
        mock_cx.__exit__.return_value = False  # Don't suppress exceptions

        mock_cx.run.return_value.failed = False
        return mock_cx

    @pytest.fixture
    def mock_connection_failed(self, mocker, mock_connection):
        """
        Fixture to mock the Fabric Connection object with a failed run.
        """
        mock_connection.run.return_value = mocker.MagicMock()
        mock_connection.run.return_value.failed = True
        mock_connection.run.return_value.return_code = 2
        mock_connection.run.return_value.stderr = "Generic error"
        return mock_connection

    @pytest.fixture
    def mock_dnf_output_return(self, mocker):
        """
        Fixture to mock the output of the dnf command enumerating packages.
        """
        output = """

        emacs-filesystem.noarch               1:27.2-13.el9_6                   appstream
        expat.x86_64                          2.5.0-5.el9_6                     baseos
        git.x86_64                            2.47.1-2.el9_6                    appstream
        git-core.x86_64                       2.47.1-2.el9_6                    appstream
        git-core-doc.noarch                   2.47.1-2.el9_6                    appstream
        openssl.x86_64                        3.0.9-16.el9_6                    baseos
        openssl-libs.x86_64                   3.0.9-16.el9_6                    baseos
        systemd.x86_64                        252-18.el9_6                      baseos
        systemd-libs.x86_64                   252-18.el9_6                      baseos
        curl.x86_64                           7.76.1-19.el9_6                   baseos
        curl-minimal.x86_64                   7.76.1-19.el9_6                   @baseos
        Obsoleting Packages
        libldb.i686                           4.21.3-3.el9                      baseos
            libldb.x86_64                     2.9.1-2.el9                       @baseos
        """

        mock_return = mocker.MagicMock()
        mock_return.stdout = output
        mock_return.failed = True
        mock_return.return_code = 100

        return mock_return

    @pytest.fixture
    def mock_dnf_current_versions_return(self, mocker):
        """
        Fixture to mock the output of the dnf command for current versions.
        """

        output = """

        Installed Packages
        emacs-filesystem.noarch               1:27.1-10.el9_6                   @appstream
        expat.x86_64                          2.5.0-3.el9_6                     @baseos
        git.x86_64                            2.47.0-1.el9_6                    @appstream
        git-core.x86_64                       2.47.0-1.el9_6                    @appstream
        git-core-doc.noarch                   2.47.1-2.el9_6                    @appstream
        openssl.x86_64                        3.0.9-15.el9_5                    @baseos
        openssl-libs.x86_64                   3.0.9-15.el9_5                    @baseos
        systemd.x86_64                        252-17.el9_5                      @baseos
        systemd-libs.x86_64                   252-17.el9_5                      @baseos
        curl.x86_64                           7.76.1-18.el9_5                   @baseos
        curl-minimal.x86_64                   7.76.1-18.el9_5                   @baseos

        Available packages
        git.x86_64                            2.47.1-2.el9_6                    appstream
        """

        mock_return = mocker.MagicMock()
        mock_return.stdout = output
        mock_return.failed = False
        mock_return.return_code = 0

        return mock_return

    @pytest.fixture
    def mock_dnf_security_output_return(self, mocker):
        """
        Fixture to mock the output of the dnf command for security updates.
        """
        output = """

        openssl.x86_64                       3.0.9-16.el9_6                     baseos
        openssl-libs.x86_64                  3.0.9-16.el9_6                     baseos
        systemd.x86_64                       252-18.el9_6                       baseos
        systemd-libs.x86_64                  252-18.el9_6                       baseos
        kernel.x86_64                        5.14.0-570.18.1.el9_6              baseos
        Obsoleting Packages
        libldb.i686                          4.21.3-3.el9                       baseos
            libldb.x86_64                    2.9.1-2.el9                        @baseos
        """

        mock_return = mocker.MagicMock()
        mock_return.stdout = output
        mock_return.failed = True
        mock_return.return_code = 100

        return mock_return

    @pytest.fixture
    def mock_dnf_output_no_updates(self, mocker, mock_connection):
        """
        Fixture to mock the output of the dnf command when no updates are available.
        """

        # Mock for security updates (empty)
        mock_security = mocker.MagicMock()
        mock_security.stdout = ""
        mock_security.return_code = 0
        mock_security.failed = False

        # Mock for regular updates (empty)
        mock_updates = mocker.MagicMock()
        mock_updates.stdout = ""
        mock_updates.return_code = 0
        mock_updates.failed = False

        def side_effect(cmd, *args, **kwargs):
            if "check-update --security" in cmd:
                return mock_security
            elif "check-update" in cmd:
                return mock_updates
            else:
                result = mocker.MagicMock()
                result.stdout = ""
                result.failed = False
                result.return_code = 0
                return result

        mock_connection.run.side_effect = side_effect

        return mock_connection

    @pytest.fixture
    def run_side_effect_normal(
        self,
        mock_dnf_output_return,
        mock_dnf_security_output_return,
        mock_dnf_current_versions_return,
    ):
        def _side_effect(cmd, *args, **kwargs):
            if "check-update --security" in cmd:
                return mock_dnf_security_output_return
            elif "check-update" in cmd:
                return mock_dnf_output_return
            elif "list installed" in cmd:
                return mock_dnf_current_versions_return

        return _side_effect

    @pytest.fixture
    def mock_dnf_command_scenario(self, mocker, mock_connection):
        """
        Flexible fixture factory for DNF command output scenarios.
        Returns a function that can create different DNF output setups.
        """

        def create_scenario(
            security_updates="",
            regular_updates="",
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="",
        ):
            # Mock security updates
            mock_security = mocker.MagicMock()
            mock_security.stdout = security_updates
            mock_security.failed = False
            mock_security.return_code = 0

            # Mock regular updates
            mock_updates = mocker.MagicMock()
            mock_updates.stdout = regular_updates
            mock_updates.failed = regular_updates_failed
            mock_updates.return_code = regular_updates_code

            # Mock installed packages
            mock_versions = mocker.MagicMock()
            mock_versions.stdout = installed_packages
            mock_versions.failed = False
            mock_versions.return_code = 0

            def side_effect(cmd, *args, **kwargs):
                if "check-update --security" in cmd:
                    return mock_security
                elif "check-update" in cmd:
                    return mock_updates
                elif "list installed" in cmd:
                    return mock_versions
                else:
                    result = mocker.MagicMock()
                    result.stdout = ""
                    result.failed = False
                    result.return_code = 0
                    return result

            mock_connection.run.side_effect = side_effect
            return mock_connection

        return create_scenario

    @pytest.mark.parametrize(
        "connection_fixture, expected",
        [
            ("mock_connection", True),
            ("mock_connection_failed", False),
        ],
        ids=["success", "failure"],
    )
    def test_reposync(self, request, connection_fixture, expected):
        """
        Test the reposync method of the DNF provider.
        This method is a no-op for Red Hat-based systems, since dnf automatically
        syncs the repositories on update checks.
        """
        mock_connection = request.getfixturevalue(connection_fixture)

        dnf = Dnf()
        result = dnf.reposync(mock_connection)

        mock_connection.run.assert_called_once_with(
            "dnf --quiet -y makecache --refresh", hide=True, warn=True
        )

        assert result is expected

    def test_get_updates(self, mock_connection, run_side_effect_normal):
        """
        Test the get_updates method of the DNF provider.
        The data is provided by the mock_dnf_output fixture.
        """
        dnf = Dnf()

        # Setup connection side effects for normal run
        # See fixture for details
        mock_connection.run.side_effect = run_side_effect_normal

        try:
            updates: list[Update] = dnf.get_updates(mock_connection)
        except DataRefreshError as e:
            pytest.fail(f"DataRefreshError should not be raised, got: {e}")

        # We should have exactly 11 updates (no kernel update in this scenario)
        assert len(updates) == 11

        # Sort found updates by name as to not deal with ordering
        update_by_name = {u.name: u for u in updates}

        # Check some updates for validity
        emacs = update_by_name["emacs-filesystem.noarch"]
        assert emacs.new_version == "1:27.2-13.el9_6"
        assert emacs.current_version == "1:27.1-10.el9_6"
        assert not emacs.security

        openssl = update_by_name["openssl.x86_64"]
        assert openssl.new_version == "3.0.9-16.el9_6"
        assert openssl.current_version == "3.0.9-15.el9_5"
        assert openssl.security

        # Ensure @ prefixed source works
        # It technically isn't present in check-update but apparently CAN be.
        # It should be automatically normalized by the parser.
        curl_minimal = update_by_name["curl-minimal.x86_64"]
        assert curl_minimal.source == "baseos"

        # Ensure versions in Available package block are NOT set as
        # currently installed version
        git = update_by_name["git.x86_64"]
        assert git.current_version != "2.47.1-1.el9_5"

    def test_get_updates_no_updates(self, mock_dnf_output_no_updates):
        """
        Test the get_updates method of the DNF provider when no updates are available.
        """
        dnf = Dnf()

        updates: list[Update] = dnf.get_updates(mock_dnf_output_no_updates)

        assert updates == []

    def test_get_updates_query_failed(self, mock_connection_failed):
        """
        Test the get_updates method of the DNF provider when the query fails.
        """
        dnf = Dnf()

        with pytest.raises(DataRefreshError):
            dnf.get_updates(mock_connection_failed)

    def test_get_updates_invalid_output(self, mock_connection):
        """
        Test the get_updates method of the DNF provider with invalid output.
        Unparsable output in lines should be ignored.
        """
        dnf = Dnf()
        mock_connection.run.return_value.stdout = "Invalid output"

        results = dnf.get_updates(mock_connection)

        assert results == []

    @pytest.mark.parametrize(
        "provider, expected_command",
        [
            (Dnf, "dnf"),
            (Yum, "yum"),
        ],
        ids=["dnf", "yum"],
    )
    def test_compatibility_mode(
        self,
        mock_dnf_output_no_updates,
        provider,
        expected_command,
    ):
        """
        Test the Yum compat mode of the DNF provider.
        This is not a very exhaustive test, but it ensures the command
        being ran is appropriate.

        If the APIs diverge in the future this will need expanded.
        """

        implementation = provider()

        _ = implementation.get_updates(mock_dnf_output_no_updates)

        assert implementation.pkgbin == expected_command

        # Verify that the correct command binary is used in the calls
        calls = mock_dnf_output_no_updates.run.call_args_list
        command_calls = [call[0][0] for call in calls]

        # Should contain the expected command binary in security and regular updates
        assert any(
            f"{expected_command} --quiet -y check-update --security" in cmd
            for cmd in command_calls
        )
        assert any(
            f"{expected_command} --quiet -y check-update" in cmd
            and "--security" not in cmd
            for cmd in command_calls
        )

    def test_get_updates_kernel(self, mock_dnf_command_scenario, caplog):
        """
        Test kernel update scenario: 3 kernels installed, 1 new kernel available.
        This tests the slotted package behavior where kernels are stored as lists
        and the latest installed version is used as the current version reference.
        """
        dnf = Dnf()

        # Setup scenario with new kernel in check-update output + dummy package
        mock_connection = mock_dnf_command_scenario(
            regular_updates="""
            some-package.x86_64  1.2.3-4.el9  updates
            kernel.x86_64  5.14.0-570.19.1.el9_7  updates
            """,
            installed_packages="""
            Installed Packages
            some-package.x86_64  1.2.3-3.el9  @baseos
            kernel.x86_64  5.14.0-502.35.1.el9_5  @baseos
            kernel.x86_64  5.14.0-570.16.1.el9_6  @updates
            kernel.x86_64  5.14.0-570.18.1.el9_6  @updates
            """,
        )

        with caplog.at_level(logging.DEBUG):
            updates = dnf.get_updates(mock_connection)

        # Should have 2 updates total (1 dummy package + 1 kernel)
        assert len(updates) == 2

        # Should have 1 kernel update
        kernel_updates = [u for u in updates if "kernel" in u.name]
        assert len(kernel_updates) == 1

        kernel_update = kernel_updates[0]
        assert kernel_update.name == "kernel.x86_64"
        assert kernel_update.current_version == "5.14.0-570.18.1.el9_6"
        assert kernel_update.new_version == "5.14.0-570.19.1.el9_7"
        assert kernel_update.source == "updates"

    def test_get_updates_kernel_only_no_regular_updates(
        self, mock_dnf_command_scenario, caplog
    ):
        """
        Test that a kernel-only update is correctly detected when no other
        packages have updates available.
        """
        dnf = Dnf()

        mock_connection = mock_dnf_command_scenario(
            regular_updates="kernel.x86_64  5.14.0-570.19.1.el9_7  updates",
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="""
            Installed Packages
            kernel.x86_64  5.14.0-502.35.1.el9_5  @baseos
            kernel.x86_64  5.14.0-570.16.1.el9_6  @updates
            """,
        )

        with caplog.at_level(logging.DEBUG):
            updates = dnf.get_updates(mock_connection)

        # Should have exactly 1 kernel update, no regular updates
        assert len(updates) == 1

        kernel_update = updates[0]
        assert kernel_update.name == "kernel.x86_64"
        assert kernel_update.new_version == "5.14.0-570.19.1.el9_7"
        assert kernel_update.source == "updates"
        assert kernel_update.current_version == "5.14.0-570.16.1.el9_6"

    def test_get_updates_with_package_clobbering(
        self, mock_dnf_command_scenario, caplog
    ):
        """
        Test get_updates when a non-kernel package has multiple installed versions.
        The last (most recent) installed version should be used as current_version.
        """
        dnf = Dnf()

        mock_connection = mock_dnf_command_scenario(
            regular_updates="openssl.x86_64  3.0.9-17.el9_7  updates",
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="""
            Installed Packages
            openssl.x86_64  3.0.9-15.el9_5  @baseos
            openssl.x86_64  3.0.9-16.el9_6  @updates
            kernel.x86_64  5.14.0-502.35.1.el9_5  @baseos
            kernel.x86_64  5.14.0-570.18.1.el9_6  @updates
            """,
        )

        with caplog.at_level(logging.DEBUG):
            updates = dnf.get_updates(mock_connection)

        assert len(updates) == 1
        assert updates[0].name == "openssl.x86_64"
        assert updates[0].current_version == "3.0.9-16.el9_6"
        assert updates[0].new_version == "3.0.9-17.el9_7"
        assert (
            "Clobbering 3.0.9-15.el9_5 with 3.0.9-16.el9_6 for package openssl.x86_64"
            in caplog.text
        )

    def test_get_updates_with_unknown_installed_source(
        self, mock_dnf_command_scenario, caplog
    ):
        """
        Test get_updates when list installed reports source metadata as <unknown>.
        Package name and version should still be parsed into current_version.
        """
        dnf = Dnf()

        mock_connection = mock_dnf_command_scenario(
            regular_updates="fuse-common.x86_64  3.16.2-6.fc42  updates",
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="""
            Installed Packages
            fuse-common.x86_64  3.16.2-5.fc42  <unknown>
            """,
        )

        with caplog.at_level(logging.DEBUG):
            updates = dnf.get_updates(mock_connection)

        assert len(updates) == 1
        assert updates[0].name == "fuse-common.x86_64"
        assert updates[0].current_version == "3.16.2-5.fc42"
        assert updates[0].new_version == "3.16.2-6.fc42"
        assert updates[0].source == "updates"

    def test_get_updates_snapshot_caret_version(
        self, mock_dnf_command_scenario, caplog
    ):
        """
        Regression: Parser should handle post-release snapshot versions

        Test that packages carrying an RPM post-release snapshot version (the
        "^" separator, e.g. "0^20260611.ga9c61ff-1.fc44") are parsed instead
        of being discarded as garbage. These are common on Fedora packages
        built from a git checkout.
        """
        dnf = Dnf()

        mock_connection = mock_dnf_command_scenario(
            regular_updates="""
            cockpit.x86_64         0^20260611.ga9c61ff-1.fc44       updates
            usbmuxd.x86_64         1.1.1^20251205git3ded00c-1.fc44  updates
            """,
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="""
            Installed Packages
            cockpit.x86_64         0^20260601.gdeadbee-1.fc44       @updates
            usbmuxd.x86_64         1.1.1^20251101gitcafef00-1.fc44  @updates
            """,
        )

        with caplog.at_level(logging.DEBUG):
            updates = dnf.get_updates(mock_connection)

        assert len(updates) == 2

        update_by_name = {u.name: u for u in updates}

        cockpit = update_by_name["cockpit.x86_64"]
        assert cockpit.new_version == "0^20260611.ga9c61ff-1.fc44"
        assert cockpit.current_version == "0^20260601.gdeadbee-1.fc44"

        usbmuxd = update_by_name["usbmuxd.x86_64"]
        assert usbmuxd.new_version == "1.1.1^20251205git3ded00c-1.fc44"
        assert usbmuxd.current_version == "1.1.1^20251101gitcafef00-1.fc44"

        # None of these should have been logged as unparsable garbage
        assert "garbage line" not in caplog.text.casefold()

    def test_get_updates_security_annotation_lines_skipped(
        self, mock_dnf_command_scenario, caplog
    ):
        """
        Test that 'Security:' annotation lines emitted by newer dnf versions
        are skipped and do not produce spurious Update objects with name
        'Security:'.
        """
        dnf = Dnf()

        mock_connection = mock_dnf_command_scenario(
            regular_updates="""
            rsync.x86_64  3.2.5-3.el9_7.2  baseos
            Security: kernel-core-5.14.0-611.41.1.el9_7.x86_64 is an installed security update
            Security: kernel-core-5.14.0-611.36.1.el9_7.x86_64 is the currently running version
            """,
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="""
            Installed Packages
            rsync.x86_64  3.2.5-2.el9_6  @baseos
            kernel.x86_64  5.14.0-611.36.1.el9_7  @baseos
            """,
        )

        with caplog.at_level(logging.DEBUG):
            updates = dnf.get_updates(mock_connection)

        # Only rsync should be in results; Security: lines must be skipped
        assert len(updates) == 1
        assert updates[0].name == "rsync.x86_64"
        assert updates[0].new_version == "3.2.5-3.el9_7.2"

        # Verify Security: lines were logged as skipped
        assert any(
            "security annotation" in r.message.casefold() for r in caplog.records
        )

    def test_get_updates_no_parsable_rows(self, mock_dnf_command_scenario, caplog):
        """
        Test that a check-update result with exit code 100 but no parsable
        package rows emits a warning and returns early without querying
        installed packages.

        We mostly fill the mock with the kind of extraneous output we skip over.
        """
        dnf = Dnf()

        mock_connection = mock_dnf_command_scenario(
            regular_updates="""
            Security: kernel-core-5.14.0-611.41.1.el9_7.x86_64 is an installed security update
            Security: kernel-core-5.14.0-611.36.1.el9_7.x86_64 is the currently running version
            Obsoleting Packages
            """,
            regular_updates_failed=True,
            regular_updates_code=100,
            installed_packages="""
            Installed Packages
            kernel.x86_64  5.14.0-611.36.1.el9_7  @baseos
            """,
        )

        with caplog.at_level(logging.WARNING):
            updates = dnf.get_updates(mock_connection)

        assert updates == []
        assert "reported updates, but none were extracted" in caplog.text.casefold()

        # Ensure we do not perform the expensive installed packages query
        command_calls = [call[0][0] for call in mock_connection.run.call_args_list]
        assert not any("list installed" in cmd for cmd in command_calls)

    @pytest.mark.parametrize(
        "provider_cls, expected_cmd",
        [(Dnf, "dnf needs-restarting -r"), (Yum, "needs-restarting -r")],
        ids=["dnf", "yum"],
    )
    @pytest.mark.parametrize(
        "return_code, stdout, expected",
        [
            (
                0,
                "No core libraries or services have been updated since boot-up.",
                False,
            ),
            (
                1,
                "Core libraries or services have been updated since boot-up:\n"
                "  * kernel-core\n"
                "Reboot is required to fully utilize these updates.",
                True,
            ),
        ],
        ids=["no-reboot", "reboot-required"],
    )
    def test_get_reboot_status(
        self,
        mock_connection,
        provider_cls,
        expected_cmd,
        return_code,
        stdout,
        expected,
    ):
        """
        Test the reboot status detection across backends.

        A reboot is reported only when the exit code is 1 AND the output
        carries the positive 'Reboot is required' signal (identical on dnf4,
        dnf5 and yum); exit 0 means no reboot. The command differs by backend
        (the dnf subcommand vs the standalone needs-restarting for yum).
        """
        provider = provider_cls()
        mock_connection.run.return_value.return_code = return_code
        mock_connection.run.return_value.stdout = stdout
        mock_connection.run.return_value.stderr = ""

        assert provider.get_reboot_status(mock_connection) is expected
        mock_connection.run.assert_called_with(expected_cmd, hide=True, warn=True)

    @pytest.mark.parametrize(
        "stderr",
        [
            "No such command: needs-restarting. Please do the needful etc",
            "Error: rpmdb open failed",
        ],
        ids=["dnf4-missing-plugin", "generic-error"],
    )
    def test_get_reboot_status_exit1_without_signal_unknown(
        self, mock_connection, caplog, stderr
    ):
        """
        Exit 1 alone is not trusted as a reboot. dnf overloads exit 1 for its
        own errors (notably a missing plugin on dnf4), so without the positive
        'Reboot is required' signal in the output the result degrades to
        unknown rather than a false 'reboot pending'.
        """
        dnf = Dnf()
        mock_connection.run.return_value.return_code = 1
        mock_connection.run.return_value.stdout = ""
        mock_connection.run.return_value.stderr = stderr

        with caplog.at_level(logging.WARNING):
            assert dnf.get_reboot_status(mock_connection) is None

        assert "Could not determine reboot status" in caplog.text

    def test_get_reboot_status_dnf5_missing_subcommand(self, mock_connection, caplog):
        """
        Test that dnf5 with missing needs-restarting subcommand degrades to unknown
        and logs the helpful stderr message.
        """
        dnf = Dnf()
        mock_connection.run.return_value.return_code = 2
        mock_connection.run.return_value.stderr = (
            'Unknown argument "needs-restarting" for command "dnf5". '
            "Do the needful etc."
        )

        with caplog.at_level(logging.WARNING):
            assert dnf.get_reboot_status(mock_connection) is None

        assert "exit 2" in caplog.text

    def test_get_reboot_status_yum_missing_package_warns(self, mock_connection, caplog):
        """
        Test that yum with missing needs-restarting binary degrades to unknown
        and logs a warning pointing the user at yum-utils.
        """
        yum = Yum()
        mock_connection.run.return_value.return_code = 127
        mock_connection.run.return_value.stderr = "needs-restarting: command not found"

        with caplog.at_level(logging.WARNING):
            assert yum.get_reboot_status(mock_connection) is None

        assert "yum-utils" in caplog.text

    def test_get_reboot_status_unexpected_failure_warns(self, mock_connection, caplog):
        """
        Any other non-zero exit code degrades to unknown and logs the exit
        code and stderr for diagnosis.
        """
        dnf = Dnf()
        mock_connection.run.return_value.return_code = 5
        mock_connection.run.return_value.stderr = "oh no THE BEANS HAVE SPILLED!"

        with caplog.at_level(logging.WARNING):
            assert dnf.get_reboot_status(mock_connection) is None

        assert "exit 5" in caplog.text
        assert "oh no THE BEANS HAVE SPILLED!" in caplog.text
