# -*- coding: utf-8 -*-
'''
Install software from the FreeBSD ``ports(7)`` system

.. versionadded:: 2014.1.0

This module allows you to install ports using ``BATCH=yes`` to bypass
configuration prompts. It is recommended to use the :mod:`ports state
<salt.states.freebsdports>` to install ports, but it is also possible to use
this module exclusively from the command line.

.. code-block:: bash

    salt minion-id ports.config security/nmap IPV6=off
    salt minion-id ports.install security/nmap
'''
from __future__ import absolute_import, unicode_literals, print_function

# Import python libs
import fnmatch
import os
import re
import logging

# Import salt libs
import salt.utils.data
import salt.utils.files
import salt.utils.path
from salt.ext.six import string_types
from salt.exceptions import SaltInvocationError, CommandExecutionError
from salt.ext import six

log = logging.getLogger(__name__)

# Define the module's virtual name
__virtualname__ = 'ports'


def __virtual__():
    '''
    Only runs on FreeBSD systems
    '''
    if __grains__['os'] == 'FreeBSD':
        return __virtualname__
    return (False, 'The freebsdports execution module cannot be loaded: '
            'only available on FreeBSD systems.')


def _portsnap():
    '''
    Return 'portsnap --interactive' for FreeBSD 10, otherwise 'portsnap'
    '''
    ret = ['portsnap']
    if float(__grains__['osrelease']) >= 10:
        ret.append('--interactive')
    return ret


def _check_portname(name):
    '''
    Check if portname is valid and whether or not the directory exists in the
    ports tree.
    '''
    if not isinstance(name, string_types) or '/' not in name:
        raise SaltInvocationError(
            'Invalid port name \'{0}\' (category required)'.format(name)
        )

    path = os.path.join('/usr/ports', name)
    if not os.path.isdir(path):
        raise SaltInvocationError('Path \'{0}\' does not exist'.format(path))

    return path


def _options_dir(name):
    '''
    Retrieve the path to the dir containing OPTIONS file for a given port
    '''
    _check_portname(name)
    _root = '/var/db/ports'

    # New path: /var/db/ports/category_portname
    new_dir = os.path.join(_root, name.replace('/', '_'))
    # Old path: /var/db/ports/portname
    old_dir = os.path.join(_root, name.split('/')[-1])

    if os.path.isdir(old_dir):
        return old_dir
    return new_dir


def _options_file_exists(name):
    '''
    Returns True/False based on whether or not the options file for the
    specified port exists.
    '''
    return os.path.isfile(os.path.join(_options_dir(name), 'options'))


def _write_options(name, configuration):
    '''
    Writes a new OPTIONS file
    '''
    _check_portname(name)

    pkg = next(iter(configuration))
    conf_ptr = configuration[pkg]

    dirname = _options_dir(name)
    if not os.path.isdir(dirname):
        try:
            os.makedirs(dirname)
        except OSError as exc:
            raise CommandExecutionError(
                'Unable to make {0}: {1}'.format(dirname, exc)
            )

    with salt.utils.files.fopen(os.path.join(dirname, 'options'), 'w') as fp_:
        sorted_options = list(conf_ptr)
        sorted_options.sort()
        fp_.write(salt.utils.stringutils.to_str(
            '# This file was auto-generated by Salt (http://saltstack.com)\n'
            '# Options for {0}\n'
            '_OPTIONS_READ={0}\n'
            '_FILE_COMPLETE_OPTIONS_LIST={1}\n'
            .format(pkg, ' '.join(sorted_options))
        ))
        opt_tmpl = 'OPTIONS_FILE_{0}SET+={1}\n'
        for opt in sorted_options:
            fp_.write(salt.utils.stringutils.to_str(
                opt_tmpl.format(
                    '' if conf_ptr[opt] == 'on' else 'UN',
                    opt
                )
            ))


def _normalize(val):
    '''
    Fix Salt's yaml-ification of on/off, and otherwise normalize the on/off
    values to be used in writing the options file
    '''
    if isinstance(val, bool):
        return 'on' if val else 'off'
    return six.text_type(val).lower()


