import atexit
import contextlib
import logging
import os
import pathlib
import pprint
import re
import shutil
import tarfile
import textwrap
import time
from typing import TYPE_CHECKING, Any, Dict, List
from zipfile import ZipFile

import attr
import distro
import packaging
import psutil
import pytest
import requests
from pytestshellutils.shell import DaemonImpl, Subprocess
from pytestshellutils.utils.processes import (
    ProcessResult,
    _get_cmdline,
    terminate_process,
)
from pytestskipmarkers.utils import platform
from saltfactories.bases import SystemdSaltDaemonImpl
from saltfactories.cli import call, key, salt
from saltfactories.daemons import api, master, minion
from saltfactories.utils import cli_scripts

try:
    import crypt

    HAS_CRYPT = True
except ImportError:
    HAS_CRYPT = False
try:
    import pwd

    HAS_PWD = True
except ImportError:
    HAS_PWD = False

try:
    import winreg

    HAS_WINREG = True
except ImportError:
    HAS_WINREG = False

TESTS_DIR = pathlib.Path(__file__).resolve().parent.parent
CODE_DIR = TESTS_DIR.parent
ARTIFACTS_DIR = CODE_DIR / "artifacts"

log = logging.getLogger(__name__)


@attr.s(kw_only=True, slots=True)
class SaltPkgInstall:
    conf_dir: pathlib.Path = attr.ib()
    system_service: bool = attr.ib(default=False)
    proc: Subprocess = attr.ib(init=False, repr=False)
    pkgs: List[str] = attr.ib(factory=list)
    onedir: bool = attr.ib(default=False)
    singlebin: bool = attr.ib(default=False)
    compressed: bool = attr.ib(default=False)
    hashes: Dict[str, Dict[str, Any]] = attr.ib(repr=False)
    root: pathlib.Path = attr.ib(default=None)
    run_root: pathlib.Path = attr.ib(default=None)
    ssm_bin: pathlib.Path = attr.ib(default=None)
    bin_dir: pathlib.Path = attr.ib(default=None)
    # The artifact is an installer (exe, msi, pkg, rpm, deb)
    installer_pkg: bool = attr.ib(default=False)
    upgrade: bool = attr.ib(default=False)
    # install salt or not. This allows someone
    # to test a currently installed version of salt
    no_install: bool = attr.ib(default=False)
    no_uninstall: bool = attr.ib(default=False)

    distro_id: str = attr.ib(init=False)
    distro_codename: str = attr.ib(init=False)
    distro_name: str = attr.ib(init=False)
    distro_version: str = attr.ib(init=False)
    pkg_mngr: str = attr.ib(init=False)
    rm_pkg: str = attr.ib(init=False)
    salt_pkgs: List[str] = attr.ib(init=False)
    install_dir: pathlib.Path = attr.ib(init=False)
    binary_paths: Dict[str, List[pathlib.Path]] = attr.ib(init=False)
    classic: bool = attr.ib(default=False)
    prev_version: str = attr.ib()
    pkg_version: str = attr.ib(default="1")
    repo_data: str = attr.ib(init=False, repr=False)
    major: str = attr.ib(init=False)
    minor: str = attr.ib(init=False)
    relenv: bool = attr.ib(default=True)
    file_ext: bool = attr.ib(default=None)
    config_path: str = attr.ib(init=False)

    @proc.default
    def _default_proc(self):
        return Subprocess()

    @hashes.default
    def _default_hashes(self):
        return {
            "BLAKE2B": {"file": None, "tool": "-blake2b512"},
            "SHA3_512": {"file": None, "tool": "-sha3-512"},
            "SHA512": {"file": None, "tool": "-sha512"},
        }

    @distro_id.default
    def _default_distro_id(self):
        return distro.id().lower()

    @distro_codename.default
    def _default_distro_codename(self):
        return distro.codename().lower()

    @distro_name.default
    def _default_distro_name(self):
        if distro.name():
            return distro.name().split()[0].lower()

    @distro_version.default
    def _default_distro_version(self):
        return distro.version().lower()

    @pkg_mngr.default
    def _default_pkg_mngr(self):
        if self.distro_id in ("centos", "redhat", "amzn", "fedora", "photon"):
            return "yum"
        elif self.distro_id in ("ubuntu", "debian"):
            ret = self.proc.run("apt-get", "update")
            self._check_retcode(ret)
            return "apt-get"

    @rm_pkg.default
    def _default_rm_pkg(self):
        if self.distro_id in ("centos", "redhat", "amzn", "fedora", "photon"):
            return "remove"
        elif self.distro_id in ("ubuntu", "debian"):
            return "purge"

    @salt_pkgs.default
    def _default_salt_pkgs(self):
        salt_pkgs = [
            "salt-api",
            "salt-syndic",
            "salt-ssh",
            "salt-master",
            "salt-cloud",
            "salt-minion",
        ]
        if self.distro_id in ("centos", "redhat", "amzn", "fedora", "photon"):
            salt_pkgs.append("salt")
        elif self.distro_id in ("ubuntu", "debian"):
            salt_pkgs.append("salt-common")
        return salt_pkgs

    @install_dir.default
    def _default_install_dir(self):
        if platform.is_windows():
            install_dir = pathlib.Path(
                os.getenv("ProgramFiles"), "Salt Project", "Salt"
            ).resolve()
        elif platform.is_darwin():
            # TODO: Add mac install dir path
            install_dir = pathlib.Path("/opt", "salt")
        else:
            install_dir = pathlib.Path("/opt", "saltstack", "salt")
        return install_dir

    @config_path.default
    def _default_config_path(self):
        """
        Default location for salt configurations
        """
        if platform.is_windows():
            config_path = pathlib.Path("C://salt", "etc", "salt")
        else:
            config_path = pathlib.Path("/etc", "salt")
        return config_path

    @repo_data.default
    def _default_repo_data(self):
        """
        Query to see the published Salt artifacts
        from repo.json
        """
        url = "https://repo.saltproject.io/salt/onedir/repo.json"
        ret = requests.get(url)
        data = ret.json()
        return data

    def check_relenv(self, version):
        """
        Detects if we are using relenv
        onedir build
        """
        relenv = False
        if packaging.version.parse(version) >= packaging.version.parse("3006.0"):
            relenv = True
        return relenv

    def update_process_path(self):
        # The installer updates the path for the system, but that doesn't
        # make it to this python session, so we need to update that
        os.environ["PATH"] = ";".join([str(self.install_dir), os.getenv("path")])

        # When the MSI installer is run from self.proc.run, it doesn't update
        # the registry. When run from a normal command prompt it does. Until we
        # figure that out, we will update the process path as above. This
        # doesn't really check that the path is being set though... but I see
        # no other way around this
        # if HAS_WINREG:
        #     log.debug("Refreshing the path")
        #     # Get the updated system path from the registry
        #     path_key = winreg.OpenKeyEx(
        #         winreg.HKEY_LOCAL_MACHINE,
        #         r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
        #     )
        #     current_path = winreg.QueryValueEx(path_key, "path")[0]
        #     path_key.Close()
        #     # Update the path for the current running process
        #     os.environ["PATH"] = current_path

    def get_version(self, version_only=False):
        """
        Return the version information
        needed to install a previous version
        of Salt.
        """
        prev_version = self.prev_version
        pkg_version = None
        if not self.upgrade:
            # working with local artifact
            version = ""
            for artifact in ARTIFACTS_DIR.glob("**/*.*"):
                version = re.search(
                    r"([0-9].*)(\-[0-9].fc|\-[0-9].el|\+ds|\_all|\_any|\_amd64|\_arm64|\-[0-9].am|(\-[0-9]-[a-z]*-[a-z]*[0-9_]*.|\-[0-9]*.*)(tar.gz|tar.xz|zip|exe|msi|pkg|rpm|deb))",
                    artifact.name,
                )
                if version:
                    version = version.groups()[0].replace("_", "-").replace("~", "")
                    version = version.split("-")[0]
                    # TODO: Remove this clause.  This is to handle a versioning difficulty between pre-3006
                    # dev versions and older salt versions on deb-based distros
                    if version.startswith("1:"):
                        version = version[2:]
                    break
            major, minor = version.split(".", 1)
        else:
            if not prev_version:
                # We did not pass in a version, lets detect the latest
                # version information of a Salt artifact.
                latest = list(self.repo_data["latest"].keys())[0]
                version = self.repo_data["latest"][latest]["version"]
                if "-" in version:
                    prev_version, pkg_version = version.split("-")
                else:
                    prev_version, pkg_version = version, None
            else:
                # We passed in a version, but lets check if the pkg_version
                # is defined. Relenv pkgs do not define a pkg build number
                if "-" not in prev_version and not self.check_relenv(
                    version=prev_version
                ):
                    pkg_numbers = [
                        x for x in self.repo_data.keys() if prev_version in x
                    ]
                    pkg_version = 1
                    for number in pkg_numbers:
                        number = int(number.split("-")[1])
                        if number > pkg_version:
                            pkg_version = number
            major, minor = prev_version.split(".")
        if version_only:
            return version
        return major, minor, prev_version, pkg_version

    def __attrs_post_init__(self):
        self.major, self.minor, self.prev_version, self.pkg_version = self.get_version()
        self.relenv = self.check_relenv(self.major)
        file_ext_re = r"tar\.gz"
        if platform.is_darwin():
            file_ext_re = r"tar\.gz|pkg"
        if platform.is_windows():
            file_ext_re = "zip|exe|msi"
        for f_path in ARTIFACTS_DIR.glob("**/*.*"):
            f_path = str(f_path)
            if re.search(f"salt-(.*).({file_ext_re})$", f_path, re.IGNORECASE):
                # Compressed can be zip, tar.gz, exe, or pkg. All others are
                # deb and rpm
                self.compressed = True
                self.file_ext = os.path.splitext(f_path)[1].strip(".")
                if self.file_ext == "gz":
                    if f_path.endswith("tar.gz"):
                        self.file_ext = "tar.gz"
                self.pkgs.append(f_path)
                if platform.is_windows():
                    self.root = pathlib.Path(os.getenv("LocalAppData")).resolve()
                    if self.file_ext == "zip":
                        with ZipFile(f_path, "r") as zip:
                            first = zip.infolist()[0]
                            if first.filename == "salt/ssm.exe":
                                self.onedir = True
                                self.bin_dir = self.root / "salt" / "salt"
                                self.run_root = self.bin_dir / "salt.exe"
                                self.ssm_bin = self.root / "salt" / "ssm.exe"
                            elif first.filename == "salt.exe":
                                self.singlebin = True
                                self.run_root = self.root / "salt.exe"
                                self.ssm_bin = self.root / "ssm.exe"
                            else:
                                log.error(
                                    "Unexpected archive layout. First: %s",
                                    first.filename,
                                )
                    elif self.file_ext in ["exe", "msi"]:
                        self.compressed = False
                        self.onedir = True
                        self.installer_pkg = True
                        self.root = self.install_dir.parent
                        self.bin_dir = self.install_dir
                        self.ssm_bin = self.install_dir / "ssm.exe"
                    else:
                        log.error("Unexpected file extension: %s", self.file_ext)
                else:
                    if platform.is_darwin():
                        self.root = pathlib.Path(os.sep, "opt")
                    else:
                        self.root = pathlib.Path(os.sep, "usr", "local", "bin")

                    if self.file_ext == "pkg":
                        self.compressed = False
                        self.onedir = True
                        self.installer_pkg = True
                        self.bin_dir = self.root / "salt" / "bin"
                        self.run_root = self.bin_dir / "run"
                    elif self.file_ext == "tar.gz":
                        with tarfile.open(f_path) as tar:
                            # The first item will be called salt
                            first = next(iter(tar.getmembers()))
                            if first.name == "salt" and first.isdir():
                                self.onedir = True
                                self.bin_dir = self.root / "salt" / "run"
                                self.run_root = self.bin_dir / "run"
                            elif first.name == "salt" and first.isfile():
                                self.singlebin = True
                                self.run_root = self.root / "salt"
                            else:
                                log.error(
                                    "Unexpected archive layout. First: %s (isdir: %s, isfile: %s)",
                                    first.name,
                                    first.isdir(),
                                    first.isfile(),
                                )
                    else:
                        log.error("Unexpected file extension: %s", self.file_ext)

            if re.search(
                r"salt(.*)(x86_64|all|amd64|aarch64|arm64)\.(rpm|deb)$", f_path
            ):
                self.installer_pkg = True
                self.pkgs.append(f_path)

        if not self.pkgs:
            pytest.fail("Could not find Salt Artifacts")

        python_bin = self.install_dir / "bin" / "python3"
        if platform.is_windows():
            python_bin = self.install_dir / "Scripts" / "python.exe"
        if not self.compressed:
            if platform.is_windows():
                self.binary_paths = {
                    "call": ["salt-call.exe"],
                    "cp": ["salt-cp.exe"],
                    "minion": ["salt-minion.exe"],
                    "pip": ["salt-pip.exe"],
                    "python": [python_bin],
                }
            else:
                if os.path.exists(self.install_dir / "bin" / "salt"):
                    install_dir = self.install_dir / "bin"
                else:
                    install_dir = self.install_dir
                self.binary_paths = {
                    "salt": [install_dir / "salt"],
                    "api": [install_dir / "salt-api"],
                    "call": [install_dir / "salt-call"],
                    "cloud": [install_dir / "salt-cloud"],
                    "cp": [install_dir / "salt-cp"],
                    "key": [install_dir / "salt-key"],
                    "master": [install_dir / "salt-master"],
                    "minion": [install_dir / "salt-minion"],
                    "proxy": [install_dir / "salt-proxy"],
                    "run": [install_dir / "salt-run"],
                    "ssh": [install_dir / "salt-ssh"],
                    "syndic": [install_dir / "salt-syndic"],
                    "spm": [install_dir / "spm"],
                    "pip": [install_dir / "salt-pip"],
                    "python": [python_bin],
                }
        else:
            if self.run_root and os.path.exists(self.run_root):
                if platform.is_windows():
                    self.binary_paths = {
                        "call": [str(self.run_root), "call"],
                        "cp": [str(self.run_root), "cp"],
                        "minion": [str(self.run_root), "minion"],
                        "pip": [str(self.run_root), "pip"],
                        "python": [python_bin],
                    }
                else:
                    self.binary_paths = {
                        "salt": [str(self.run_root)],
                        "api": [str(self.run_root), "api"],
                        "call": [str(self.run_root), "call"],
                        "cloud": [str(self.run_root), "cloud"],
                        "cp": [str(self.run_root), "cp"],
                        "key": [str(self.run_root), "key"],
                        "master": [str(self.run_root), "master"],
                        "minion": [str(self.run_root), "minion"],
                        "proxy": [str(self.run_root), "proxy"],
                        "run": [str(self.run_root), "run"],
                        "ssh": [str(self.run_root), "ssh"],
                        "syndic": [str(self.run_root), "syndic"],
                        "spm": [str(self.run_root), "spm"],
                        "pip": [str(self.run_root), "pip"],
                        "python": [python_bin],
                    }
            else:
                if platform.is_windows():
                    self.binary_paths = {
                        "call": [self.install_dir / "salt-call.exe"],
                        "cp": [self.install_dir / "salt-cp.exe"],
                        "minion": [self.install_dir / "salt-minion.exe"],
                        "pip": [self.install_dir / "salt-pip.exe"],
                        "python": [python_bin],
                    }
                else:
                    self.binary_paths = {
                        "salt": [self.install_dir / "salt"],
                        "api": [self.install_dir / "salt-api"],
                        "call": [self.install_dir / "salt-call"],
                        "cloud": [self.install_dir / "salt-cloud"],
                        "cp": [self.install_dir / "salt-cp"],
                        "key": [self.install_dir / "salt-key"],
                        "master": [self.install_dir / "salt-master"],
                        "minion": [self.install_dir / "salt-minion"],
                        "proxy": [self.install_dir / "salt-proxy"],
                        "run": [self.install_dir / "salt-run"],
                        "ssh": [self.install_dir / "salt-ssh"],
                        "syndic": [self.install_dir / "salt-syndic"],
                        "spm": [self.install_dir / "spm"],
                        "pip": [self.install_dir / "salt-pip"],
                        "python": [python_bin],
                    }

    @staticmethod
    def salt_factories_root_dir(system_service: bool = False) -> pathlib.Path:
        if system_service is False:
            return None
        if platform.is_windows():
            return pathlib.Path("C:/salt")
        if platform.is_darwin():
            return pathlib.Path("/opt/salt")
        return pathlib.Path("/")

    def _check_retcode(self, ret):
        """
        helper function ot check subprocess.run
        returncode equals 0, if not raise assertionerror
        """
        if ret.returncode != 0:
            log.error(ret)
        assert ret.returncode == 0
        return True

    @property
    def salt_hashes(self):
        for _hash in self.hashes.keys():
            for fpath in ARTIFACTS_DIR.glob(f"**/*{_hash}*"):
                fpath = str(fpath)
                if re.search(f"{_hash}", fpath):
                    self.hashes[_hash]["file"] = fpath

        return self.hashes

    def _install_ssm_service(self):
        # Register the services
        # run_root and ssm_bin are configured in helper.py to point to the
        # correct binary location
        log.debug("Installing master service")
        ret = self.proc.run(
            str(self.ssm_bin),
            "install",
            "salt-master",
            str(self.run_root),
            "master",
            "-c",
            str(self.conf_dir),
        )
        self._check_retcode(ret)
        log.debug("Installing minion service")
        ret = self.proc.run(
            str(self.ssm_bin),
            "install",
            "salt-minion",
            str(self.run_root),
            "minion",
            "-c",
            str(self.conf_dir),
        )
        self._check_retcode(ret)
        log.debug("Installing api service")
        ret = self.proc.run(
            str(self.ssm_bin),
            "install",
            "salt-api",
            str(self.run_root),
            "api",
            "-c",
            str(self.conf_dir),
        )
        self._check_retcode(ret)

    def _install_compressed(self, upgrade=False):
        pkg = self.pkgs[0]
        log.info("Installing %s", pkg)
        if platform.is_windows():
            if pkg.endswith("zip"):
                # Extract the files
                log.debug("Extracting zip file")
                with ZipFile(pkg, "r") as zip:
                    zip.extractall(path=self.root)
            elif pkg.endswith("exe") or pkg.endswith("msi"):
                log.error("Not a compressed package type: %s", pkg)
            else:
                log.error("Unknown package type: %s", pkg)
            if self.system_service:
                self._install_ssm_service()
        elif platform.is_darwin():
            log.debug("Extracting tarball into %s", self.root)
            with tarfile.open(pkg) as tar:  # , "r:gz")
                tar.extractall(path=str(self.root))
        else:
            log.debug("Extracting tarball into %s", self.root)
            with tarfile.open(pkg) as tar:  # , "r:gz")
                tar.extractall(path=str(self.root))

    def _install_pkgs(self, upgrade=False):
        pkg = self.pkgs[0]
        if platform.is_windows():
            if upgrade:
                self.root = self.install_dir.parent
                self.bin_dir = self.install_dir
                self.ssm_bin = self.install_dir / "ssm.exe"
            if pkg.endswith("exe"):
                # Install the package
                log.debug("Installing: %s", str(pkg))
                ret = self.proc.run(str(pkg), "/start-minion=0", "/S")
                self._check_retcode(ret)
            elif pkg.endswith("msi"):
                # Install the package
                log.debug("Installing: %s", str(pkg))
                # Write a batch file to run the installer. It is impossible to
                # perform escaping of the START_MINION property that the MSI
                # expects unless we do it via a batch file
                batch_file = pathlib.Path(pkg).parent / "install_msi.cmd"
                batch_content = f'msiexec /qn /i "{str(pkg)}" START_MINION=""\n'
                with open(batch_file, "w") as fp:
                    fp.write(batch_content)
                # Now run the batch file
                ret = self.proc.run("cmd.exe", "/c", str(batch_file))
                self._check_retcode(ret)
            else:
                log.error("Invalid package: %s", pkg)
                return False

            # Remove the service installed by the installer
            log.debug("Removing installed salt-minion service")
            self.proc.run(str(self.ssm_bin), "remove", "salt-minion", "confirm")
            self.update_process_path()

        elif platform.is_darwin():
            daemons_dir = pathlib.Path(os.sep, "Library", "LaunchDaemons")
            service_name = "com.saltstack.salt.minion"
            plist_file = daemons_dir / f"{service_name}.plist"
            log.debug("Installing: %s", str(pkg))
            ret = self.proc.run("installer", "-pkg", str(pkg), "-target", "/")
            self._check_retcode(ret)
            # Stop the service installed by the installer
            self.proc.run("launchctl", "disable", f"system/{service_name}")
            self.proc.run("launchctl", "bootout", "system", str(plist_file))
        elif upgrade:
            env = os.environ.copy()
            extra_args = []
            if self.distro_id in ("ubuntu", "debian"):
                env["DEBIAN_FRONTEND"] = "noninteractive"
                extra_args = [
                    "-o",
                    "DPkg::Options::=--force-confdef",
                    "-o",
                    "DPkg::Options::=--force-confold",
                ]
            log.info("Installing packages:\n%s", pprint.pformat(self.pkgs))
            args = extra_args + self.pkgs
            ret = self.proc.run(
                self.pkg_mngr,
                "upgrade",
                "-y",
                *args,
                _timeout=120,
                env=env,
            )
        else:
            log.info("Installing packages:\n%s", pprint.pformat(self.pkgs))
            ret = self.proc.run(self.pkg_mngr, "install", "-y", *self.pkgs)
        if not platform.is_darwin() and not platform.is_windows():
            # Make sure we don't have any trailing references to old package file locations
            ret.returncode == 0
            assert "/saltstack/salt/run" not in ret.stdout
        log.info(ret)
        self._check_retcode(ret)

    def install(self, upgrade=False):
        if self.compressed:
            self._install_compressed(upgrade=upgrade)
        else:
            self._install_pkgs(upgrade=upgrade)
            if self.distro_id in ("ubuntu", "debian"):
                self.stop_services()

    def stop_services(self):
        """
        Debian distros automatically start the services
        We want to ensure our tests start with the config
        settings we have set. This will also verify the expected
        services are up and running.
        """
        for service in ["salt-syndic", "salt-master", "salt-minion"]:
            check_run = self.proc.run("systemctl", "status", service)
            if check_run.returncode != 0:
                # The system was not started automatically and we
                # are expecting it to be on install
                log.debug("The service %s was not started on install.", service)
                return False
            stop_service = self.proc.run("systemctl", "stop", service)
            self._check_retcode(stop_service)
        return True

    def install_previous(self):
        """
        Install previous version. This is used for
        upgrade tests.
        """
        major_ver = self.major
        minor_ver = self.minor
        pkg_version = self.pkg_version
        full_version = f"{self.major}.{self.minor}-{pkg_version}"

        min_ver = f"{major_ver}"
        distro_name = self.distro_name
        if distro_name == "centos" or distro_name == "fedora":
            distro_name = "redhat"
        root_url = "salt/py3/"
        if self.classic:
            root_url = "py3/"

        if self.distro_name in ["redhat", "centos", "amazon", "fedora", "vmware"]:
            for fp in pathlib.Path("/etc", "yum.repos.d").glob("epel*"):
                fp.unlink()
            gpg_key = "SALTSTACK-GPG-KEY.pub"
            if self.distro_version == "9":
                gpg_key = "SALTSTACK-GPG-KEY2.pub"
            if platform.is_aarch64():
                arch = "aarch64"
            else:
                arch = "x86_64"
            ret = self.proc.run(
                "rpm",
                "--import",
                f"https://repo.saltproject.io/{root_url}{distro_name}/{self.distro_version}/{arch}/{major_ver}/{gpg_key}",
            )
            self._check_retcode(ret)
            download_file(
                f"https://repo.saltproject.io/{root_url}{distro_name}/{self.distro_version}/{arch}/{major_ver}.repo",
                f"/etc/yum.repos.d/salt-{distro_name}.repo",
            )
            ret = self.proc.run(self.pkg_mngr, "clean", "expire-cache")
            self._check_retcode(ret)
            ret = self.proc.run(
                self.pkg_mngr,
                "install",
                *self.salt_pkgs,
                "-y",
            )
            self._check_retcode(ret)

        elif distro_name in ["debian", "ubuntu"]:
            ret = self.proc.run(self.pkg_mngr, "install", "curl", "-y")
            self._check_retcode(ret)
            ret = self.proc.run(self.pkg_mngr, "install", "apt-transport-https", "-y")
            self._check_retcode(ret)
            ## only classic 3005 has arm64 support
            if self.major >= "3006" and platform.is_aarch64():
                arch = "arm64"
            elif platform.is_aarch64() and self.classic:
                arch = "arm64"
            else:
                arch = "amd64"
            pathlib.Path("/etc/apt/keyrings").mkdir(parents=True, exist_ok=True)
            download_file(
                f"https://repo.saltproject.io/{root_url}{distro_name}/{self.distro_version}/{arch}/{major_ver}/salt-archive-keyring.gpg",
                "/etc/apt/keyrings/salt-archive-keyring.gpg",
            )
            with open(
                pathlib.Path("/etc", "apt", "sources.list.d", "salt.list"), "w"
            ) as fp:
                fp.write(
                    f"deb [signed-by=/etc/apt/keyrings/salt-archive-keyring.gpg arch={arch}] "
                    f"https://repo.saltproject.io/{root_url}{distro_name}/{self.distro_version}/{arch}/{major_ver} {self.distro_codename} main"
                )
            ret = self.proc.run(self.pkg_mngr, "update")
            self._check_retcode(ret)
            ret = self.proc.run(
                self.pkg_mngr,
                "install",
                *self.salt_pkgs,
                "-y",
            )
            self._check_retcode(ret)
            self.stop_services()
        elif platform.is_windows():
            self.onedir = True
            self.installer_pkg = True
            self.bin_dir = self.install_dir / "bin"
            self.run_root = self.bin_dir / f"salt.exe"
            self.ssm_bin = self.bin_dir / "ssm.exe"
            if self.file_ext == "msi":
                self.ssm_bin = self.install_dir / "ssm.exe"

            if not self.classic:
                win_pkg = f"salt-{full_version}-windows-amd64.{self.file_ext}"
                win_pkg_url = f"https://repo.saltproject.io/salt/py3/windows/{full_version}/{win_pkg}"
            else:
                if self.file_ext == "msi":
                    win_pkg = f"Salt-Minion-{min_ver}-1-Py3-AMD64.{self.file_ext}"
                elif self.file_ext == "exe":
                    win_pkg = f"Salt-Minion-{min_ver}-1-Py3-AMD64-Setup.{self.file_ext}"
                win_pkg_url = f"https://repo.saltproject.io/windows/{win_pkg}"
            pkg_path = pathlib.Path(r"C:\TEMP", win_pkg)
            pkg_path.parent.mkdir(exist_ok=True)
            ret = requests.get(win_pkg_url)

            with open(pkg_path, "wb") as fp:
                fp.write(ret.content)
            if self.file_ext == "msi":
                # Write a batch file to run the installer. It is impossible to
                # perform escaping of the START_MINION property that the MSI
                # expects unless we do it via a batch file
                batch_file = pkg_path.parent / "install_msi.cmd"
                batch_content = f'msiexec /qn /i {str(pkg_path)} START_MINION=""'
                with open(batch_file, "w") as fp:
                    fp.write(batch_content)
                # Now run the batch file
                ret = self.proc.run("cmd.exe", "/c", str(batch_file))
                self._check_retcode(ret)
            else:
                ret = self.proc.run(pkg_path, "/start-minion=0", "/S")
                self._check_retcode(ret)

            log.debug("Removing installed salt-minion service")
            ret = self.proc.run(str(self.ssm_bin), "remove", "salt-minion", "confirm")
            self._check_retcode(ret)

            if self.system_service:
                self._install_system_service()

        elif platform.is_darwin():
            if self.classic:
                mac_pkg = f"salt-{min_ver}.{minor_ver}-1-py3-x86_64.pkg"
                mac_pkg_url = f"https://repo.saltproject.io/osx/{mac_pkg}"
            else:
                mac_pkg = f"salt-{min_ver}.{minor_ver}-1-macos-x86_64.pkg"
                mac_pkg_url = f"https://repo.saltproject.io/salt/py3/macos/{major_ver}.{minor_ver}-1/{mac_pkg}"
            mac_pkg_path = f"/tmp/{mac_pkg}"
            if not os.path.exists(mac_pkg_path):
                download_file(
                    f"{mac_pkg_url}",
                    f"/tmp/{mac_pkg}",
                )

            ret = self.proc.run("installer", "-pkg", mac_pkg_path, "-target", "/")
            self._check_retcode(ret)

    def _uninstall_compressed(self):
        if platform.is_windows():
            if self.system_service:
                # Uninstall the services
                log.debug("Uninstalling master service")
                self.proc.run(str(self.ssm_bin), "stop", "salt-master")
                self.proc.run(str(self.ssm_bin), "remove", "salt-master", "confirm")
                log.debug("Uninstalling minion service")
                self.proc.run(str(self.ssm_bin), "stop", "salt-minion")
                self.proc.run(str(self.ssm_bin), "remove", "salt-minion", "confirm")
                log.debug("Uninstalling api service")
                self.proc.run(str(self.ssm_bin), "stop", "salt-api")
                self.proc.run(str(self.ssm_bin), "remove", "salt-api", "confirm")
            log.debug("Removing the Salt Service Manager")
            if self.ssm_bin:
                try:
                    self.ssm_bin.unlink()
                except PermissionError:
                    atexit.register(self.ssm_bin.unlink)
        if platform.is_darwin():
            # From here: https://stackoverflow.com/a/46118276/4581998
            daemons_dir = pathlib.Path(os.sep, "Library", "LaunchDaemons")
            for service in ("minion", "master", "api", "syndic"):
                service_name = f"com.saltstack.salt.{service}"
                plist_file = daemons_dir / f"{service_name}.plist"
                # Stop the services
                self.proc.run("launchctl", "disable", f"system/{service_name}")
                self.proc.run("launchctl", "bootout", "system", str(plist_file))

            # Remove Symlink to salt-config
            if os.path.exists("/usr/local/sbin/salt-config"):
                os.unlink("/usr/local/sbin/salt-config")

            # Remove supporting files
            self.proc.run(
                "pkgutil",
                "--only-files",
                "--files",
                "com.saltstack.salt",
                "|",
                "grep",
                "-v",
                "opt",
                "|",
                "tr",
                "'\n'",
                "' '",
                "|",
                "xargs",
                "-0",
                "rm",
                "-f",
            )

            # Remove directories
            if os.path.exists("/etc/salt"):
                shutil.rmtree("/etc/salt")

            # Remove path
            if os.path.exists("/etc/paths.d/salt"):
                os.remove("/etc/paths.d/salt")

            # Remove receipt
            self.proc.run("pkgutil", "--forget", "com.saltstack.salt")

        if self.singlebin:
            log.debug("Deleting the salt binary: %s", self.run_root)
            if self.run_root:
                try:
                    self.run_root.unlink()
                except PermissionError:
                    atexit.register(self.run_root.unlink)
        else:
            log.debug("Deleting the onedir directory: %s", self.root / "salt")
            shutil.rmtree(str(self.root / "salt"))

    def _uninstall_pkgs(self):
        pkg = self.pkgs[0]
        if platform.is_windows():
            log.info("Uninstalling %s", pkg)
            if pkg.endswith("exe"):
                uninst = self.install_dir / "uninst.exe"
                ret = self.proc.run(uninst, "/S")
                self._check_retcode(ret)
            elif pkg.endswith("msi"):
                ret = self.proc.run("msiexec.exe", "/qn", "/x", pkg)
                self._check_retcode(ret)

        elif platform.is_darwin():
            self._uninstall_compressed()
        else:
            log.debug("Un-Installing packages:\n%s", pprint.pformat(self.salt_pkgs))
            ret = self.proc.run(self.pkg_mngr, self.rm_pkg, "-y", *self.salt_pkgs)
            self._check_retcode(ret)

    def uninstall(self):
        if self.compressed:
            self._uninstall_compressed()
        else:
            self._uninstall_pkgs()

    def assert_uninstalled(self):
        """
        Assert that the paths in /opt/saltstack/ were correctly
        removed or not removed
        """
        return
        if platform.is_windows():
            # I'm not sure where the /opt/saltstack path is coming from
            # This is the path we're using to test windows
            opt_path = pathlib.Path(os.getenv("LocalAppData"), "salt", "pypath")
        else:
            opt_path = pathlib.Path(os.sep, "opt", "saltstack", "salt", "pypath")
        if not opt_path.exists():
            if platform.is_windows():
                assert not opt_path.parent.exists()
            else:
                assert not opt_path.parent.parent.exists()
        else:
            opt_path_contents = list(opt_path.rglob("*"))
            if not opt_path_contents:
                pytest.fail(
                    f"The path '{opt_path}' exists but there are no files in it."
                )
            else:
                for path in list(opt_path_contents):
                    if path.name in (".installs.json", "__pycache__"):
                        opt_path_contents.remove(path)
                if opt_path_contents:
                    pytest.fail(
                        "The test left some files behind: {}".format(
                            ", ".join([str(p) for p in opt_path_contents])
                        )
                    )

    def write_launchd_conf(self, service):
        service_name = f"com.saltstack.salt.{service}"
        ret = self.proc.run("launchctl", "list", service_name)
        # 113 means it couldn't find a service with that name
        if ret.returncode == 113:
            daemons_dir = pathlib.Path(os.sep, "Library", "LaunchDaemons")
            plist_file = daemons_dir / f"{service_name}.plist"
            # Make sure we're using this plist file
            if plist_file.exists():
                log.warning("Removing existing plist file for service: %s", service)
                plist_file.unlink()

            log.debug("Creating plist file for service: %s", service)
            contents = textwrap.dedent(
                f"""\
                <?xml version="1.0" encoding="UTF-8"?>
                <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
                <plist version="1.0">
                    <dict>
                        <key>Label</key>
                        <string>{service_name}</string>
                        <key>RunAtLoad</key>
                        <true/>
                        <key>KeepAlive</key>
                        <true/>
                        <key>ProgramArguments</key>
                        <array>
                            <string>{self.run_root}</string>
                            <string>{service}</string>
                            <string>-c</string>
                            <string>{self.conf_dir}</string>
                        </array>
                        <key>SoftResourceLimits</key>
                        <dict>
                            <key>NumberOfFiles</key>
                            <integer>100000</integer>
                        </dict>
                        <key>HardResourceLimits</key>
                        <dict>
                            <key>NumberOfFiles</key>
                            <integer>100000</integer>
                        </dict>
                    </dict>
                </plist>
                """
            )
            plist_file.write_text(contents, encoding="utf-8")
            contents = plist_file.read_text()
            log.debug("Created '%s'. Contents:\n%s", plist_file, contents)

            # Delete the plist file upon completion
            atexit.register(plist_file.unlink)

    def write_systemd_conf(self, service, binary):
        ret = self.proc.run("systemctl", "daemon-reload")
        self._check_retcode(ret)
        ret = self.proc.run("systemctl", "status", service)
        if ret.returncode in (3, 4):
            log.warning(
                "No systemd unit file was found for service %s. Creating one.", service
            )
            contents = textwrap.dedent(
                """\
                [Unit]
                Description={service}

                [Service]
                KillMode=process
                Type=notify
                NotifyAccess=all
                LimitNOFILE=8192
                ExecStart={tgt} -c {conf_dir}

                [Install]
                WantedBy=multi-user.target
                """
            )
            if isinstance(binary, list) and len(binary) == 1:
                binary = shutil.which(binary[0]) or binary[0]
            elif isinstance(binary, list):
                binary = " ".join(binary)
            unit_path = pathlib.Path(
                os.sep, "etc", "systemd", "system", f"{service}.service"
            )
            contents = contents.format(
                service=service, tgt=binary, conf_dir=self.conf_dir
            )
            log.info("Created '%s'. Contents:\n%s", unit_path, contents)
            unit_path.write_text(contents, encoding="utf-8")
            ret = self.proc.run("systemctl", "daemon-reload")
            atexit.register(unit_path.unlink)
            self._check_retcode(ret)

    def __enter__(self):
        if platform.is_windows():
            self.update_process_path()

        if not self.no_install:
            if self.upgrade:
                self.install_previous()
            else:
                self.install()
        return self

    def __exit__(self, *_):
        if not self.no_uninstall:
            self.uninstall()
            self.assert_uninstalled()


