"""
runit service module
(http://smarden.org/runit)

This module is compatible with the :mod:`service <salt.states.service>` states,
so it can be used to maintain services using the ``provider`` argument:

.. code-block:: yaml

    myservice:
      service:
        - running
        - provider: runit

Provides virtual `service` module on systems using runit as init.


Service management rules (`sv` command):

    service $n is ENABLED   if file SERVICE_DIR/$n/run exists
    service $n is AVAILABLE if ENABLED or if file AVAIL_SVR_DIR/$n/run exists
    service $n is DISABLED  if AVAILABLE but not ENABLED

    SERVICE_DIR/$n is normally a symlink to a AVAIL_SVR_DIR/$n folder


Service auto-start/stop mechanism:

    `sv` (auto)starts/stops service as soon as SERVICE_DIR/<service> is
    created/deleted, both on service creation or a boot time.

    autostart feature is disabled if file SERVICE_DIR/<n>/down exists. This
    does not affect the current's service status (if already running) nor
    manual service management.


Service's alias:

    Service `sva` is an alias of service `svc` when `AVAIL_SVR_DIR/sva` symlinks
    to folder `AVAIL_SVR_DIR/svc`. `svc` can't be enabled if it is already
    enabled through an alias already enabled, since `sv` files are stored in
    folder `SERVICE_DIR/svc/`.

    XBPS package management uses a service's alias to provides service
    alternative(s), such as chrony and openntpd both aliased to ntpd.
"""

import glob
import logging
import os
import time

import salt.utils.files
import salt.utils.path
from salt.exceptions import CommandExecutionError

log = logging.getLogger(__name__)


# Function alias to not shadow built-ins.
__func_alias__ = {"reload_": "reload"}

# which dir sv works with
VALID_SERVICE_DIRS = [
    "/service",
    "/var/service",
    "/etc/service",
]
SERVICE_DIR = None
for service_dir in VALID_SERVICE_DIRS:
    if os.path.exists(service_dir):
        SERVICE_DIR = service_dir
        break

# available service directory(ies)
AVAIL_SVR_DIRS = []

# Define the module's virtual name
__virtualname__ = "runit"
__virtual_aliases__ = ("runit",)


def __virtual__():
    """
    Virtual service only on systems using runit as init process (PID 1).
    Otherwise, use this module with the provider mechanism.
    """
    if __grains__.get("init") == "runit":
        if __grains__["os"] == "Void":
            add_svc_avail_path("/etc/sv")
        global __virtualname__
        __virtualname__ = "service"
        return __virtualname__
    if salt.utils.path.which("sv"):
        return __virtualname__
    return (False, "Runit not available.  Please install sv")


def _service_path(name):
    """
    Return SERVICE_DIR+name if possible

    name
        the service's name to work on
    """
    if not SERVICE_DIR:
        raise CommandExecutionError("Could not find service directory.")
    return os.path.join(SERVICE_DIR, name)


# -- states.service  compatible args
def start(name):
    """
    Start service

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.start <service name>
    """
    cmd = f"sv start {_service_path(name)}"
    return not __salt__["cmd.retcode"](cmd)


# -- states.service compatible args
def stop(name):
    """
    Stop service

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.stop <service name>
    """
    cmd = f"sv stop {_service_path(name)}"
    return not __salt__["cmd.retcode"](cmd)


# -- states.service compatible
def reload_(name):
    """
    Reload service

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.reload <service name>
    """
    cmd = f"sv reload {_service_path(name)}"
    return not __salt__["cmd.retcode"](cmd)


# -- states.service compatible
def restart(name):
    """
    Restart service

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.restart <service name>
    """
    cmd = f"sv restart {_service_path(name)}"
    return not __salt__["cmd.retcode"](cmd)


# -- states.service compatible
def full_restart(name):
    """
    Calls runit.restart()

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.full_restart <service name>
    """
    restart(name)


# -- states.service compatible
def status(name, sig=None):
    """
    Return ``True`` if service is running

    name
        the service's name

    sig
        signature to identify with ps

    CLI Example:

    .. code-block:: bash

        salt '*' runit.status <service name>
    """
    if sig:
        # usual way to do by others (debian_service, netbsdservice).
        # XXX probably does not work here (check 'runsv sshd' instead of 'sshd' ?)
        return bool(__salt__["status.pid"](sig))

    svc_path = _service_path(name)
    if not os.path.exists(svc_path):
        # service does not exist
        return False

    # sv return code is not relevant to get a service status.
    # Check its output instead.
    cmd = f"sv status {svc_path}"
    try:
        out = __salt__["cmd.run_stdout"](cmd)
        return out.startswith("run: ")
    except Exception:  # pylint: disable=broad-except
        # sv (as a command) returned an error
        return False


def _is_svc(svc_path):
    """
    Return ``True`` if directory <svc_path> is really a service:
    file <svc_path>/run exists and is executable

    svc_path
        the (absolute) directory to check for compatibility
    """
    run_file = os.path.join(svc_path, "run")
    if (
        os.path.exists(svc_path)
        and os.path.exists(run_file)
        and os.access(run_file, os.X_OK)
    ):
        return True
    return False


