"""
checkrestart functionality for Debian and Red Hat Based systems

Identifies services (processes) that are linked against deleted files (for example after downloading an updated
binary of a shared library).

Based on checkrestart script from debian-goodies (written  by Matt Zimmerman for the Debian GNU/Linux distribution,
https://packages.debian.org/debian-goodies) and psdel by Sam Morris.

:codeauthor: Jiri Kotlin <jiri.kotlin@ultimum.io>
"""

import os
import re
import subprocess
import sys
import time

import salt.exceptions
import salt.utils.args
import salt.utils.files
import salt.utils.path

NILRT_FAMILY_NAME = "NILinuxRT"

HAS_PSUTIL = False
try:
    import psutil

    HAS_PSUTIL = True
except ImportError:
    pass

LIST_DIRS = [
    # We don't care about log files
    "^/var/log/",
    "^/var/local/log/",
    # Or about files under temporary locations
    "^/var/run/",
    "^/var/local/run/",
    # Or about files under /tmp
    "^/tmp/",
    # Or about files under /dev/shm
    "^/dev/shm/",
    # Or about files under /run
    "^/run/",
    # Or about files under /drm
    "^/drm",
    # Or about files under /var/tmp and /var/local/tmp
    "^/var/tmp/",
    "^/var/local/tmp/",
    # Or /dev/zero
    "^/dev/zero",
    # Or /dev/pts (used by gpm)
    "^/dev/pts/",
    # Or /usr/lib/locale
    "^/usr/lib/locale/",
    # Skip files from the user's home directories
    # many processes hold temporafy files there
    "^/home/",
    # Skip automatically generated files
    "^.*icon-theme.cache",
    # Skip font files
    "^/var/cache/fontconfig/",
    # Skip Nagios Spool
    "^/var/lib/nagios3/spool/",
    # Skip nagios spool files
    "^/var/lib/nagios3/spool/checkresults/",
    # Skip Postgresql files
    "^/var/lib/postgresql/",
    # Skip VDR lib files
    "^/var/lib/vdr/",
    # Skip Aio files found in MySQL servers
    "^/[aio]",
    # ignore files under /SYSV
    "^/SYSV",
]


def __virtual__():
    """
    Only run this module if the psutil python module is installed (package python-psutil).
    """
    if HAS_PSUTIL:
        return HAS_PSUTIL
    else:
        return (False, "Missing dependency: psutil")


def _valid_deleted_file(path):
    """
    Filters file path against unwanted directories and decides whether file is marked as deleted.

    Returns:
        True if file is desired deleted file, else False.

    Args:
        path: A string - path to file.
    """
    ret = False
    if path.endswith(" (deleted)"):
        ret = True
    if re.compile(r"\(path inode=[0-9]+\)$").search(path):
        ret = True

    regex = re.compile("|".join(LIST_DIRS))
    if regex.match(path):
        ret = False
    return ret


def _deleted_files():
    """
    Iterates over /proc/PID/maps and /proc/PID/fd links and returns list of desired deleted files.

    Returns:
        List of deleted files to analyze, False on failure.

    """
    deleted_files = []

    for proc in psutil.process_iter():  # pylint: disable=too-many-nested-blocks
        try:
            pinfo = proc.as_dict(attrs=["pid", "name"])
        except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied):
            continue

        try:
            with salt.utils.files.fopen(
                "/proc/{}/maps".format(pinfo["pid"])
            ) as maps:  # pylint: disable=resource-leakage
                dirpath = "/proc/" + str(pinfo["pid"]) + "/fd/"
                listdir = os.listdir(dirpath)
                maplines = maps.readlines()
        except (
            OSError,
            psutil.NoSuchProcess,
            psutil.ZombieProcess,
            psutil.AccessDenied,
        ):
            continue

        # /proc/PID/maps
        mapline = re.compile(
            r"^[\da-f]+-[\da-f]+ [r-][w-][x-][sp-] "
            r"[\da-f]+ [\da-f]{2}:[\da-f]{2} (\d+) *(.+)( \(deleted\))?\n$"
        )

        for line in maplines:
            line = salt.utils.stringutils.to_unicode(line)
            matched = mapline.match(line)
            if not matched:
                continue
            path = matched.group(2)
            if not path:
                continue
            valid = _valid_deleted_file(path)
            if not valid:
                continue
            val = (pinfo["name"], pinfo["pid"], path[0:-10])
            if val not in deleted_files:
                deleted_files.append(val)
                yield val

        # /proc/PID/fd
        try:
            for link in listdir:
                path = dirpath + link
                readlink = os.readlink(path)
                filenames = []

                if os.path.isfile(readlink):
                    filenames.append(readlink)
                elif os.path.isdir(readlink) and readlink != "/":
                    for root, dummy_dirs, files in salt.utils.path.os_walk(
                        readlink, followlinks=True
                    ):
                        for name in files:
                            filenames.append(os.path.join(root, name))

                for filename in filenames:
                    valid = _valid_deleted_file(filename)
                    if not valid:
                        continue
                    val = (pinfo["name"], pinfo["pid"], filename)
                    if val not in deleted_files:
                        deleted_files.append(val)
                        yield val
        except (
            OSError,
            psutil.NoSuchProcess,
            psutil.ZombieProcess,
            psutil.AccessDenied,
        ):
            pass


