#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Copyright (c) 2017 F5 Networks Inc.
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type


ANSIBLE_METADATA = {'metadata_version': '1.1',
                    'status': ['stableinterface'],
                    'supported_by': 'certified'}

DOCUMENTATION = r'''
---
module: bigip_command
short_description: Run arbitrary command on F5 devices
description:
  - Sends an arbitrary command to an BIG-IP node and returns the results
    read from the device. This module includes an argument that will cause
    the module to wait for a specific condition before returning or timing
    out if the condition is not met.
  - This module is B(not) idempotent, nor will it ever be. It is intended as
    a stop-gap measure to satisfy automation requirements until such a time as
    a real module has been developed to configure in the way you need.
  - If you are using this module, you should probably also be filing an issue
    to have a B(real) module created for your needs.
version_added: 2.4
options:
  commands:
    description:
      - The commands to send to the remote BIG-IP device over the
        configured provider. The resulting output from the command
        is returned. If the I(wait_for) argument is provided, the
        module is not returned until the condition is satisfied or
        the number of retries as expired.
      - Only C(tmsh) commands are supported. If you are piping or adding additional
        logic that is outside of C(tmsh) (such as grep'ing, awk'ing or other shell
        related things that are not C(tmsh), this behavior is not supported.
    required: True
  wait_for:
    description:
      - Specifies what to evaluate from the output of the command
        and what conditionals to apply.  This argument will cause
        the task to wait for a particular conditional to be true
        before moving forward. If the conditional is not true
        by the configured retries, the task fails. See examples.
    aliases: ['waitfor']
  match:
    description:
      - The I(match) argument is used in conjunction with the
        I(wait_for) argument to specify the match policy. Valid
        values are C(all) or C(any). If the value is set to C(all)
        then all conditionals in the I(wait_for) must be satisfied. If
        the value is set to C(any) then only one of the values must be
        satisfied.
    choices:
      - any
      - all
    default: all
  retries:
    description:
      - Specifies the number of retries a command should by tried
        before it is considered failed. The command is run on the
        target device every retry and evaluated against the I(wait_for)
        conditionals.
    default: 10
  interval:
    description:
      - Configures the interval in seconds to wait between retries
        of the command. If the command does not pass the specified
        conditional, the interval indicates how to long to wait before
        trying the command again.
    default: 1
  transport:
    description:
      - Configures the transport connection to use when connecting to the
        remote device. The transport argument supports connectivity to the
        device over cli (ssh) or rest.
    required: true
    choices:
        - rest
        - cli
    default: rest
    version_added: 2.5
  warn:
    description:
      - Whether the module should raise warnings related to command idempotency
        or not.
      - Note that the F5 Ansible developers specifically leave this on to make you
        aware that your usage of this module may be better served by official F5
        Ansible modules. This module should always be used as a last resort.
    default: True
    type: bool
    version_added: 2.6
  chdir:
    description:
      - Change into this directory before running the command.
    version_added: 2.6
extends_documentation_fragment: f5
author:
  - Tim Rupp (@caphrim007)
'''

EXAMPLES = r'''
- name: run show version on remote devices
  bigip_command:
    commands: show sys version
    server: lb.mydomain.com
    password: secret
    user: admin
    validate_certs: no
  delegate_to: localhost

- name: run show version and check to see if output contains BIG-IP
  bigip_command:
    commands: show sys version
    wait_for: result[0] contains BIG-IP
    server: lb.mydomain.com
    password: secret
    user: admin
    validate_certs: no
  register: result
  delegate_to: localhost

- name: run multiple commands on remote nodes
  bigip_command:
    commands:
      - show sys version
      - list ltm virtual
    server: lb.mydomain.com
    password: secret
    user: admin
    validate_certs: no
  delegate_to: localhost

- name: run multiple commands and evaluate the output
  bigip_command:
    commands:
      - show sys version
      - list ltm virtual
    wait_for:
      - result[0] contains BIG-IP
      - result[1] contains my-vs
    server: lb.mydomain.com
    password: secret
    user: admin
    validate_certs: no
  register: result
  delegate_to: localhost

- name: tmsh prefixes will automatically be handled
  bigip_command:
    commands:
      - show sys version
      - tmsh list ltm virtual
    server: lb.mydomain.com
    password: secret
    user: admin
    validate_certs: no
  delegate_to: localhost

- name: Delete all LTM nodes in Partition1, assuming no dependencies exist
  bigip_command:
    commands:
      - delete ltm node all
    chdir: Partition1
    server: lb.mydomain.com
    password: secret
    user: admin
    validate_certs: no
  delegate_to: localhost
'''