def install(name, clean=True):
    '''
    Install a port from the ports tree. Installs using ``BATCH=yes`` for
    non-interactive building. To set config options for a given port, use
    :mod:`ports.config <salt.modules.freebsdports.config>`.

    clean : True
        If ``True``, cleans after installation. Equivalent to running ``make
        install clean BATCH=yes``.

    .. note::

        It may be helpful to run this function using the ``-t`` option to set a
        higher timeout, since compiling a port may cause the Salt command to
        exceed the default timeout.

    CLI Example:

    .. code-block:: bash

        salt -t 1200 '*' ports.install security/nmap
    '''
    portpath = _check_portname(name)
    old = __salt__['pkg.list_pkgs']()
    if old.get(name.rsplit('/')[-1]):
        deinstall(name)
    cmd = ['make', 'install']
    if clean:
        cmd.append('clean')
    cmd.append('BATCH=yes')
    result = __salt__['cmd.run_all'](
        cmd,
        cwd=portpath,
        reset_system_locale=False,
        python_shell=False
    )
    if result['retcode'] != 0:
        __context__['ports.install_error'] = result['stderr']
    __context__.pop('pkg.list_pkgs', None)
    new = __salt__['pkg.list_pkgs']()
    ret = salt.utils.data.compare_dicts(old, new)
    if not ret and result['retcode'] == 0:
        # No change in package list, but the make install was successful.
        # Assume that the installation was a recompile with new options, and
        # set return dict so that changes are detected by the ports.installed
        # state.
        ret = {name: {'old': old.get(name, ''),
                      'new': new.get(name, '')}}
    return ret


def deinstall(name):
    '''
    De-install a port.

    CLI Example:

    .. code-block:: bash

        salt '*' ports.deinstall security/nmap
    '''
    portpath = _check_portname(name)
    old = __salt__['pkg.list_pkgs']()
    result = __salt__['cmd.run_all'](
        ['make', 'deinstall', 'BATCH=yes'],
        cwd=portpath,
        python_shell=False
    )
    __context__.pop('pkg.list_pkgs', None)
    new = __salt__['pkg.list_pkgs']()
    return salt.utils.data.compare_dicts(old, new)


def rmconfig(name):
    '''
    Clear the cached options for the specified port; run a ``make rmconfig``

    name
        The name of the port to clear

    CLI Example:

    .. code-block:: bash

        salt '*' ports.rmconfig security/nmap
    '''
    portpath = _check_portname(name)
    return __salt__['cmd.run'](
        ['make', 'rmconfig'],
        cwd=portpath,
        python_shell=False
    )


def showconfig(name, default=False, dict_return=False):
    '''
    Show the configuration options for a given port.

    default : False
        Show the default options for a port (not necessarily the same as the
        current configuration)

    dict_return : False
        Instead of returning the output of ``make showconfig``, return the data
        in an dictionary

    CLI Example:

    .. code-block:: bash

        salt '*' ports.showconfig security/nmap
        salt '*' ports.showconfig security/nmap default=True
    '''
    portpath = _check_portname(name)

    if default and _options_file_exists(name):
        saved_config = showconfig(name, default=False, dict_return=True)
        rmconfig(name)
        if _options_file_exists(name):
            raise CommandExecutionError('Unable to get default configuration')
        default_config = showconfig(name, default=False,
                                    dict_return=dict_return)
        _write_options(name, saved_config)
        return default_config

    try:
        result = __salt__['cmd.run_all'](
            ['make', 'showconfig'],
            cwd=portpath,
            python_shell=False
        )
        output = result['stdout'].splitlines()
        if result['retcode'] != 0:
            error = result['stderr']
        else:
            error = ''
    except TypeError:
        error = result

    if error:
        msg = ('Error running \'make showconfig\' for {0}: {1}'
               .format(name, error))
        log.error(msg)
        raise SaltInvocationError(msg)

    if not dict_return:
        return '\n'.join(output)

    if (not output) or ('configuration options' not in output[0]):
        return {}

    try:
        pkg = output[0].split()[-1].rstrip(':')
    except (IndexError, AttributeError, TypeError) as exc:
        log.error(
            'Unable to get pkg-version string: {0}'.format(exc)
        )
        return {}

    ret = {pkg: {}}
    output = output[1:]
    for line in output:
        try:
            opt, val, desc = re.match(
                r'\s+([^=]+)=(off|on): (.+)', line
            ).groups()
        except AttributeError:
            continue
        ret[pkg][opt] = val

    if not ret[pkg]:
        return {}
    return ret