def _format_output(
    kernel_restart,
    packages,
    verbose,
    restartable,
    nonrestartable,
    restartservicecommands,
    restartinitcommands,
):
    """
    Formats the output of the restartcheck module.

    Returns:
        String - formatted output.

    Args:
        kernel_restart: indicates that newer kernel is instaled.
        packages: list of packages that should be restarted.
        verbose: enables extensive output.
        restartable: list of restartable packages.
        nonrestartable: list of non-restartable packages.
        restartservicecommands: list of commands to restart services.
        restartinitcommands: list of commands to restart init.d scripts.

    """
    if not verbose:
        packages = restartable + nonrestartable
        if kernel_restart:
            packages.append("System restart required.")
        return packages
    else:
        ret = ""
        if kernel_restart:
            ret = "System restart required.\n\n"

        if packages:
            ret += "Found {} processes using old versions of upgraded files.\n".format(
                len(packages)
            )
            ret += "These are the packages:\n"

        if restartable:
            ret += (
                "Of these, {} seem to contain systemd service definitions or init"
                " scripts which can be used to restart them:\n".format(len(restartable))
            )
            for package in restartable:
                ret += package + ":\n"
                for program in packages[package]["processes"]:
                    ret += program + "\n"

            if restartservicecommands:
                ret += "\n\nThese are the systemd services:\n"
                ret += "\n".join(restartservicecommands)

            if restartinitcommands:
                ret += "\n\nThese are the initd scripts:\n"
                ret += "\n".join(restartinitcommands)

        if nonrestartable:
            ret += (
                "\n\nThese processes {} do not seem to have an associated init script "
                "to restart them:\n".format(len(nonrestartable))
            )
            for package in nonrestartable:
                ret += package + ":\n"
                for program in packages[package]["processes"]:
                    ret += program + "\n"
    return ret


def _kernel_versions_debian():
    """
    Last installed kernel name, for Debian based systems.

    Returns:
            List with possible names of last installed kernel
            as they are probably interpreted in output of `uname -a` command.
    """
    kernel_get_selections = __salt__["cmd.run"]("dpkg --get-selections linux-image-*")
    kernels = []
    kernel_versions = []
    for line in kernel_get_selections.splitlines():
        kernels.append(line)

    try:
        kernel = kernels[-2]
    except IndexError:
        kernel = kernels[0]

    kernel = kernel.rstrip("\t\tinstall")

    kernel_get_version = __salt__["cmd.run"]("apt-cache policy " + kernel)

    for line in kernel_get_version.splitlines():
        if line.startswith("  Installed: "):
            kernel_v = line.strip("  Installed: ")
            kernel_versions.append(kernel_v)
            break

    if __grains__["os"] == "Ubuntu":
        kernel_v = kernel_versions[0].rsplit(".", 1)
        kernel_ubuntu_generic = kernel_v[0] + "-generic #" + kernel_v[1]
        kernel_ubuntu_lowlatency = kernel_v[0] + "-lowlatency #" + kernel_v[1]
        kernel_versions.extend([kernel_ubuntu_generic, kernel_ubuntu_lowlatency])

    return kernel_versions


def _kernel_versions_redhat():
    """
    Name of the last installed kernel, for Red Hat based systems.

    Returns:
            List with name of last installed kernel as it is interpreted in output of `uname -a` command.
    """
    kernel_get_last = __salt__["cmd.run"]("rpm -q --last kernel")
    kernels = []
    kernel_versions = []
    for line in kernel_get_last.splitlines():
        if "kernel-" in line:
            kernels.append(line)

    kernel = kernels[0].split(" ", 1)[0]
    kernel = kernel.strip("kernel-")
    kernel_versions.append(kernel)

    return kernel_versions