RETURN = r'''
stdout:
  description: The set of responses from the commands.
  returned: always
  type: list
  sample: ['...', '...']
stdout_lines:
  description: The value of stdout split into a list.
  returned: always
  type: list
  sample: [['...', '...'], ['...'], ['...']]
failed_conditions:
  description: The list of conditionals that have failed.
  returned: failed
  type: list
  sample: ['...', '...']
warn:
  description: Whether or not to raise warnings about modification commands.
  returned: changed
  type: bool
  sample: True
'''

import re
import time

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.network.common.parsing import FailedConditionsError
from ansible.module_utils.network.common.parsing import Conditional
from ansible.module_utils.network.common.utils import ComplexList
from ansible.module_utils.network.common.utils import to_list
from ansible.module_utils.six import string_types
from collections import deque


try:
    from library.module_utils.network.f5.bigip import HAS_F5SDK
    from library.module_utils.network.f5.bigip import F5Client
    from library.module_utils.network.f5.common import F5ModuleError
    from library.module_utils.network.f5.common import AnsibleF5Parameters
    from library.module_utils.network.f5.common import cleanup_tokens
    from library.module_utils.network.f5.common import is_cli
    from library.module_utils.network.f5.common import f5_argument_spec
    try:
        from library.module_utils.network.f5.common import iControlUnexpectedHTTPError
    except ImportError:
        HAS_F5SDK = False
except ImportError:
    from ansible.module_utils.network.f5.bigip import HAS_F5SDK
    from ansible.module_utils.network.f5.bigip import F5Client
    from ansible.module_utils.network.f5.common import F5ModuleError
    from ansible.module_utils.network.f5.common import AnsibleF5Parameters
    from ansible.module_utils.network.f5.common import cleanup_tokens
    from ansible.module_utils.network.f5.common import is_cli
    from ansible.module_utils.network.f5.common import f5_argument_spec
    try:
        from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError
    except ImportError:
        HAS_F5SDK = False

try:
    from ansible.module_utils.network.f5.common import run_commands
    HAS_CLI_TRANSPORT = True
except ImportError:
    HAS_CLI_TRANSPORT = False


if HAS_F5SDK:
    from f5.sdk_exception import LazyAttributesRequired


class NoChangeReporter(object):
    stdout_re = [
        # A general error when a resource already exists
        re.compile(r"The requested.*already exists"),

        # Returned when creating a duplicate cli alias
        re.compile(r"Data Input Error: shared.*already exists"),
    ]

    def find_no_change(self, responses):
        """Searches the response for something that looks like a change

        This method borrows heavily from Ansible's ``_find_prompt`` method
        defined in the ``lib/ansible/plugins/connection/network_cli.py::Connection``
        class.

        Arguments:
            response (string): The output from the command.

        Returns:
            bool: True when change is detected. False otherwise.
        """
        for response in responses:
            for regex in self.stdout_re:
                if regex.search(response):
                    return True
        return False