class PkgSystemdSaltDaemonImpl(SystemdSaltDaemonImpl):
    def get_service_name(self):
        if self._service_name is None:
            self._service_name = self.factory.script_name
        return self._service_name


@attr.s(kw_only=True)
class PkgLaunchdSaltDaemonImpl(PkgSystemdSaltDaemonImpl):

    plist_file = attr.ib()

    @plist_file.default
    def _default_plist_file(self):
        daemons_dir = pathlib.Path(os.sep, "Library", "LaunchDaemons")
        return daemons_dir / f"{self.get_service_name()}.plist"

    def get_service_name(self):
        if self._service_name is None:
            service_name = super().get_service_name()
            if "-" in service_name:
                service_name = service_name.split("-")[-1]
            self._service_name = f"com.saltstack.salt.{service_name}"
        return self._service_name

    def cmdline(self, *args):  # pylint: disable=arguments-differ
        """
        Construct a list of arguments to use when starting the subprocess.

        :param str args:
            Additional arguments to use when starting the subprocess

        """
        if args:  # pragma: no cover
            log.debug(
                "%s.run() is ignoring the passed in arguments: %r",
                self.__class__.__name__,
                args,
            )
        self._internal_run(
            "launchctl",
            "enable",
            f"system/{self.get_service_name()}",
        )
        return (
            "launchctl",
            "bootstrap",
            "system",
            str(self.plist_file),
        )

    def is_running(self):
        """
        Returns true if the sub-process is alive.
        """
        if self._process is None:
            ret = self._internal_run("launchctl", "list", self.get_service_name())
            if ret.stdout == "":
                return False

            if "PID" not in ret.stdout:
                return False

            pid = None
            # PID in a line that looks like this
            # "PID" = 445;
            for line in ret.stdout.splitlines():
                if "PID" in line:
                    pid = line.rstrip(";").split(" = ")[1]

            if pid is None:
                return False

            self._process = psutil.Process(int(pid))

        return self._process.is_running()

    def _terminate(self):
        """
        This method actually terminates the started daemon.
        """
        # We completely override the parent class method because we're not using
        # the self._terminal property, it's a launchd service
        if self._process is None:  # pragma: no cover
            if TYPE_CHECKING:
                # Make mypy happy
                assert self._terminal_result
            return (
                self._terminal_result
            )  # pylint: disable=access-member-before-definition

        atexit.unregister(self.terminate)
        log.info("Stopping %s", self.factory)
        pid = self.pid
        # Collect any child processes information before terminating the process
        with contextlib.suppress(psutil.NoSuchProcess):
            for child in psutil.Process(pid).children(recursive=True):
                if (
                    child not in self._children
                ):  # pylint: disable=access-member-before-definition
                    self._children.append(
                        child
                    )  # pylint: disable=access-member-before-definition

        if self._process.is_running():  # pragma: no cover
            cmdline = _get_cmdline(self._process)
        else:
            cmdline = []

        # Disable the service
        self._internal_run(
            "launchctl",
            "disable",
            f"system/{self.get_service_name()}",
        )
        # Unload the service
        self._internal_run("launchctl", "bootout", "system", str(self.plist_file))

        if self._process.is_running():  # pragma: no cover
            try:
                self._process.wait()
            except psutil.TimeoutExpired:
                self._process.terminate()
                try:
                    self._process.wait()
                except psutil.TimeoutExpired:
                    pass

        exitcode = self._process.wait() or 0

        # Dereference the internal _process attribute
        self._process = None
        # Lets log and kill any child processes left behind, including the main subprocess
        # if it failed to properly stop
        terminate_process(
            pid=pid,
            kill_children=True,
            children=self._children,  # pylint: disable=access-member-before-definition
            slow_stop=self.factory.slow_stop,
        )

        if self._terminal_stdout is not None:
            self._terminal_stdout.close()  # pylint: disable=access-member-before-definition
        if self._terminal_stderr is not None:
            self._terminal_stderr.close()  # pylint: disable=access-member-before-definition
        stdout = stderr = ""
        try:
            self._terminal_result = ProcessResult(
                returncode=exitcode, stdout=stdout, stderr=stderr, cmdline=cmdline
            )
            log.info("%s %s", self.factory.__class__.__name__, self._terminal_result)
            return self._terminal_result
        finally:
            self._terminal = None
            self._terminal_stdout = None
            self._terminal_stderr = None
            self._terminal_timeout = None
            self._children = []