def _kernel_versions_nilrt():
    """
    Last installed kernel name, for Debian based systems.

    Returns:
            List with possible names of last installed kernel
            as they are probably interpreted in output of `uname -a` command.
    """
    kver = None

    def _get_kver_from_bin(kbin):
        """
        Get kernel version from a binary image or None if detection fails
        """
        kvregex = r"[0-9]+\.[0-9]+\.[0-9]+-rt\S+"
        kernel_strings = __salt__["cmd.run"](f"strings {kbin}")
        re_result = re.search(kvregex, kernel_strings)
        return None if re_result is None else re_result.group(0)

    if __grains__.get("lsb_distrib_id") == "nilrt":
        if "arm" in __grains__.get("cpuarch"):
            # the kernel is inside a uboot created itb (FIT) image alongside the
            # device tree, ramdisk and a bootscript. There is no package management
            # or any other kind of versioning info, so we need to extract the itb.
            itb_path = "/boot/linux_runmode.itb"
            compressed_kernel = "/var/volatile/tmp/uImage.gz"
            uncompressed_kernel = "/var/volatile/tmp/uImage"
            __salt__["cmd.run"](
                "dumpimage -i {} -T flat_dt -p0 kernel -o {}".format(
                    itb_path, compressed_kernel
                )
            )
            __salt__["cmd.run"](f"gunzip -f {compressed_kernel}")
            kver = _get_kver_from_bin(uncompressed_kernel)
        else:
            # the kernel bzImage is copied to rootfs without package management or
            # other versioning info.
            kver = _get_kver_from_bin("/boot/runmode/bzImage")
    else:
        # kernels in newer NILRT's are installed via package management and
        # have the version appended to the kernel image filename
        if "arm" in __grains__.get("cpuarch"):
            kver = os.path.basename(os.readlink("/boot/uImage")).strip("uImage-")
        else:
            kver = os.path.basename(os.readlink("/boot/bzImage")).strip("bzImage-")

    return [] if kver is None else [kver]


def _check_timeout(start_time, timeout):
    """
    Name of the last installed kernel, for Red Hat based systems.

    Returns:
            List with name of last installed kernel as it is interpreted in output of `uname -a` command.
    """
    timeout_milisec = timeout * 60000
    if timeout_milisec < (int(round(time.time() * 1000)) - start_time):
        raise salt.exceptions.TimeoutError("Timeout expired.")


def _file_changed_nilrt(full_filepath):
    """
    Detect whether a file changed in an NILinuxRT system using md5sum and timestamp
    files from a state directory.

    Returns:
             - True if either md5sum/timestamp state files do not exist, or
               the file at ``full_filepath`` was touched or modified.
             - False otherwise.
    """
    rs_state_dir = "/var/lib/salt/restartcheck_state"
    base_filename = os.path.basename(full_filepath)
    timestamp_file = os.path.join(rs_state_dir, f"{base_filename}.timestamp")
    md5sum_file = os.path.join(rs_state_dir, f"{base_filename}.md5sum")

    if not os.path.exists(timestamp_file) or not os.path.exists(md5sum_file):
        return True

    prev_timestamp = __salt__["file.read"](timestamp_file).rstrip()
    # Need timestamp in seconds so floor it using int()
    cur_timestamp = str(int(os.path.getmtime(full_filepath)))

    if prev_timestamp != cur_timestamp:
        return True

    return bool(
        __salt__["cmd.retcode"](f"md5sum -cs {md5sum_file}", output_loglevel="quiet")
    )


def _kernel_modules_changed_nilrt(kernelversion):
    """
    Once a NILRT kernel module is inserted, it can't be rmmod so systems need
    rebooting (some modules explicitly ask for reboots even on first install),
    hence this functionality of determining if the module state got modified by
    testing if depmod was run.

    Returns:
             - True if modules.dep was modified/touched, False otherwise.
    """
    if kernelversion is not None:
        return _file_changed_nilrt(f"/lib/modules/{kernelversion}/modules.dep")
    return False


