#
# Author: Bo Maryniuk <bo@suse.de>
#
# Copyright 2017 SUSE LLC
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Ansible Support
===============

This module can have an optional minion-level
configuration in /etc/salt/minion.d/ as follows:

  ansible_timeout: 1200

The timeout is how many seconds Salt should wait for
any Ansible module to respond.
"""


import fnmatch
import importlib
import json
import logging
import os
import subprocess
import sys

import salt.utils.decorators.path
import salt.utils.json
import salt.utils.platform
import salt.utils.stringutils
import salt.utils.timed_subprocess
import salt.utils.yaml
from salt.exceptions import CommandExecutionError, LoaderError
from salt.utils.decorators import depends

try:
    import ansible
    import ansible.constants  # pylint: disable=no-name-in-module
    import ansible.modules  # pylint: disable=no-name-in-module
except ImportError:
    ansible = None

# Function alias to make sure not to shadow built-in's
__func_alias__ = {"list_": "list"}

__virtualname__ = "ansible"

log = logging.getLogger(__name__)


class AnsibleModuleResolver:
    """
    This class is to resolve all available modules in Ansible.
    """

    def __init__(self, opts):
        self.opts = opts
        self._modules_map = {}

    def _get_modules_map(self, path=None):
        """
        Get installed Ansible modules
        :return:
        """
        paths = {}
        root = ansible.modules.__path__[0]
        if not path:
            path = root
        for p_el in os.listdir(path):
            p_el_path = os.path.join(path, p_el)
            if os.path.islink(p_el_path):
                continue
            if os.path.isdir(p_el_path):
                paths.update(self._get_modules_map(p_el_path))
            else:
                if (
                    any(p_el.startswith(elm) for elm in ["__", "."])
                    or not p_el.endswith(".py")
                    or p_el in ansible.constants.IGNORE_FILES
                ):
                    continue
                p_el_path = p_el_path.replace(root, "").split(".")[0]
                als_name = (
                    p_el_path.replace(".", "").replace("/", "", 1).replace("/", ".")
                )
                paths[als_name] = p_el_path

        return paths

    def load_module(self, module):
        """
        Introspect Ansible module.

        :param module:
        :return:
        """
        m_ref = self._modules_map.get(module)
        if m_ref is None:
            raise LoaderError('Module "{}" was not found'.format(module))
        mod = importlib.import_module(
            "ansible.modules{}".format(
                ".".join([elm.split(".")[0] for elm in m_ref.split(os.path.sep)])
            )
        )

        return mod

    def get_modules_list(self, pattern=None):
        """
        Return module map references.
        :return:
        """
        if pattern and "*" not in pattern:
            pattern = "*{}*".format(pattern)
        modules = []
        for m_name, m_path in self._modules_map.items():
            m_path = m_path.split(".")[0]
            m_name = ".".join([elm for elm in m_path.split(os.path.sep) if elm])
            if pattern and fnmatch.fnmatch(m_name, pattern) or not pattern:
                modules.append(m_name)
        return sorted(modules)

    def resolve(self):
        log.debug("Resolving Ansible modules")
        self._modules_map = self._get_modules_map()
        return self

    def install(self):
        log.debug("Installing Ansible modules")
        return self


class AnsibleModuleCaller:
    DEFAULT_TIMEOUT = 1200  # seconds (20 minutes)
    OPT_TIMEOUT_KEY = "ansible_timeout"

    def __init__(self, resolver):
        self._resolver = resolver
        self.timeout = self._resolver.opts.get(
            self.OPT_TIMEOUT_KEY, self.DEFAULT_TIMEOUT
        )

    def call(self, module, *args, **kwargs):
        """
        Call an Ansible module by invoking it.
        :param module: the name of the module.
        :param args: Arguments to the module
        :param kwargs: keywords to the module
        :return:
        """

        module = self._resolver.load_module(module)
        if not hasattr(module, "main"):
            raise CommandExecutionError(
                "This module is not callable "
                '(see "ansible.help {}")'.format(
                    module.__name__.replace("ansible.modules.", "")
                )
            )
        if args:
            kwargs["_raw_params"] = " ".join(args)
        js_args = str(
            '{{"ANSIBLE_MODULE_ARGS": {args}}}'
        )  # future lint: disable=blacklisted-function
        js_args = js_args.format(args=salt.utils.json.dumps(kwargs))

        proc_out = salt.utils.timed_subprocess.TimedProc(
            ["echo", "{}".format(js_args)],
            stdout=subprocess.PIPE,
            timeout=self.timeout,
        )
        proc_out.run()
        proc_out_stdout = salt.utils.stringutils.to_str(proc_out.stdout)
        proc_exc = salt.utils.timed_subprocess.TimedProc(
            [sys.executable, module.__file__],
            stdin=proc_out_stdout,
            stdout=subprocess.PIPE,
            timeout=self.timeout,
        )
        proc_exc.run()

        try:
            out = salt.utils.json.loads(proc_exc.stdout)
        except ValueError as ex:
            out = {"Error": (proc_exc.stderr and (proc_exc.stderr + ".") or str(ex))}
            if proc_exc.stdout:
                out["Given JSON output"] = proc_exc.stdout
            return out

        if "invocation" in out:
            del out["invocation"]

        out["timeout"] = self.timeout

        return out


_resolver = None
_caller = None


def _set_callables(modules):
    """
    Set all Ansible modules callables
    :return:
    """

    def _set_function(cmd_name, doc):
        """
        Create a Salt function for the Ansible module.
        """

        def _cmd(*args, **kw):
            """
            Call an Ansible module as a function from the Salt.
            """
            kwargs = {}
            if kw.get("__pub_arg"):
                for _kw in kw.get("__pub_arg", []):
                    if isinstance(_kw, dict):
                        kwargs = _kw
                        break

            return _caller.call(cmd_name, *args, **kwargs)

        _cmd.__doc__ = doc
        return _cmd

    for mod in modules:
        setattr(sys.modules[__name__], mod, _set_function(mod, "Available"))


def __virtual__():
    """
    Ansible module caller.
    :return:
    """
    if salt.utils.platform.is_windows():
        return False, "The ansiblegate module isn't supported on Windows"
    ret = ansible is not None
    msg = not ret and "Ansible is not installed on this system" or None
    if ret:
        global _resolver
        global _caller
        _resolver = AnsibleModuleResolver(__opts__).resolve().install()
        _caller = AnsibleModuleCaller(_resolver)
        _set_callables(list())
    return __virtualname__


@depends("ansible")
def help(module=None, *args):
    """
    Display help on Ansible standard module.

    :param module:
    :return:
    """
    if not module:
        raise CommandExecutionError(
            "Please tell me what module you want to have helped with. "
            'Or call "ansible.list" to know what is available.'
        )
    try:
        module = _resolver.load_module(module)
    except (ImportError, LoaderError) as err:
        raise CommandExecutionError(
            'Module "{}" is currently not functional on your system.'.format(module)
        )

    doc = {}
    ret = {}
    for docset in module.DOCUMENTATION.split("---"):
        try:
            docset = salt.utils.yaml.safe_load(docset)
            if docset:
                doc.update(docset)
        except Exception as err:  # pylint: disable=broad-except
            log.error("Error parsing doc section: %s", err)
    if not args:
        if "description" in doc:
            description = doc.get("description") or ""
            del doc["description"]
            ret["Description"] = description
        ret[
            'Available sections on module "{}"'.format(
                module.__name__.replace("ansible.modules.", "")
            )
        ] = list(doc)
    else:
        for arg in args:
            info = doc.get(arg)
            if info is not None:
                ret[arg] = info

    return ret


@depends("ansible")
def list_(pattern=None):
    """
    Lists available modules.
    :return:
    """
    return _resolver.get_modules_list(pattern=pattern)


@salt.utils.decorators.path.which("ansible-playbook")
def playbooks(
    playbook,
    rundir=None,
    check=False,
    diff=False,
    extra_vars=None,
    flush_cache=False,
    forks=5,
    inventory=None,
    limit=None,
    list_hosts=False,
    list_tags=False,
    list_tasks=False,
    module_path=None,
    skip_tags=None,
    start_at_task=None,
    syntax_check=False,
    tags=None,
    playbook_kwargs=None,
):
    """
    Run Ansible Playbooks

    :param playbook: Which playbook to run.
    :param rundir: Directory to run `ansible-playbook` in. (Default: None)
    :param check: don't make any changes; instead, try to predict some
                  of the changes that may occur (Default: False)
    :param diff: when changing (small) files and templates, show the
                 differences in those files; works great with --check
                 (default: False)
    :param extra_vars: set additional variables as key=value or YAML/JSON, if
                       filename prepend with @, (default: None)
    :param flush_cache: clear the fact cache for every host in inventory
                        (default: False)
    :param forks: specify number of parallel processes to use
                  (Default: 5)
    :param inventory: specify inventory host path or comma separated host
                      list. (Default: None) (Ansible's default is /etc/ansible/hosts)
    :param limit: further limit selected hosts to an additional pattern (Default: None)
    :param list_hosts: outputs a list of matching hosts; does not execute anything else
                       (Default: False)
    :param list_tags: list all available tags (Default: False)
    :param list_tasks: list all tasks that would be executed (Default: False)
    :param module_path: prepend colon-separated path(s) to module library. (Default: None)
    :param skip_tags: only run plays and tasks whose tags do not match these
                      values (Default: False)
    :param start_at_task: start the playbook at the task matching this name (Default: None)
    :param: syntax_check: perform a syntax check on the playbook, but do not execute it
                          (Default: False)
    :param tags: only run plays and tasks tagged with these values (Default: None)

    :return: Playbook return

    CLI Example:

    .. code-block:: bash

        salt 'ansiblehost'  ansible.playbook playbook=/srv/playbooks/play.yml
    """
    command = ["ansible-playbook", playbook]
    if check:
        command.append("--check")
    if diff:
        command.append("--diff")
    if isinstance(extra_vars, dict):
        command.append("--extra-vars='{}'".format(json.dumps(extra_vars)))
    elif isinstance(extra_vars, str) and extra_vars.startswith("@"):
        command.append("--extra-vars={}".format(extra_vars))
    if flush_cache:
        command.append("--flush-cache")
    if inventory:
        command.append("--inventory={}".format(inventory))
    if limit:
        command.append("--limit={}".format(limit))
    if list_hosts:
        command.append("--list-hosts")
    if list_tags:
        command.append("--list-tags")
    if list_tasks:
        command.append("--list-tasks")
    if module_path:
        command.append("--module-path={}".format(module_path))
    if skip_tags:
        command.append("--skip-tags={}".format(skip_tags))
    if start_at_task:
        command.append("--start-at-task={}".format(start_at_task))
    if syntax_check:
        command.append("--syntax-check")
    if tags:
        command.append("--tags={}".format(tags))
    if playbook_kwargs:
        for key, value in playbook_kwargs.items():
            key = key.replace("_", "-")
            if value is True:
                command.append("--{}".format(key))
            elif isinstance(value, str):
                command.append("--{}={}".format(key, value))
            elif isinstance(value, dict):
                command.append("--{}={}".format(key, json.dumps(value)))
    command.append("--forks={}".format(forks))
    cmd_kwargs = {
        "env": {"ANSIBLE_STDOUT_CALLBACK": "json", "ANSIBLE_RETRY_FILES_ENABLED": "0"},
        "cwd": rundir,
        "cmd": " ".join(command),
    }
    ret = __salt__["cmd.run_all"](**cmd_kwargs)
    log.debug("Ansible Playbook Return: %s", ret)
    retdata = json.loads(ret["stdout"])
    if "retcode" in ret:
        __context__["retcode"] = retdata["retcode"] = ret["retcode"]
    return retdata