@attr.s(kw_only=True)
class PkgSsmSaltDaemonImpl(PkgSystemdSaltDaemonImpl):
    def cmdline(self, *args):  # pylint: disable=arguments-differ
        """
        Construct a list of arguments to use when starting the subprocess.

        :param str args:
            Additional arguments to use when starting the subprocess

        """
        if args:  # pragma: no cover
            log.debug(
                "%s.run() is ignoring the passed in arguments: %r",
                self.__class__.__name__,
                args,
            )
        return (
            str(self.factory.salt_pkg_install.ssm_bin),
            "start",
            self.get_service_name(),
        )

    def is_running(self):
        """
        Returns true if the sub-process is alive.
        """
        if self._process is None:
            n = 1
            while True:
                if self._process is not None:
                    break
                time.sleep(1)
                ret = self._internal_run(
                    str(self.factory.salt_pkg_install.ssm_bin),
                    "processes",
                    self.get_service_name(),
                )
                log.warning(ret)
                if not ret.stdout or (ret.stdout and not ret.stdout.strip()):
                    if n >= 120:
                        return False
                    n += 1
                    continue
                for line in ret.stdout.splitlines():
                    log.warning("Line: %s", line)
                    if not line.strip():
                        continue
                    mainpid = line.strip().split()[0]
                    self._process = psutil.Process(int(mainpid))
                    break
        return self._process.is_running()

    def _terminate(self):
        """
        This method actually terminates the started daemon.
        """
        # We completely override the parent class method because we're not using the
        # self._terminal property, it's a systemd service
        if self._process is None:  # pragma: no cover
            if TYPE_CHECKING:
                # Make mypy happy
                assert self._terminal_result
            return (
                self._terminal_result
            )  # pylint: disable=access-member-before-definition

        atexit.unregister(self.terminate)
        log.info("Stopping %s", self.factory)
        pid = self.pid
        # Collect any child processes information before terminating the process
        with contextlib.suppress(psutil.NoSuchProcess):
            for child in psutil.Process(pid).children(recursive=True):
                if (
                    child not in self._children
                ):  # pylint: disable=access-member-before-definition
                    self._children.append(
                        child
                    )  # pylint: disable=access-member-before-definition

        if self._process.is_running():  # pragma: no cover
            cmdline = _get_cmdline(self._process)
        else:
            cmdline = []

        # Tell ssm to stop the service
        try:
            self._internal_run(
                str(self.factory.salt_pkg_install.ssm_bin),
                "stop",
                self.get_service_name(),
            )
        except FileNotFoundError:
            pass

        if self._process.is_running():  # pragma: no cover
            try:
                self._process.wait()
            except psutil.TimeoutExpired:
                self._process.terminate()
                try:
                    self._process.wait()
                except psutil.TimeoutExpired:
                    pass

        exitcode = self._process.wait() or 0

        # Dereference the internal _process attribute
        self._process = None
        # Lets log and kill any child processes left behind, including the main subprocess
        # if it failed to properly stop
        terminate_process(
            pid=pid,
            kill_children=True,
            children=self._children,  # pylint: disable=access-member-before-definition
            slow_stop=self.factory.slow_stop,
        )

        if self._terminal_stdout is not None:
            self._terminal_stdout.close()  # pylint: disable=access-member-before-definition
        if self._terminal_stderr is not None:
            self._terminal_stderr.close()  # pylint: disable=access-member-before-definition
        stdout = stderr = ""
        try:
            self._terminal_result = ProcessResult(
                returncode=exitcode, stdout=stdout, stderr=stderr, cmdline=cmdline
            )
            log.info("%s %s", self.factory.__class__.__name__, self._terminal_result)
            return self._terminal_result
        finally:
            self._terminal = None
            self._terminal_stdout = None
            self._terminal_stderr = None
            self._terminal_timeout = None
            self._children = []