def _sysapi_changed_nilrt():
    """
    Besides the normal Linux kernel driver interfaces, NILinuxRT-supported hardware features an
    extensible, plugin-based device enumeration and configuration interface named "System API".
    When an installed package is extending the API it is very hard to know all repercurssions and
    actions to be taken, so reboot making sure all drivers are reloaded, hardware reinitialized,
    daemons restarted, etc.

    Returns:
             - True if nisysapi .ini files were modified/touched.
             - False if no nisysapi .ini files exist.
    """
    nisysapi_path = "/usr/local/natinst/share/nisysapi.ini"
    if os.path.exists(nisysapi_path) and _file_changed_nilrt(nisysapi_path):
        return True

    restartcheck_state_dir = "/var/lib/salt/restartcheck_state"
    nisysapi_conf_d_path = "/usr/lib/{}/nisysapi/conf.d/experts/".format(
        "arm-linux-gnueabi"
        if "arm" in __grains__.get("cpuarch")
        else "x86_64-linux-gnu"
    )

    if os.path.exists(nisysapi_conf_d_path):
        rs_count_file = f"{restartcheck_state_dir}/sysapi.conf.d.count"
        if not os.path.exists(rs_count_file):
            return True

        with salt.utils.files.fopen(rs_count_file, "r") as fcount:
            current_nb_files = len(os.listdir(nisysapi_conf_d_path))
            rs_stored_nb_files = int(fcount.read())
            if current_nb_files != rs_stored_nb_files:
                return True

        for fexpert in os.listdir(nisysapi_conf_d_path):
            if _file_changed_nilrt(f"{nisysapi_conf_d_path}/{fexpert}"):
                return True

    return False