class Parameters(AnsibleF5Parameters):
    returnables = ['stdout', 'stdout_lines', 'warnings', 'executed_commands']

    def to_return(self):
        result = {}
        try:
            for returnable in self.returnables:
                result[returnable] = getattr(self, returnable)
            result = self._filter_params(result)
            return result
        except Exception:
            return result

    @property
    def raw_commands(self):
        if self._values['commands'] is None:
            return []
        if isinstance(self._values['commands'], string_types):
            result = [self._values['commands']]
        else:
            result = self._values['commands']
        return result

    def convert_commands(self, commands):
        result = []
        for command in commands:
            tmp = dict(
                command='',
                pipeline=''
            )

            command = command.replace("'", "\\'")
            pipeline = command.split('|', 1)
            tmp['command'] = pipeline[0]
            try:
                tmp['pipeline'] = pipeline[1]
            except IndexError:
                pass
            result.append(tmp)
        return result

    def convert_commands_cli(self, commands):
        result = []
        for command in commands:
            tmp = dict(
                command='',
                pipeline=''
            )

            pipeline = command.split('|', 1)
            tmp['command'] = pipeline[0]
            try:
                tmp['pipeline'] = pipeline[1]
            except IndexError:
                pass
            result.append(tmp)
        return result

    def merge_command_dict(self, command):
        if command['pipeline'] != '':
            escape_patterns = r'([$"])'
            command['pipeline'] = re.sub(escape_patterns, r'\\\1', command['pipeline'])
            command['command'] = '{0} | {1}'.format(command['command'], command['pipeline']).strip()

    def merge_command_dict_cli(self, command):
        if command['pipeline'] != '':
            command['command'] = '{0} | {1}'.format(command['command'], command['pipeline']).strip()

    @property
    def rest_commands(self):
        # ['list ltm virtual']
        commands = self.normalized_commands
        commands = self.convert_commands(commands)
        if self.chdir:
            # ['cd /Common; list ltm virtual']
            for command in commands:
                self.addon_chdir(command)
        # ['tmsh -c "cd /Common; list ltm virtual"']
        for command in commands:
            self.addon_tmsh(command)
        for command in commands:
            self.merge_command_dict(command)
        result = [x['command'] for x in commands]
        return result

    @property
    def cli_commands(self):
        # ['list ltm virtual']
        commands = self.normalized_commands
        commands = self.convert_commands_cli(commands)
        if self.chdir:
            # ['cd /Common; list ltm virtual']
            for command in commands:
                self.addon_chdir(command)
        if not self.is_tmsh:
            # ['tmsh -c "cd /Common; list ltm virtual"']
            for command in commands:
                self.addon_tmsh_cli(command)
        for command in commands:
            self.merge_command_dict_cli(command)
        result = [x['command'] for x in commands]
        return result

    @property
    def normalized_commands(self):
        if self._values['normalized_commands'] is None:
            return None
        return deque(self._values['normalized_commands'])

    @property
    def chdir(self):
        if self._values['chdir'] is None:
            return None
        if self._values['chdir'].startswith('/'):
            return self._values['chdir']
        return '/{0}'.format(self._values['chdir'])

    @property
    def user_commands(self):
        commands = self.raw_commands
        return map(self._ensure_tmsh_prefix, commands)

    @property
    def wait_for(self):
        return self._values['wait_for'] or list()

    def addon_tmsh(self, command):
        escape_patterns = r'([$"])'
        if command['command'].count('"') % 2 != 0:
            raise Exception('Double quotes are unbalanced')
        command['command'] = re.sub(escape_patterns, r'\\\\\\\1', command['command'])
        command['command'] = 'tmsh -c \\\"{0}\\\"'.format(command['command'])

    def addon_tmsh_cli(self, command):
        if command['command'].count('"') % 2 != 0:
            raise Exception('Double quotes are unbalanced')
        command['command'] = 'tmsh -c "{0}"'.format(command['command'])

    def addon_chdir(self, command):
        command['command'] = "cd {0}; {1}".format(self.chdir, command['command'])


