# -*- 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 logging
import os
import plistlib
import fnmatch
import re

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

# 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:
                    # 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