# pylint: disable=too-many-locals,too-many-branches,too-many-statements
def restartcheck(ignorelist=None, blacklist=None, excludepid=None, **kwargs):
    """
    Analyzes files openeded by running processes and seeks for packages which need to be restarted.

    Args:
        ignorelist: string or list of packages to be ignored.
        blacklist: string or list of file paths to be ignored.
        excludepid: string or list of process IDs to be ignored.
        verbose: boolean, enables extensive output.
        timeout: int, timeout in minute.

    Returns:
        Dict on error: ``{ 'result': False, 'comment': '<reason>' }``.
        String with checkrestart output if some package seems to need to be restarted or
        if no packages need restarting.

    .. versionadded:: 2015.8.3

    CLI Example:

    .. code-block:: bash

        salt '*' restartcheck.restartcheck
    """
    kwargs = salt.utils.args.clean_kwargs(**kwargs)
    start_time = int(round(time.time() * 1000))
    kernel_restart = True
    verbose = kwargs.pop("verbose", True)
    timeout = kwargs.pop("timeout", 5)
    if __grains__.get("os_family") == "Debian":
        cmd_pkg_query = ["dpkg-query", "--listfiles"]
        systemd_folder = "/lib/systemd/system/"
        systemd = "/bin/systemd"
        kernel_versions = _kernel_versions_debian()
    elif __grains__.get("os_family") == "RedHat":
        cmd_pkg_query = ["repoquery", "-l"]
        systemd_folder = "/usr/lib/systemd/system/"
        systemd = "/usr/bin/systemctl"
        kernel_versions = _kernel_versions_redhat()
    elif __grains__.get("os_family") == NILRT_FAMILY_NAME:
        cmd_pkg_query = ["opkg", "files"]
        systemd = ""
        kernel_versions = _kernel_versions_nilrt()
    else:
        return {
            "result": False,
            "comment": (
                "Only available on Debian, Red Hat and NI Linux Real-Time based"
                " systems."
            ),
        }

    # Check kernel versions
    kernel_current = __salt__["cmd.run"]("uname -a")
    for kernel in kernel_versions:
        _check_timeout(start_time, timeout)
        if kernel in kernel_current:
            if __grains__.get("os_family") == "NILinuxRT":
                # Check kernel modules and hardware API's for version changes
                # If a restartcheck=True event was previously witnessed, propagate it
                if (
                    not _kernel_modules_changed_nilrt(kernel)
                    and not _sysapi_changed_nilrt()
                    and not __salt__["system.get_reboot_required_witnessed"]()
                ):
                    kernel_restart = False
                    break
            else:
                kernel_restart = False
                break

    packages = {}
    running_services = {}
    restart_services = []

    if ignorelist:
        if not isinstance(ignorelist, list):
            ignorelist = [ignorelist]
    else:
        ignorelist = ["screen", "systemd"]

    if blacklist:
        if not isinstance(blacklist, list):
            blacklist = [blacklist]
    else:
        blacklist = []

    if excludepid:
        if not isinstance(excludepid, list):
            excludepid = [excludepid]
    else:
        excludepid = []

    for service in __salt__["service.get_running"]():
        _check_timeout(start_time, timeout)
        service_show = __salt__["service.show"](service)
        if "ExecMainPID" in service_show:
            running_services[service] = int(service_show["ExecMainPID"])

    owners_cache = {}
    for deleted_file in _deleted_files():
        if deleted_file is False:
            return {
                "result": False,
                "comment": (
                    "Could not get list of processes. (Do you have root access?)"
                ),
            }

        _check_timeout(start_time, timeout)
        name, pid, path = deleted_file[0], deleted_file[1], deleted_file[2]
        if path in blacklist or pid in excludepid:
            continue
        try:
            readlink = os.readlink(f"/proc/{pid}/exe")
        except OSError:
            excludepid.append(pid)
            continue
        try:
            packagename = owners_cache[readlink]
        except KeyError:
            packagename = __salt__["pkg.owner"](readlink)
            if not packagename:
                packagename = name
            owners_cache[readlink] = packagename
        for running_service in running_services:
            _check_timeout(start_time, timeout)
            if (
                running_service not in restart_services
                and pid == running_services[running_service]
            ):
                if packagename and packagename not in ignorelist:
                    restart_services.append(running_service)
                    name = running_service
        if packagename and packagename not in ignorelist:
            program = "\t" + str(pid) + " " + readlink + " (file: " + str(path) + ")"
            if packagename not in packages:
                packages[packagename] = {
                    "initscripts": [],
                    "systemdservice": [],
                    "processes": [program],
                    "process_name": name,
                }
            else:
                if program not in packages[packagename]["processes"]:
                    packages[packagename]["processes"].append(program)

    if not packages and not kernel_restart:
        return "No packages seem to need to be restarted."

    for package in packages:
        _check_timeout(start_time, timeout)
        cmd = cmd_pkg_query[:]
        cmd.append(package)
        paths = subprocess.Popen(cmd, stdout=subprocess.PIPE)

        while True:
            _check_timeout(start_time, timeout)
            line = salt.utils.stringutils.to_unicode(paths.stdout.readline())
            if not line:
                break
            pth = line[:-1]
            if pth.startswith("/etc/init.d/") and not pth.endswith(".sh"):
                packages[package]["initscripts"].append(pth[12:])

            if (
                os.path.exists(systemd)
                and pth.startswith(systemd_folder)
                and pth.endswith(".service")
                and pth.find(".wants") == -1
            ):
                is_oneshot = False
                try:
                    # pylint: disable=resource-leakage
                    servicefile = salt.utils.files.fopen(pth)
                    # pylint: enable=resource-leakage
                except OSError:
                    continue
                sysfold_len = len(systemd_folder)

                for line in servicefile.readlines():
                    line = salt.utils.stringutils.to_unicode(line)
                    if line.find("Type=oneshot") > 0:
                        # scripts that does a single job and then exit
                        is_oneshot = True
                    continue
                servicefile.close()

                if not is_oneshot:
                    packages[package]["systemdservice"].append(pth[sysfold_len:])

            sys.stdout.flush()
        paths.stdout.close()

    # Alternatively, find init.d script or service that match the process name
    for package in packages:
        _check_timeout(start_time, timeout)
        if (
            not packages[package]["systemdservice"]
            and not packages[package]["initscripts"]
        ):
            service = __salt__["service.available"](packages[package]["process_name"])

            if service:
                if os.path.exists("/etc/init.d/" + packages[package]["process_name"]):
                    packages[package]["initscripts"].append(
                        packages[package]["process_name"]
                    )
                else:
                    packages[package]["systemdservice"].append(
                        packages[package]["process_name"]
                    )

    restartable = []
    nonrestartable = []
    restartinitcommands = []
    restartservicecommands = []

    for package in packages:
        _check_timeout(start_time, timeout)
        if packages[package]["initscripts"]:
            restartable.append(package)
            restartinitcommands.extend(
                ["service " + s + " restart" for s in packages[package]["initscripts"]]
            )
        elif packages[package]["systemdservice"]:
            restartable.append(package)
            restartservicecommands.extend(
                ["systemctl restart " + s for s in packages[package]["systemdservice"]]
            )
        else:
            nonrestartable.append(package)
        if packages[package]["process_name"] in restart_services:
            restart_services.remove(packages[package]["process_name"])

    for restart_service in restart_services:
        _check_timeout(start_time, timeout)
        restartservicecommands.extend(["systemctl restart " + restart_service])

    ret = _format_output(
        kernel_restart,
        packages,
        verbose,
        restartable,
        nonrestartable,
        restartservicecommands,
        restartinitcommands,
    )
    return ret