def status_autostart(name):
    """
    Return ``True`` if service <name> is autostarted by sv
    (file $service_folder/down does not exist)
    NB: return ``False`` if the service is not enabled.

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.status_autostart <service name>
    """
    return not os.path.exists(os.path.join(_service_path(name), "down"))


def get_svc_broken_path(name="*"):
    """
    Return list of broken path(s) in SERVICE_DIR that match ``name``

    A path is broken if it is a broken symlink or can not be a runit service

    name
        a glob for service name. default is '*'

    CLI Example:

    .. code-block:: bash

        salt '*' runit.get_svc_broken_path <service name>
    """
    if not SERVICE_DIR:
        raise CommandExecutionError("Could not find service directory.")

    ret = set()

    for el in glob.glob(os.path.join(SERVICE_DIR, name)):
        if not _is_svc(el):
            ret.add(el)
    return sorted(ret)


def get_svc_avail_path():
    """
    Return list of paths that may contain available services
    """
    return AVAIL_SVR_DIRS


def add_svc_avail_path(path):
    """
    Add a path that may contain available services.
    Return ``True`` if added (or already present), ``False`` on error.

    path
        directory to add to AVAIL_SVR_DIRS
    """
    if os.path.exists(path):
        if path not in AVAIL_SVR_DIRS:
            AVAIL_SVR_DIRS.append(path)
        return True
    return False


def _get_svc_path(name="*", status=None):
    """
    Return a list of paths to services with ``name`` that have the specified ``status``

    name
        a glob for service name. default is '*'

    status
        None       : all services (no filter, default choice)
        'DISABLED' : available service(s) that is not enabled
        'ENABLED'  : enabled service (whether started on boot or not)
    """

    # This is the core routine to work with services, called by many
    # other functions of this module.
    #
    # The name of a service is the "apparent" folder's name that contains its
    # "run" script. If its "folder" is a symlink, the service is an "alias" of
    # the targeted service.

    if not SERVICE_DIR:
        raise CommandExecutionError("Could not find service directory.")

    # path list of enabled services as /AVAIL_SVR_DIRS/$service,
    # taking care of any service aliases (do not use os.path.realpath()).
    ena = set()
    for el in glob.glob(os.path.join(SERVICE_DIR, name)):
        if _is_svc(el):
            if os.path.islink(el):
                ena.add(os.readlink(el))
            else:
                ena.add(el)
            log.trace("found enabled service path: %s", el)

    if status == "ENABLED":
        return sorted(ena)

    # path list of available services as /AVAIL_SVR_DIRS/$service
    ava = set()
    for d in AVAIL_SVR_DIRS:
        for el in glob.glob(os.path.join(d, name)):
            if _is_svc(el):
                ava.add(el)
                log.trace("found available service path: %s", el)

    if status == "DISABLED":
        # service available but not enabled
        ret = ava.difference(ena)
    else:
        # default: return available services
        ret = ava.union(ena)

    return sorted(ret)


def _get_svc_list(name="*", status=None):
    """
    Return list of services that have the specified service ``status``

    name
        a glob for service name. default is '*'

    status
        None       : all services (no filter, default choice)
        'DISABLED' : available service that is not enabled
        'ENABLED'  : enabled service (whether started on boot or not)
    """
    return sorted(os.path.basename(el) for el in _get_svc_path(name, status))


def get_svc_alias():
    """
    Returns the list of service's name that are aliased and their alias path(s)
    """

    ret = {}
    for d in AVAIL_SVR_DIRS:
        for el in glob.glob(os.path.join(d, "*")):
            if not os.path.islink(el):
                continue
            psvc = os.readlink(el)
            if not os.path.isabs(psvc):
                psvc = os.path.join(d, psvc)
            nsvc = os.path.basename(psvc)
            if nsvc not in ret:
                ret[nsvc] = []
            ret[nsvc].append(el)
    return ret


def available(name):
    """
    Returns ``True`` if the specified service is available, otherwise returns
    ``False``.

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.available <service name>
    """
    return name in _get_svc_list(name)


def missing(name):
    """
    The inverse of runit.available.
    Returns ``True`` if the specified service is not available, otherwise returns
    ``False``.

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' runit.missing <service name>
    """
    return name not in _get_svc_list(name)


def get_all():
    """
    Return a list of all available services

    CLI Example:

    .. code-block:: bash

        salt '*' runit.get_all
    """
    return _get_svc_list()


def get_enabled():
    """
    Return a list of all enabled services

    CLI Example:

    .. code-block:: bash

        salt '*' service.get_enabled
    """
    return _get_svc_list(status="ENABLED")


def get_disabled():
    """
    Return a list of all disabled services

    CLI Example:

    .. code-block:: bash

        salt '*' service.get_disabled
    """
    return _get_svc_list(status="DISABLED")


def enabled(name):
    """
    Return ``True`` if the named service is enabled, ``False`` otherwise

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' service.enabled <service name>
    """
    # exhaustive check instead of (only) os.path.exists(_service_path(name))
    return name in _get_svc_list(name, "ENABLED")