@attr.s(kw_only=True)
class PkgMixin:
    salt_pkg_install: SaltPkgInstall = attr.ib()

    def get_script_path(self):
        if self.salt_pkg_install.compressed or (
            platform.is_darwin()
            and self.salt_pkg_install.classic
            and self.salt_pkg_install.upgrade
        ):
            if self.salt_pkg_install.run_root and os.path.exists(
                self.salt_pkg_install.run_root
            ):
                return str(self.salt_pkg_install.run_root)
            elif os.path.exists(self.salt_pkg_install.bin_dir / self.script_name):
                return str(self.salt_pkg_install.bin_dir / self.script_name)
            else:
                return str(self.salt_pkg_install.install_dir / self.script_name)
        return super().get_script_path()

    def get_base_script_args(self):
        base_script_args = []
        if self.salt_pkg_install.run_root and os.path.exists(
            self.salt_pkg_install.run_root
        ):
            if self.salt_pkg_install.compressed:
                if self.script_name == "spm":
                    base_script_args.append(self.script_name)
                elif self.script_name != "salt":
                    base_script_args.append(self.script_name.split("salt-")[-1])
        base_script_args.extend(super().get_base_script_args())
        return base_script_args

    def cmdline(self, *args, **kwargs):
        _cmdline = super().cmdline(*args, **kwargs)
        if self.salt_pkg_install.compressed is False:
            return _cmdline
        if _cmdline[0] == self.python_executable:
            _cmdline.pop(0)
        return _cmdline