class BaseManager(object):
    def __init__(self, *args, **kwargs):
        self.module = kwargs.get('module', None)
        self.client = kwargs.get('client', None)
        self.want = Parameters(params=self.module.params)
        self.want.update({'module': self.module})
        self.changes = Parameters(module=self.module)
        self.valid_configs = [
            'list', 'show', 'modify cli preference pager disabled'
        ]
        self.changed_command_prefixes = ('modify', 'create', 'delete')
        self.warnings = list()

    def _to_lines(self, stdout):
        lines = list()
        for item in stdout:
            if isinstance(item, string_types):
                item = item.split('\n')
            lines.append(item)
        return lines

    def exec_module(self):
        result = dict()

        try:
            changed = self.execute()
        except iControlUnexpectedHTTPError as e:
            raise F5ModuleError(str(e))

        result.update(**self.changes.to_return())
        result.update(dict(changed=changed))
        self._announce_warnings(result)
        return result

    def _announce_warnings(self, result):
        warnings = result.pop('warnings', [])
        for warning in warnings:
            self.module.warn(warning)

    def notify_non_idempotent_commands(self, commands):
        for index, item in enumerate(commands):
            if any(item.startswith(x) for x in self.valid_configs):
                return
            else:
                self.warnings.append(
                    'Using "write" commands is not idempotent. You should use '
                    'a module that is specifically made for that. If such a '
                    'module does not exist, then please file a bug. The command '
                    'in question is "{0}..."'.format(item[0:40])
                )

    @staticmethod
    def normalize_commands(raw_commands):
        if not raw_commands:
            return None
        result = []
        for command in raw_commands:
            command = command.strip()
            if command[0:5] == 'tmsh ':
                command = command[4:].strip()
            result.append(command)
        return result

    def parse_commands(self):
        results = []
        commands = self._transform_to_complex_commands(self.commands)

        for index, item in enumerate(commands):
            # This needs to be removed so that the ComplexList used in to_commands
            # will work correctly.
            output = item.pop('output', None)

            if output == 'one-line' and 'one-line' not in item['command']:
                item['command'] += ' one-line'
            elif output == 'text' and 'one-line' in item['command']:
                item['command'] = item['command'].replace('one-line', '')

            results.append(item)
        return results

    def execute(self):
        if self.want.normalized_commands:
            result = self.want.normalized_commands
        else:
            result = self.normalize_commands(self.want.raw_commands)
            self.want.update({'normalized_commands': result})
        if not result:
            return False
        self.notify_non_idempotent_commands(self.want.normalized_commands)

        commands = self.parse_commands()
        retries = self.want.retries
        conditionals = [Conditional(c) for c in self.want.wait_for]

        if self.module.check_mode:
            return

        while retries > 0:
            responses = self._execute(commands)
            self._check_known_errors(responses)
            for item in list(conditionals):
                if item(responses):
                    if self.want.match == 'any':
                        conditionals = list()
                        break
                    conditionals.remove(item)
            if not conditionals:
                break

            time.sleep(self.want.interval)
            retries -= 1
        else:
            failed_conditions = [item.raw for item in conditionals]
            errmsg = 'One or more conditional statements have not been satisfied.'
            raise FailedConditionsError(errmsg, failed_conditions)
        stdout_lines = self._to_lines(responses)
        changes = {
            'stdout': responses,
            'stdout_lines': stdout_lines,
            'executed_commands': self.commands
        }
        if self.want.warn:
            changes['warnings'] = self.warnings
        self.changes = Parameters(params=changes, module=self.module)
        return self.determine_change(responses)

    def determine_change(self, responses):
        changer = NoChangeReporter()
        if changer.find_no_change(responses):
            return False
        if any(x for x in self.want.normalized_commands if x.startswith(self.changed_command_prefixes)):
            return True
        return False

    def _check_known_errors(self, responses):
        # A regex to match the error IDs used in the F5 v2 logging framework.
        # pattern = r'^[0-9A-Fa-f]+:?\d+?:'

        for resp in responses:
            if 'usage: tmsh' in resp:
                raise F5ModuleError(
                    "tmsh command printed its 'help' message instead of running your command. "
                    "This usually indicates unbalanced quotes."
                )

    def _transform_to_complex_commands(self, commands):
        spec = dict(
            command=dict(key=True),
            output=dict(
                default='text',
                choices=['text', 'one-line']
            ),
        )
        transform = ComplexList(spec, self.module)
        result = transform(commands)
        return result


