# -*- coding: utf-8 -*-
"""
Module for the management of MacOS systems that use launchd/launchctl

.. important::
    If you feel that Salt should be using this module to manage services on a
    minion, and it is using a different module (or gives an error similar to
    *'service.start' is not available*), see :ref:`here
    <module-provider-override>`.

:depends:   - plistlib Python module
"""

# Import python libs
from __future__ import absolute_import, print_function, unicode_literals

import fnmatch
import logging
import os
import plistlib
import re

# Import salt libs
import salt.utils.data
import salt.utils.decorators as decorators
import salt.utils.files
import salt.utils.path
import salt.utils.platform
import salt.utils.stringutils
from salt.ext import six
from salt.utils.versions import LooseVersion as _LooseVersion

# Set up logging
log = logging.getLogger(__name__)

# Define the module's virtual name
__virtualname__ = "service"

BEFORE_YOSEMITE = True


def __virtual__():
    """
    Only work on MacOS
    """
    if not salt.utils.platform.is_darwin():
        return (
            False,
            "Failed to load the mac_service module:\n"
            "Only available on macOS systems.",
        )

    if not os.path.exists("/bin/launchctl"):
        return (
            False,
            "Failed to load the mac_service module:\n"
            'Required binary not found: "/bin/launchctl"',
        )

    if _LooseVersion(__grains__["osrelease"]) >= _LooseVersion("10.11"):
        return (
            False,
            "Failed to load the mac_service module:\n"
            "Not available on El Capitan, uses mac_service.py",
        )

    if _LooseVersion(__grains__["osrelease"]) >= _LooseVersion("10.10"):
        global BEFORE_YOSEMITE
        BEFORE_YOSEMITE = False

    return __virtualname__


def _launchd_paths():
    """
    Paths where launchd services can be found
    """
    return [
        "/Library/LaunchAgents",
        "/Library/LaunchDaemons",
        "/System/Library/LaunchAgents",
        "/System/Library/LaunchDaemons",
    ]


@decorators.memoize
def _available_services():
    """
    Return a dictionary of all available services on the system
    """
    available_services = dict()
    for launch_dir in _launchd_paths():
        for root, dirs, files in salt.utils.path.os_walk(launch_dir):
            for filename in files:
                file_path = os.path.join(root, filename)
                # Follow symbolic links of files in _launchd_paths
                true_path = os.path.realpath(file_path)
                # ignore broken symlinks
                if not os.path.exists(true_path):
                    continue

                try:
                    # This assumes most of the plist files
                    # will be already in XML format
                    with salt.utils.files.fopen(file_path):
                        plist = plistlib.readPlist(salt.utils.data.decode(true_path))

                except Exception:  # pylint: disable=broad-except
                    # If plistlib is unable to read the file we'll need to use
                    # the system provided plutil program to do the conversion
                    cmd = '/usr/bin/plutil -convert xml1 -o - -- "{0}"'.format(
                        true_path
                    )
                    plist_xml = __salt__["cmd.run_all"](cmd, python_shell=False)[
                        "stdout"
                    ]
                    if six.PY2:
                        plist = plistlib.readPlistFromString(plist_xml)
                    else:
                        plist = plistlib.readPlistFromBytes(
                            salt.utils.stringutils.to_bytes(plist_xml)
                        )

                try:
                    available_services[plist.Label.lower()] = {
                        "filename": filename,
                        "file_path": true_path,
                        "plist": plist,
                    }
                except AttributeError:
                    # As of MacOS 10.12 there might be plist files without Label key
                    # in the searched directories. As these files do not represent
                    # services, thay are not added to the list.
                    pass

    return available_services


def _service_by_name(name):
    """
    Return the service info for a service by label, filename or path
    """
    services = _available_services()
    name = name.lower()

    if name in services:
        # Match on label
        return services[name]

    for service in six.itervalues(services):
        if service["file_path"].lower() == name:
            # Match on full path
            return service
        basename, ext = os.path.splitext(service["filename"])
        if basename.lower() == name:
            # Match on basename
            return service

    return False


def get_all():
    """
    Return all installed services

    CLI Example:

    .. code-block:: bash

        salt '*' service.get_all
    """
    cmd = "launchctl list"

    service_lines = [
        line
        for line in __salt__["cmd.run"](cmd).splitlines()
        if not line.startswith("PID")
    ]

    service_labels_from_list = [line.split("\t")[2] for line in service_lines]
    service_labels_from_services = list(_available_services().keys())

    return sorted(set(service_labels_from_list + service_labels_from_services))