def disabled(name):
    """
    Return ``True`` if the named service is disabled, ``False``  otherwise

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' service.disabled <service name>
    """
    # return True for a non-existent service
    return name not in _get_svc_list(name, "ENABLED")


def show(name):
    """
    Show properties of one or more units/jobs or the manager

    name
        the service's name

    CLI Example:

    .. code-block:: bash

        salt '*' service.show <service name>
    """
    ret = {}
    ret["enabled"] = False
    ret["disabled"] = True
    ret["running"] = False
    ret["service_path"] = None
    ret["autostart"] = False
    ret["command_path"] = None

    ret["available"] = available(name)
    if not ret["available"]:
        return ret

    ret["enabled"] = enabled(name)
    ret["disabled"] = not ret["enabled"]
    ret["running"] = status(name)
    ret["autostart"] = status_autostart(name)
    ret["service_path"] = _get_svc_path(name)[0]
    if ret["service_path"]:
        ret["command_path"] = os.path.join(ret["service_path"], "run")

    # XXX provide info about alias ?

    return ret


def enable(name, start=False, **kwargs):
    """
    Start service ``name`` at boot.
    Returns ``True`` if operation is successful

    name
        the service's name

    start : False
        If ``True``, start the service once enabled.

    CLI Example:

    .. code-block:: bash

        salt '*' service.enable <name> [start=True]
    """

    # non-existent service
    if not available(name):
        return False

    # if service is aliased, refuse to enable it
    alias = get_svc_alias()
    if name in alias:
        log.error("This service is aliased, enable its alias instead")
        return False

    # down_file: file that disables sv autostart
    svc_realpath = _get_svc_path(name)[0]
    down_file = os.path.join(svc_realpath, "down")

    # if service already enabled, remove down_file to
    # let service starts on boot (as requested)
    if enabled(name):
        if os.path.exists(down_file):
            try:
                os.unlink(down_file)
            except OSError:
                log.error("Unable to remove file %s", down_file)
                return False
        return True

    # let's enable the service

    if not start:
        # create a temp 'down' file BEFORE enabling service.
        # will prevent sv from starting this service automatically.
        log.trace("need a temporary file %s", down_file)
        if not os.path.exists(down_file):
            try:
                # pylint: disable=resource-leakage
                salt.utils.files.fopen(down_file, "w").close()
                # pylint: enable=resource-leakage
            except OSError:
                log.error("Unable to create file %s", down_file)
                return False

    # enable the service
    try:
        os.symlink(svc_realpath, _service_path(name))

    except OSError:
        # (attempt to) remove temp down_file anyway
        log.error("Unable to create symlink %s", down_file)
        if not start:
            os.unlink(down_file)
        return False

    # ensure sv is aware of this new service before continuing.
    # if not, down_file might be removed too quickly,
    # before 'sv' have time to take care about it.
    # Documentation indicates that a change is handled within 5 seconds.
    cmd = f"sv status {_service_path(name)}"
    retcode_sv = 1
    count_sv = 0
    while retcode_sv != 0 and count_sv < 10:
        time.sleep(0.5)
        count_sv += 1
        call = __salt__["cmd.run_all"](cmd)
        retcode_sv = call["retcode"]

    # remove the temp down_file in any case.
    if (not start) and os.path.exists(down_file):
        try:
            os.unlink(down_file)
        except OSError:
            log.error("Unable to remove temp file %s", down_file)
            retcode_sv = 1

    # if an error happened, revert our changes
    if retcode_sv != 0:
        os.unlink(os.path.join([_service_path(name), name]))
        return False
    return True


def disable(name, stop=False, **kwargs):
    """
    Don't start service ``name`` at boot
    Returns ``True`` if operation is successful

    name
        the service's name

    stop
        if True, also stops the service

    CLI Example:

    .. code-block:: bash

        salt '*' service.disable <name> [stop=True]
    """

    # non-existent as registrered service
    if not enabled(name):
        return False

    # down_file: file that prevent sv autostart
    svc_realpath = _get_svc_path(name)[0]
    down_file = os.path.join(svc_realpath, "down")

    if stop:
        stop(name)

    if not os.path.exists(down_file):
        try:
            # pylint: disable=resource-leakage
            salt.utils.files.fopen(down_file, "w").close()
            # pylint: enable=resource-leakage
        except OSError:
            log.error("Unable to create file %s", down_file)
            return False

    return True


def remove(name):
    """
    Remove the service <name> from system.
    Returns ``True`` if operation is successful.
    The service will be also stopped.

    name
        the service's name

    CLI Example:

    .. code-block:: bash

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

    if not enabled(name):
        return False

    svc_path = _service_path(name)
    if not os.path.islink(svc_path):
        log.error("%s is not a symlink: not removed", svc_path)
        return False

    if not stop(name):
        log.error("Failed to stop service %s", name)
        return False
    try:
        os.remove(svc_path)
    except OSError:
        log.error("Unable to remove symlink %s", svc_path)
        return False
    return True
