# -*- coding: utf-8 -*-
'''
Execute puppet routines
'''

# Import python libs
from __future__ import absolute_import, print_function, unicode_literals
from distutils import version  # pylint: disable=no-name-in-module
import logging
import os
import datetime

# Import salt libs
import salt.utils.args
import salt.utils.files
import salt.utils.path
import salt.utils.platform
import salt.utils.yaml
import salt.utils.stringutils
from salt.exceptions import CommandExecutionError

# Import 3rd-party libs
from salt.ext import six
from salt.ext.six.moves import range
log = logging.getLogger(__name__)


def __virtual__():
    '''
    Only load if puppet is installed
    '''
    unavailable_exes = ', '.join(exe for exe in ('facter', 'puppet')
                                 if salt.utils.path.which(exe) is None)
    if unavailable_exes:
        return (False,
                ('The puppet execution module cannot be loaded: '
                 '{0} unavailable.'.format(unavailable_exes)))
    else:
        return 'puppet'


def _format_fact(output):
    try:
        fact, value = output.split(' => ', 1)
        value = value.strip()
    except ValueError:
        fact = None
        value = None
    return (fact, value)


class _Puppet(object):
    '''
    Puppet helper class. Used to format command for execution.
    '''
    def __init__(self):
        '''
        Setup a puppet instance, based on the premis that default usage is to
        run 'puppet agent --test'. Configuration and run states are stored in
        the default locations.
        '''
        self.subcmd = 'agent'
        self.subcmd_args = []  # e.g. /a/b/manifest.pp

        self.kwargs = {'color': 'false'}       # e.g. --tags=apache::server
        self.args = []         # e.g. --noop

        if salt.utils.platform.is_windows():
            self.vardir = 'C:\\ProgramData\\PuppetLabs\\puppet\\var'
            self.rundir = 'C:\\ProgramData\\PuppetLabs\\puppet\\run'
            self.confdir = 'C:\\ProgramData\\PuppetLabs\\puppet\\etc'
        else:
            self.puppet_version = __salt__['cmd.run']('puppet --version')
            if 'Enterprise' in self.puppet_version:
                self.vardir = '/var/opt/lib/pe-puppet'
                self.rundir = '/var/opt/run/pe-puppet'
                self.confdir = '/etc/puppetlabs/puppet'
            elif self.puppet_version != [] and version.StrictVersion(self.puppet_version) >= version.StrictVersion('4.0.0'):
                self.vardir = '/opt/puppetlabs/puppet/cache'
                self.rundir = '/var/run/puppetlabs'
                self.confdir = '/etc/puppetlabs/puppet'
            else:
                self.vardir = '/var/lib/puppet'
                self.rundir = '/var/run/puppet'
                self.confdir = '/etc/puppet'

        self.disabled_lockfile = self.vardir + '/state/agent_disabled.lock'
        self.run_lockfile = self.vardir + '/state/agent_catalog_run.lock'
        self.agent_pidfile = self.rundir + '/agent.pid'
        self.lastrunfile = self.vardir + '/state/last_run_summary.yaml'

    def __repr__(self):
        '''
        Format the command string to executed using cmd.run_all.
        '''
        cmd = 'puppet {subcmd} --vardir {vardir} --confdir {confdir}'.format(
            **self.__dict__
        )

        args = ' '.join(self.subcmd_args)
        args += ''.join(
            [' --{0}'.format(k) for k in self.args]  # single spaces
        )
        args += ''.join([
            ' --{0} {1}'.format(k, v) for k, v in six.iteritems(self.kwargs)]
        )

        # Ensure that the puppet call will return 0 in case of exit code 2
        if salt.utils.platform.is_windows():
            return 'cmd /V:ON /c {0} {1} ^& if !ERRORLEVEL! EQU 2 (EXIT 0) ELSE (EXIT /B)'.format(cmd, args)
        return '({0} {1}) || test $? -eq 2'.format(cmd, args)

    def arguments(self, args=None):
        '''
        Read in arguments for the current subcommand. These are added to the
        cmd line without '--' appended. Any others are redirected as standard
        options with the double hyphen prefixed.
        '''
        # permits deleting elements rather than using slices
        args = args and list(args) or []

        # match against all known/supported subcmds
        if self.subcmd == 'apply':
            # apply subcommand requires a manifest file to execute
            self.subcmd_args = [args[0]]
            del args[0]

        if self.subcmd == 'agent':
            # no arguments are required
            args.extend([
                'test'
            ])

        # finally do this after subcmd has been matched for all remaining args
        self.args = args


def run(*args, **kwargs):
    '''
    Execute a puppet run and return a dict with the stderr, stdout,
    return code, etc. The first positional argument given is checked as a
    subcommand. Following positional arguments should be ordered with arguments
    required by the subcommand first, followed by non-keyword arguments.
    Tags are specified by a tag keyword and comma separated list of values. --
    http://docs.puppetlabs.com/puppet/latest/reference/lang_tags.html

    CLI Examples:

    .. code-block:: bash

        salt '*' puppet.run
        salt '*' puppet.run tags=basefiles::edit,apache::server
        salt '*' puppet.run agent onetime no-daemonize no-usecacheonfailure no-splay ignorecache
        salt '*' puppet.run debug
        salt '*' puppet.run apply /a/b/manifest.pp modulepath=/a/b/modules tags=basefiles::edit,apache::server
    '''
    puppet = _Puppet()

    # new args tuple to filter out agent/apply for _Puppet.arguments()
    buildargs = ()
    for arg in range(len(args)):
        # based on puppet documentation action must come first. making the same
        # assertion. need to ensure the list of supported cmds here matches
        # those defined in _Puppet.arguments()
        if args[arg] in ['agent', 'apply']:
            puppet.subcmd = args[arg]
        else:
            buildargs += (args[arg],)
    # args will exist as an empty list even if none have been provided
    puppet.arguments(buildargs)

    puppet.kwargs.update(salt.utils.args.clean_kwargs(**kwargs))

    ret = __salt__['cmd.run_all'](repr(puppet), python_shell=True)
    return ret