@attr.s(kw_only=True)
class DaemonPkgMixin(PkgMixin):
    def __attrs_post_init__(self):
        if not platform.is_windows() and self.salt_pkg_install.system_service:
            if platform.is_darwin():
                self.write_launchd_conf()
            else:
                self.write_systemd_conf()

    def get_service_name(self):
        return self.script_name

    def write_launchd_conf(self):
        raise NotImplementedError

    def write_systemd_conf(self):
        raise NotImplementedError


@attr.s(kw_only=True)
class SaltMaster(DaemonPkgMixin, master.SaltMaster):
    """
    Subclassed just to tweak the binary paths if needed and factory classes.
    """

    def __attrs_post_init__(self):
        self.script_name = "salt-master"
        master.SaltMaster.__attrs_post_init__(self)
        DaemonPkgMixin.__attrs_post_init__(self)

    def _get_impl_class(self):
        if self.system_install and self.salt_pkg_install.system_service:
            if platform.is_windows():
                return PkgSsmSaltDaemonImpl
            if platform.is_darwin():
                return PkgLaunchdSaltDaemonImpl
            return PkgSystemdSaltDaemonImpl
        return DaemonImpl

    def write_launchd_conf(self):
        self.salt_pkg_install.write_launchd_conf("master")

    def write_systemd_conf(self):
        self.salt_pkg_install.write_systemd_conf(
            "salt-master", self.salt_pkg_install.binary_paths["master"]
        )

    def salt_minion_daemon(self, minion_id, **kwargs):
        return super().salt_minion_daemon(
            minion_id,
            factory_class=SaltMinion,
            salt_pkg_install=self.salt_pkg_install,
            **kwargs,
        )

    def salt_api_daemon(self, **kwargs):
        return super().salt_api_daemon(
            factory_class=SaltApi, salt_pkg_install=self.salt_pkg_install, **kwargs
        )

    def salt_key_cli(self, **factory_class_kwargs):
        return super().salt_key_cli(
            factory_class=SaltKey,
            salt_pkg_install=self.salt_pkg_install,
            **factory_class_kwargs,
        )

    def salt_cli(self, **factory_class_kwargs):
        return super().salt_cli(
            factory_class=SaltCli,
            salt_pkg_install=self.salt_pkg_install,
            **factory_class_kwargs,
        )