def config(name, reset=False, **kwargs):
    '''
    Modify configuration options for a given port. Multiple options can be
    specified. To see the available options for a port, use
    :mod:`ports.showconfig <salt.modules.freebsdports.showconfig>`.

    name
        The port name, in ``category/name`` format

    reset : False
        If ``True``, runs a ``make rmconfig`` for the port, clearing its
        configuration before setting the desired options

    CLI Examples:

    .. code-block:: bash

        salt '*' ports.config security/nmap IPV6=off
    '''
    portpath = _check_portname(name)

    if reset:
        rmconfig(name)

    configuration = showconfig(name, dict_return=True)

    if not configuration:
        raise CommandExecutionError(
            'Unable to get port configuration for \'{0}\''.format(name)
        )

    # Get top-level key for later reference
    pkg = next(iter(configuration))
    conf_ptr = configuration[pkg]

    opts = dict(
        (six.text_type(x), _normalize(kwargs[x]))
        for x in kwargs
        if not x.startswith('_')
    )

    bad_opts = [x for x in opts if x not in conf_ptr]
    if bad_opts:
        raise SaltInvocationError(
            'The following opts are not valid for port {0}: {1}'
            .format(name, ', '.join(bad_opts))
        )

    bad_vals = [
        '{0}={1}'.format(x, y) for x, y in six.iteritems(opts)
        if y not in ('on', 'off')
    ]
    if bad_vals:
        raise SaltInvocationError(
            'The following key/value pairs are invalid: {0}'
            .format(', '.join(bad_vals))
        )

    conf_ptr.update(opts)
    _write_options(name, configuration)

    new_config = showconfig(name, dict_return=True)
    try:
        new_config = new_config[next(iter(new_config))]
    except (StopIteration, TypeError):
        return False

    return all(conf_ptr[x] == new_config.get(x) for x in conf_ptr)


def update(extract=False):
    '''
    Update the ports tree

    extract : False
        If ``True``, runs a ``portsnap extract`` after fetching, should be used
        for first-time installation of the ports tree.

    CLI Example:

    .. code-block:: bash

        salt '*' ports.update
    '''
    result = __salt__['cmd.run_all'](
        _portsnap() + ['fetch'],
        python_shell=False
    )
    if not result['retcode'] == 0:
        raise CommandExecutionError(
            'Unable to fetch ports snapshot: {0}'.format(result['stderr'])
        )

    ret = []
    try:
        patch_count = re.search(
            r'Fetching (\d+) patches', result['stdout']
        ).group(1)
    except AttributeError:
        patch_count = 0

    try:
        new_port_count = re.search(
            r'Fetching (\d+) new ports or files', result['stdout']
        ).group(1)
    except AttributeError:
        new_port_count = 0

    ret.append('Applied {0} new patches'.format(patch_count))
    ret.append('Fetched {0} new ports or files'.format(new_port_count))

    if extract:
        result = __salt__['cmd.run_all'](
            _portsnap() + ['extract'],
            python_shell=False
        )
        if not result['retcode'] == 0:
            raise CommandExecutionError(
                'Unable to extract ports snapshot {0}'.format(result['stderr'])
            )

    result = __salt__['cmd.run_all'](
        _portsnap() + ['update'],
        python_shell=False
    )
    if not result['retcode'] == 0:
        raise CommandExecutionError(
            'Unable to apply ports snapshot: {0}'.format(result['stderr'])
        )

    __context__.pop('ports.list_all', None)
    return '\n'.join(ret)


def list_all():
    '''
    Lists all ports available.

    CLI Example:

    .. code-block:: bash

        salt '*' ports.list_all

    .. warning::

        Takes a while to run, and returns a **LOT** of output
    '''
    if 'ports.list_all' not in __context__:
        __context__['ports.list_all'] = []
        for path, dirs, files in salt.utils.path.os_walk('/usr/ports'):
            stripped = path[len('/usr/ports'):]
            if stripped.count('/') != 2 or stripped.endswith('/CVS'):
                continue
            __context__['ports.list_all'].append(stripped[1:])
    return __context__['ports.list_all']


def search(name):
    '''
    Search for matches in the ports tree. Globs are supported, and the category
    is optional

    CLI Examples:

    .. code-block:: bash

        salt '*' ports.search 'security/*'
        salt '*' ports.search 'security/n*'
        salt '*' ports.search nmap

    .. warning::

        Takes a while to run
    '''
    name = six.text_type(name)
    all_ports = list_all()
    if '/' in name:
        if name.count('/') > 1:
            raise SaltInvocationError(
                'Invalid search string \'{0}\'. Port names cannot have more '
                'than one slash'
            )
        else:
            return fnmatch.filter(all_ports, name)
    else:
        ret = []
        for port in all_ports:
            if fnmatch.fnmatch(port.rsplit('/')[-1], name):
                ret.append(port)
        return ret