def noop(*args, **kwargs):
    '''
    Execute a puppet noop run and return a dict with the stderr, stdout,
    return code, etc. Usage is the same as for puppet.run.

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.noop
        salt '*' puppet.noop tags=basefiles::edit,apache::server
        salt '*' puppet.noop debug
        salt '*' puppet.noop apply /a/b/manifest.pp modulepath=/a/b/modules tags=basefiles::edit,apache::server
    '''
    args += ('noop',)
    return run(*args, **kwargs)


def enable():
    '''
    .. versionadded:: 2014.7.0

    Enable the puppet agent

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.enable
    '''
    puppet = _Puppet()

    if os.path.isfile(puppet.disabled_lockfile):
        try:
            os.remove(puppet.disabled_lockfile)
        except (IOError, OSError) as exc:
            msg = 'Failed to enable: {0}'.format(exc)
            log.error(msg)
            raise CommandExecutionError(msg)
        else:
            return True
    return False


def disable(message=None):
    '''
    .. versionadded:: 2014.7.0

    Disable the puppet agent

    message
        .. versionadded:: 2015.5.2

        Disable message to send to puppet

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.disable
        salt '*' puppet.disable 'Disabled, contact XYZ before enabling'
    '''

    puppet = _Puppet()

    if os.path.isfile(puppet.disabled_lockfile):
        return False
    else:
        with salt.utils.files.fopen(puppet.disabled_lockfile, 'w') as lockfile:
            try:
                # Puppet chokes when no valid json is found
                msg = '{{"disabled_message":"{0}"}}'.format(message) if message is not None else '{}'
                lockfile.write(salt.utils.stringutils.to_str(msg))
                lockfile.close()
                return True
            except (IOError, OSError) as exc:
                msg = 'Failed to disable: {0}'.format(exc)
                log.error(msg)
                raise CommandExecutionError(msg)


def status():
    '''
    .. versionadded:: 2014.7.0

    Display puppet agent status

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.status
    '''
    puppet = _Puppet()

    if os.path.isfile(puppet.disabled_lockfile):
        return 'Administratively disabled'

    if os.path.isfile(puppet.run_lockfile):
        try:
            with salt.utils.files.fopen(puppet.run_lockfile, 'r') as fp_:
                pid = int(salt.utils.stringutils.to_unicode(fp_.read()))
                os.kill(pid, 0)  # raise an OSError if process doesn't exist
        except (OSError, ValueError):
            return 'Stale lockfile'
        else:
            return 'Applying a catalog'

    if os.path.isfile(puppet.agent_pidfile):
        try:
            with salt.utils.files.fopen(puppet.agent_pidfile, 'r') as fp_:
                pid = int(salt.utils.stringutils.to_unicode(fp_.read()))
                os.kill(pid, 0)  # raise an OSError if process doesn't exist
        except (OSError, ValueError):
            return 'Stale pidfile'
        else:
            return 'Idle daemon'

    return 'Stopped'


def summary():
    '''
    .. versionadded:: 2014.7.0

    Show a summary of the last puppet agent run

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.summary
    '''

    puppet = _Puppet()

    try:
        with salt.utils.files.fopen(puppet.lastrunfile, 'r') as fp_:
            report = salt.utils.yaml.safe_load(fp_)
        result = {}

        if 'time' in report:
            try:
                result['last_run'] = datetime.datetime.fromtimestamp(
                    int(report['time']['last_run'])).isoformat()
            except (TypeError, ValueError, KeyError):
                result['last_run'] = 'invalid or missing timestamp'

            result['time'] = {}
            for key in ('total', 'config_retrieval'):
                if key in report['time']:
                    result['time'][key] = report['time'][key]

        if 'resources' in report:
            result['resources'] = report['resources']

    except salt.utils.yaml.YAMLError as exc:
        raise CommandExecutionError(
            'YAML error parsing puppet run summary: {0}'.format(exc)
        )
    except IOError as exc:
        raise CommandExecutionError(
            'Unable to read puppet run summary: {0}'.format(exc)
        )

    return result


def plugin_sync():
    '''
    Runs a plugin sync between the puppet master and agent

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.plugin_sync
    '''
    ret = __salt__['cmd.run']('puppet plugin download')

    if not ret:
        return ''
    return ret


def facts(puppet=False):
    '''
    Run facter and return the results

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.facts
    '''
    ret = {}
    opt_puppet = '--puppet' if puppet else ''
    cmd_ret = __salt__['cmd.run_all']('facter {0}'.format(opt_puppet))

    if cmd_ret['retcode'] != 0:
        raise CommandExecutionError(cmd_ret['stderr'])

    output = cmd_ret['stdout']

    # Loop over the facter output and  properly
    # parse it into a nice dictionary for using
    # elsewhere
    for line in output.splitlines():
        if not line:
            continue
        fact, value = _format_fact(line)
        if not fact:
            continue
        ret[fact] = value
    return ret


def fact(name, puppet=False):
    '''
    Run facter for a specific fact

    CLI Example:

    .. code-block:: bash

        salt '*' puppet.fact kernel
    '''
    opt_puppet = '--puppet' if puppet else ''
    ret = __salt__['cmd.run_all'](
            'facter {0} {1}'.format(opt_puppet, name),
            python_shell=False)

    if ret['retcode'] != 0:
        raise CommandExecutionError(ret['stderr'])

    if not ret['stdout']:
        return ''
    return ret['stdout']