@attr.s(kw_only=True)
class SaltMasterWindows(SaltMaster):
    """
    Subclassed just to tweak the binary paths if needed and factory classes.
    """

    def __attrs_post_init__(self):
        super().__attrs_post_init__()
        self.script_name = cli_scripts.generate_script(
            bin_dir=self.factories_manager.scripts_dir,
            script_name="salt-master",
            code_dir=self.factories_manager.code_dir.parent,
        )

    def _get_impl_class(self):
        return DaemonImpl

    def cmdline(self, *args, **kwargs):
        cmdline_ = super().cmdline(*args, **kwargs)
        if self.python_executable:
            if cmdline_[0] != self.python_executable:
                cmdline_.insert(0, self.python_executable)
        return cmdline_


@attr.s(kw_only=True, slots=True)
class SaltMinion(DaemonPkgMixin, minion.SaltMinion):
    """
    Subclassed just to tweak the binary paths if needed and factory classes.
    """

    def __attrs_post_init__(self):
        self.script_name = "salt-minion"
        minion.SaltMinion.__attrs_post_init__(self)
        DaemonPkgMixin.__attrs_post_init__(self)

    def _get_impl_class(self):
        if self.system_install and self.salt_pkg_install.system_service:
            if platform.is_windows():
                return PkgSsmSaltDaemonImpl
            if platform.is_darwin():
                return PkgLaunchdSaltDaemonImpl
            return PkgSystemdSaltDaemonImpl
        return DaemonImpl

    def write_launchd_conf(self):
        self.salt_pkg_install.write_launchd_conf("minion")

    def write_systemd_conf(self):
        self.salt_pkg_install.write_systemd_conf(
            "salt-minion", self.salt_pkg_install.binary_paths["minion"]
        )

    def salt_call_cli(self, **factory_class_kwargs):
        return super().salt_call_cli(
            factory_class=SaltCall,
            salt_pkg_install=self.salt_pkg_install,
            **factory_class_kwargs,
        )