class V1Manager(BaseManager):
    """Supports CLI (SSH) communication with the remote device

    """
    def _execute(self, commands):
        if self.want.is_tmsh:
            command = dict(
                command="modify cli preference pager disabled"
            )
        else:
            command = dict(
                command="tmsh modify cli preference pager disabled"
            )
        self.execute_on_device(command)
        return self.execute_on_device(commands)

    @property
    def commands(self):
        return self.want.cli_commands

    def is_tmsh(self):
        try:
            self.execute_on_device('tmsh -v')
        except Exception as ex:
            if 'Syntax Error:' in str(ex):
                return True
            raise
        return False

    def execute(self):
        self.want.update({'is_tmsh': self.is_tmsh()})
        return super(V1Manager, self).execute()

    def execute_on_device(self, commands):
        result = run_commands(self.module, commands)
        return result


class V2Manager(BaseManager):
    """Supports REST communication with the remote device

    """
    def _execute(self, commands):
        command = dict(
            command="tmsh modify cli preference pager disabled"
        )
        self.execute_on_device(command)
        return self.execute_on_device(commands)

    @property
    def commands(self):
        return self.want.rest_commands

    def execute_on_device(self, commands):
        responses = []
        for item in to_list(commands):
            try:
                command = '-c "{0}"'.format(item['command'])
                output = self.client.api.tm.util.bash.exec_cmd(
                    'run',
                    utilCmdArgs=command
                )
                if hasattr(output, 'commandResult'):
                    output = u'{0}'.format(output.commandResult)
                    responses.append(output.strip())
            except F5ModuleError:
                raise
            except LazyAttributesRequired:
                # This can happen if there is no "commandResult" attribute in
                # the output variable above.
                pass
        return responses


class ModuleManager(object):
    def __init__(self, *args, **kwargs):
        self.kwargs = kwargs
        self.module = kwargs.get('module', None)

    def exec_module(self):
        if is_cli(self.module) and HAS_CLI_TRANSPORT:
            manager = self.get_manager('v1')
        else:
            manager = self.get_manager('v2')
        result = manager.exec_module()
        return result

    def get_manager(self, type):
        if type == 'v1':
            return V1Manager(**self.kwargs)
        elif type == 'v2':
            return V2Manager(**self.kwargs)


class ArgumentSpec(object):
    def __init__(self):
        self.supports_check_mode = True
        argument_spec = dict(
            commands=dict(
                type='raw',
                required=True
            ),
            wait_for=dict(
                type='list',
                aliases=['waitfor']
            ),
            match=dict(
                default='all',
                choices=['any', 'all']
            ),
            retries=dict(
                default=10,
                type='int'
            ),
            interval=dict(
                default=1,
                type='int'
            ),
            transport=dict(
                type='str',
                default='rest',
                choices=['cli', 'rest']
            ),
            warn=dict(
                type='bool',
                default='yes'
            ),
            chdir=dict()
        )
        self.argument_spec = {}
        self.argument_spec.update(f5_argument_spec)
        self.argument_spec.update(argument_spec)


def main():
    spec = ArgumentSpec()

    module = AnsibleModule(
        argument_spec=spec.argument_spec,
        supports_check_mode=spec.supports_check_mode
    )
    if is_cli(module) and not HAS_F5SDK:
        module.fail_json(msg="The python f5-sdk module is required to use the REST api")
    client = F5Client(**module.params)

    try:
        mm = ModuleManager(module=module, client=client)
        results = mm.exec_module()
        if not is_cli(module):
            cleanup_tokens(client)
        module.exit_json(**results)
    except F5ModuleError as e:
        if not is_cli(module):
            cleanup_tokens(client)
        module.fail_json(msg=str(e))


if __name__ == '__main__':
    main()