def _get_launchctl_data(job_label, runas=None):
    if BEFORE_YOSEMITE:
        cmd = "launchctl list -x {0}".format(job_label)
    else:
        cmd = "launchctl list {0}".format(job_label)

    launchctl_data = __salt__["cmd.run_all"](cmd, python_shell=False, runas=runas)

    if launchctl_data["stderr"]:
        # The service is not loaded, further, it might not even exist
        # in either case we didn't get XML to parse, so return an empty
        # dict
        return None

    return launchctl_data["stdout"]


def available(job_label):
    """
    Check that the given service is available.

    CLI Example:

    .. code-block:: bash

        salt '*' service.available com.openssh.sshd
    """
    return True if _service_by_name(job_label) else False


def missing(job_label):
    """
    The inverse of service.available
    Check that the given service is not available.

    CLI Example:

    .. code-block:: bash

        salt '*' service.missing com.openssh.sshd
    """
    return False if _service_by_name(job_label) else True


def status(name, runas=None):
    """
    Return the status for a service via systemd.
    If the name contains globbing, a dict mapping service name to True/False
    values is returned.

    .. versionchanged:: 2018.3.0
        The service name can now be a glob (e.g. ``salt*``)

    Args:
        name (str): The name of the service to check
        runas (str): User to run launchctl commands

    Returns:
        bool: True if running, False otherwise
        dict: Maps service name to True if running, False otherwise

    CLI Example:

    .. code-block:: bash

        salt '*' service.status <service name>
    """

    contains_globbing = bool(re.search(r"\*|\?|\[.+\]", name))
    if contains_globbing:
        services = fnmatch.filter(get_all(), name)
    else:
        services = [name]
    results = {}
    for service in services:
        service_info = _service_by_name(service)

        lookup_name = service_info["plist"]["Label"] if service_info else service
        launchctl_data = _get_launchctl_data(lookup_name, runas=runas)

        if launchctl_data:
            if BEFORE_YOSEMITE:
                if six.PY3:
                    results[service] = "PID" in plistlib.loads(launchctl_data)
                else:
                    results[service] = "PID" in dict(
                        plistlib.readPlistFromString(launchctl_data)
                    )
            else:
                pattern = '"PID" = [0-9]+;'
                results[service] = True if re.search(pattern, launchctl_data) else False
        else:
            results[service] = False
    if contains_globbing:
        return results
    return results[name]


def stop(job_label, runas=None):
    """
    Stop the specified service

    CLI Example:

    .. code-block:: bash

        salt '*' service.stop <service label>
        salt '*' service.stop org.ntp.ntpd
        salt '*' service.stop /System/Library/LaunchDaemons/org.ntp.ntpd.plist
    """
    service = _service_by_name(job_label)
    if service:
        cmd = "launchctl unload -w {0}".format(service["file_path"], runas=runas)
        return not __salt__["cmd.retcode"](cmd, runas=runas, python_shell=False)

    return False


def start(job_label, runas=None):
    """
    Start the specified service

    CLI Example:

    .. code-block:: bash

        salt '*' service.start <service label>
        salt '*' service.start org.ntp.ntpd
        salt '*' service.start /System/Library/LaunchDaemons/org.ntp.ntpd.plist
    """
    service = _service_by_name(job_label)
    if service:
        cmd = "launchctl load -w {0}".format(service["file_path"], runas=runas)
        return not __salt__["cmd.retcode"](cmd, runas=runas, python_shell=False)

    return False


def restart(job_label, runas=None):
    """
    Restart the named service

    CLI Example:

    .. code-block:: bash

        salt '*' service.restart <service label>
    """
    stop(job_label, runas=runas)
    return start(job_label, runas=runas)


def enabled(job_label, runas=None):
    """
    Return True if the named service is enabled, false otherwise

    CLI Example:

    .. code-block:: bash

        salt '*' service.enabled <service label>
    """
    overrides_data = dict(
        plistlib.readPlist("/var/db/launchd.db/com.apple.launchd/overrides.plist")
    )
    if overrides_data.get(job_label, False):
        if overrides_data[job_label]["Disabled"]:
            return False
        else:
            return True
    else:
        return False


def disabled(job_label, runas=None):
    """
    Return True if the named service is disabled, false otherwise

    CLI Example:

    .. code-block:: bash

        salt '*' service.disabled <service label>
    """
    overrides_data = dict(
        plistlib.readPlist("/var/db/launchd.db/com.apple.launchd/overrides.plist")
    )
    if overrides_data.get(job_label, False):
        if overrides_data[job_label]["Disabled"]:
            return True
        else:
            return False
    else:
        return True