@attr.s(kw_only=True, slots=True)
class SaltApi(DaemonPkgMixin, api.SaltApi):
    """
    Subclassed just to tweak the binary paths if needed.
    """

    def __attrs_post_init__(self):
        self.script_name = "salt-api"
        api.SaltApi.__attrs_post_init__(self)
        DaemonPkgMixin.__attrs_post_init__(self)

    def _get_impl_class(self):
        if self.system_install and self.salt_pkg_install.system_service:
            if platform.is_windows():
                return PkgSsmSaltDaemonImpl
            if platform.is_darwin():
                return PkgLaunchdSaltDaemonImpl
            return PkgSystemdSaltDaemonImpl
        return DaemonImpl

    def write_launchd_conf(self):
        self.salt_pkg_install.write_launchd_conf("api")

    def write_systemd_conf(self):
        self.salt_pkg_install.write_systemd_conf(
            "salt-api",
            self.salt_pkg_install.binary_paths["api"],
        )


@attr.s(kw_only=True, slots=True)
class SaltCall(PkgMixin, call.SaltCall):
    """
    Subclassed just to tweak the binary paths if needed.
    """

    def __attrs_post_init__(self):
        call.SaltCall.__attrs_post_init__(self)
        self.script_name = "salt-call"


@attr.s(kw_only=True, slots=True)
class SaltCli(PkgMixin, salt.SaltCli):
    """
    Subclassed just to tweak the binary paths if needed.
    """

    def __attrs_post_init__(self):
        self.script_name = "salt"
        salt.SaltCli.__attrs_post_init__(self)


@attr.s(kw_only=True, slots=True)
class SaltKey(PkgMixin, key.SaltKey):
    """
    Subclassed just to tweak the binary paths if needed.
    """

    def __attrs_post_init__(self):
        self.script_name = "salt-key"
        key.SaltKey.__attrs_post_init__(self)


@attr.s(kw_only=True, slots=True)
class TestUser:
    """
    Add a test user
    """

    salt_call_cli = attr.ib()

    username = attr.ib(default="saltdev")
    # Must follow Windows Password Complexity requirements
    password = attr.ib(default="P@ssW0rd")
    _pw_record = attr.ib(init=False, repr=False, default=None)

    def salt_call_local(self, *args):
        ret = self.salt_call_cli.run("--local", *args)
        if ret.returncode != 0:
            log.error(ret)
        assert ret.returncode == 0
        return ret.data

    def add_user(self):
        log.debug("Adding system account %r", self.username)
        if platform.is_windows():
            self.salt_call_local("user.add", self.username, self.password)
        else:
            self.salt_call_local("user.add", self.username)
            hash_passwd = crypt.crypt(self.password, crypt.mksalt(crypt.METHOD_SHA512))
            self.salt_call_local("shadow.set_password", self.username, hash_passwd)
        assert self.username in self.salt_call_local("user.list_users")

    def remove_user(self):
        log.debug("Removing system account %r", self.username)
        if platform.is_windows():
            self.salt_call_local(
                "user.delete", self.username, "purge=True", "force=True"
            )
        else:
            self.salt_call_local("user.delete", self.username, "remove=True")

    @property
    def pw_record(self):
        if self._pw_record is None and HAS_PWD:
            self._pw_record = pwd.getpwnam(self.username)
        return self._pw_record

    @property
    def uid(self):
        if HAS_PWD:
            return self.pw_record.pw_uid
        return None

    @property
    def gid(self):
        if HAS_PWD:
            return self.pw_record.pw_gid
        return None

    @property
    def env(self):
        environ = os.environ.copy()
        environ["LOGNAME"] = environ["USER"] = self.username
        environ["HOME"] = self.pw_record.pw_dir
        return environ

    def __enter__(self):
        self.add_user()
        return self

    def __exit__(self, *_):
        self.remove_user()


@attr.s(kw_only=True, slots=True)
class ApiRequest:
    salt_api: SaltApi = attr.ib(repr=False)
    test_account: TestUser = attr.ib(repr=False)
    session: requests.Session = attr.ib(init=False, repr=False)
    api_uri: str = attr.ib(init=False)
    auth_data: Dict[str, str] = attr.ib(init=False)

    @session.default
    def _default_session(self):
        return requests.Session()

    @api_uri.default
    def _default_api_uri(self):
        return f"http://localhost:{self.salt_api.config['rest_cherrypy']['port']}"

    @auth_data.default
    def _default_auth_data(self):
        return {
            "username": self.test_account.username,
            "password": self.test_account.password,
            "eauth": "auto",
            "out": "json",
        }

    def post(self, url, data):
        post_data = dict(**self.auth_data, **data)
        resp = self.session.post(f"{self.api_uri}/run", data=post_data).json()
        minion = next(iter(resp["return"][0]))
        return resp["return"][0][minion]

    def __enter__(self):
        self.session.__enter__()
        return self

    def __exit__(self, *args):
        self.session.__exit__(*args)


@pytest.helpers.register
def remove_stale_minion_key(master, minion_id):
    key_path = os.path.join(master.config["pki_dir"], "minions", minion_id)
    if os.path.exists(key_path):
        os.unlink(key_path)
    else:
        log.debug("The minion(id=%r) key was not found at %s", minion_id, key_path)


@pytest.helpers.register
def remove_stale_master_key(master):
    keys_path = os.path.join(master.config["pki_dir"], "master")
    for key_name in ("master.pem", "master.pub"):
        key_path = os.path.join(keys_path, key_name)
        if os.path.exists(key_path):
            os.unlink(key_path)
        else:
            log.debug(
                "The master(id=%r) %s key was not found at %s",
                master.id,
                key_name,
                key_path,
            )
    key_path = os.path.join(master.config["pki_dir"], "minion", "minion_master.pub")
    if os.path.exists(key_path):
        os.unlink(key_path)
    else:
        log.debug(
            "The master(id=%r) minion_master.pub key was not found at %s",
            master.id,
            key_path,
        )


@pytest.helpers.register
def download_file(url, dest, auth=None):
    # NOTE the stream=True parameter below
    with requests.get(url, stream=True, auth=auth) as r:
        r.raise_for_status()
        with open(dest, "wb") as f:
            for chunk in r.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
    return dest
