# Copyright (C) 2011-2012  Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

from lettuce import *
import time
import subprocess
import re
import json

@step('sleep for (\d+) seconds')
def wait_seconds(step, seconds):
    """Sleep for some seconds.
       Parameters:
       seconds number of seconds to sleep for.
    """
    time.sleep(float(seconds))

@step('start bundy(?: with configuration (\S+))?' +\
      '(?: with cmdctl port (\d+))?' +\
      '(?: with msgq socket file (\S+))?' +\
      '(?: as (\S+))?')
def start_bundy(step, config_file, cmdctl_port, msgq_sockfile, process_name):
    """
    Start BUNDY with the given optional config file, cmdctl port, and
    store the running process in world with the given process name.
    Parameters:
    config_file ('with configuration <file>', optional): this configuration
                will be used. The path is relative to the base lettuce
                directory.
    cmdctl_port ('with cmdctl port <portnr>', optional): The port on which
                bundy-cmdctl listens for bundyctl commands. Defaults to 56175.
    msgq_sockfile ('with msgq socket file', optional): The msgq socket file
                that will be used for internal communication
    process_name ('as <name>', optional). This is the name that can be used
                 in the following steps of the scenario to refer to this
                 BUNDY instance. Defaults to 'bundy'.
    This call will block until BUNDY_STARTUP_COMPLETE or BUNDY_STARTUP_ERROR
    is logged. In the case of the latter, or if it times out, the step (and
    scenario) will fail.
    It will also fail if there is a running process with the given process_name
    already.
    """
    args = [ 'bundy', '-v' ]
    if config_file is not None:
        args.append('-p')
        args.append("configurations/")
        args.append('-c')
        args.append(config_file)
    if cmdctl_port is None:
        args.append('--cmdctl-port=56175')
    else:
        args.append('--cmdctl-port=' + cmdctl_port)
    if process_name is None:
        process_name = "bundy"
    else:
        args.append('-m')
        args.append(process_name + '_msgq.socket')

    world.processes.add_process(step, process_name, args)

    # check output to know when startup has been completed
    (message, line) = world.processes.wait_for_log_str(
        process_name, ["BUNDY_STARTUP_COMPLETE", "BUNDY_STARTUP_ERROR"])
    assert message == "BUNDY_STARTUP_COMPLETE", "Got: " + str(line)

@step('wait for bundy auth (?:of (\w+) )?to start')
def wait_for_auth(step, process_name):
    """Wait for bundy-auth to run. This is done by blocking until the message
       AUTH_SERVER_STARTED is logged.
       Parameters:
       process_name ('of <name', optional): The name of the BUNDY instance
                    to wait for. Defaults to 'bundy'.
    """
    if process_name is None:
        process_name = "bundy"
    world.processes.wait_for_log_str(process_name, ['AUTH_SERVER_STARTED'],
                                     False)

@step('wait for bundy xfrout (?:of (\w+) )?to start')
def wait_for_xfrout(step, process_name):
    """Wait for bundy-xfrout to run. This is done by blocking until the message
       XFROUT_NEW_CONFIG_DONE is logged.
       Parameters:
       process_name ('of <name', optional): The name of the BUNDY instance
                    to wait for. Defaults to 'bundy'.
    """
    if process_name is None:
        process_name = "bundy"
    world.processes.wait_for_log_str(process_name,
                                     ['XFROUT_NEW_CONFIG_DONE'],
                                     False)

@step('have bundy running(?: with configuration ([\S]+))?' +\
      '(?: with cmdctl port (\d+))?' +\
      '(?: as ([\S]+))?')
def have_bundy_running(step, config_file, cmdctl_port, process_name):
    """
    Compound convenience step for running bundy, which consists of
    start_bundy.
    Currently only supports the 'with configuration' option.
    """
    start_step = 'start bundy with configuration ' + config_file
    if cmdctl_port is not None:
        start_step += ' with cmdctl port ' + str(cmdctl_port)
    if process_name is not None:
        start_step += ' as ' + process_name
    step.given(start_step)

# function to send lines to bundyctl, and store the result
def run_bundyctl(commands, cmdctl_port=None, ignore_failure=False):
    """Run bundyctl.
       Parameters:
       commands: a sequence of strings which will be sent.
       cmdctl_port: a port number on which cmdctl is listening, is converted
                    to string if necessary. If not provided, or None, defaults
                    to 56175
       ignore_failure(bool): if set to True, don't examin the result code
                    of bundyctl and assert it succeeds.

       bundyctl's stdout and stderr streams are stored (as one multiline string
       in world.last_bundyctl_stdout/stderr.
       Fails if the return code is not 0
    """
    if cmdctl_port is None:
        cmdctl_port = 56175

    args = ['bundyctl', '-p', str(cmdctl_port), '--csv-file-dir=configurations/' ]
    bundyctl = subprocess.Popen(args, 1, None, subprocess.PIPE,
                               subprocess.PIPE, None)
    for line in commands:
        bundyctl.stdin.write(line + "\n")
    (stdout, stderr) = bundyctl.communicate()
    if ignore_failure:
        return
    result = bundyctl.returncode
    world.last_bundyctl_stdout = stdout
    world.last_bundyctl_stderr = stderr
    assert result == 0, "bundyctl exit code: " + str(result) +\
                        "\nstdout:\n" + str(stdout) +\
                        "stderr:\n" + str(stderr)


@step('last bundyctl( stderr)? output should( not)? contain (\S+)( exactly)?')
def check_bundyctl_output(step, stderr, notv, string, exactly):
    """Checks the stdout (or stderr) stream of the last run of bundyctl,
       fails if the given string is not found in it (or fails if 'not' was
       set and it is found
       Parameters:
       stderr ('stderr'): Check stderr instead of stdout output
       notv ('not'): reverse the check (fail if string is found)
       string ('contain <string>') string to look for
       exactly ('exactly'): Make an exact match delimited by whitespace
    """
    if stderr is None:
        output = world.last_bundyctl_stdout
    else:
        output = world.last_bundyctl_stderr
    found = False
    if exactly is None:
        if string in output:
            found = True
    else:
        if re.search(r'^\s+' + string + r'\s+', output, re.IGNORECASE | re.MULTILINE) is not None:
            found = True
    if notv is None:
        assert found == True, "'" + string +\
                              "' was not found in bundyctl output:\n" +\
                              output
    else:
        assert not found, "'" + string +\
                          "' was found in bundyctl output:\n" +\
                          output

def parse_bundyctl_output_as_data_structure():
    """Helper function for data-related command tests: evaluates the
       last output of bundyctl as a data structure that can then be
       inspected.
       If the bundyctl output is not valid (json) data, this call will
       fail with an assertion failure.
       If it is valid, it is parsed and returned as whatever data
       structure it represented.
    """
    # strip any extra output after a character that commonly terminates a valid
    # JSON expression, i.e., ']', '}' and '"'.  (The extra output would
    # contain 'Exit from bundyctl' message, and depending on environment some
    # other control-like characters...but why is this message even there?)
    # Note that this filter is not perfect.  For example, it cannot recognize
    # a simple expression of true/false/null.
    output = re.sub("(.*)([^]}\"]*$)", r"\1", world.last_bundyctl_stdout)
    try:
        return json.loads(output)
    except ValueError as ve:
        assert False, "Last bundyctl output does not appear to be a " +\
                      "parseable data structure: '" + output + "': " + str(ve)

def find_process_pid(step, process_name):
    """Helper function to request the running processes from Init, and
       return the pid of the process with the given process_name.
       Fails with an assert if the response from bundy-init is not valid JSON,
       or if the process with the given name is not found.
    """
    # show_processes output is a list of lists, where the inner lists
    # are of the form [ pid, "name" ]
    # Not checking data form; errors will show anyway (if these turn
    # out to be too vague, we can change this)
    step.given('send bundy the command Init show_processes')
    running_processes = parse_bundyctl_output_as_data_structure()

    for process in running_processes:
        if process[1] == process_name:
            return process[0]
    assert False, "Process named " + process_name +\
                  " not found in output of Init show_processes";

@step("remember the pid of process ([\S]+)")
def remember_pid(step, process_name):
    """Stores the PID of the process with the given name as returned by
       Init show_processes command.
       Fails if the process with the given name does not appear to exist.
       Stores the component_name->pid value in the dict world.process_pids.
       This should only be used by the related step
       'the pid of process <name> should (not) have changed'
       Arguments:
       process name ('process <name>') the name of the component to store
                                       the pid of.
    """
    if world.process_pids is None:
        world.process_pids = {}
    world.process_pids[process_name] = find_process_pid(step, process_name)

@step('pid of process ([\S]+) should not have changed')
def check_pid(step, process_name):
    """Checks the PID of the process with the given name as returned by
       Init show_processes command.
       Fails if the process with the given name does not appear to exist.
       Fails if the process with the given name exists, but has a different
       pid than it had when the step 'remember the pid of process' was
       called.
       Fails if that step has not been called (since world.process_pids
       does not exist).
    """
    assert world.process_pids is not None, "No process pids stored"
    assert process_name in world.process_pids, "Process named " +\
                                               process_name +\
                                               " was not stored"
    pid = find_process_pid(step, process_name)
    assert world.process_pids[process_name] == pid,\
                   "Expected pid: " + str(world.process_pids[process_name]) +\
                   " Got pid: " + str(pid)

@step('set bundy configuration (\S+) to (.*)(?: with cmdctl port (\d+))?')
def config_set_command(step, name, value, cmdctl_port):
    """
    Run bundyctl, set the given configuration to the given value, and commit it.
    Parameters:
    name ('configuration <name>'): Identifier of the configuration to set
    value ('to <value>'): value to set it to.
    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
                the command to. Defaults to 56175.
    Fails if cmdctl does not exit with status code 0.
    """
    commands = ["config set " + name + " " + value,
                "config commit",
                "quit"]
    run_bundyctl(commands, cmdctl_port)

@step('send bundy the following commands(?: with cmdctl port (\d+))?')
def send_multiple_commands(step, cmdctl_port):
    """
    Run bundyctl, and send it the given multiline set of commands.
    A quit command is always appended.
    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
                the command to. Defaults to 56175.
    Fails if cmdctl does not exit with status code 0.
    """
    commands = step.multiline.split("\n")
    # Always add quit
    commands.append("quit")
    run_bundyctl(commands, cmdctl_port)

@step('remove bundy configuration (\S+)(?: value (\S+))?(?: with cmdctl port (\d+))?')
def config_remove_command(step, name, value, cmdctl_port):
    """
    Run bundyctl, remove the given configuration item, and commit it.
    Parameters:
    name ('configuration <name>'): Identifier of the configuration to remove
    value ('value <value>'): if name is a named set, use value to identify
                             item to remove
    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
                the command to. Defaults to 56175.
    Fails if cmdctl does not exit with status code 0.
    """
    cmd = "config remove " + name
    if value is not None:
        cmd = cmd + " " + value
    commands = [cmd,
                "config commit",
                "quit"]
    run_bundyctl(commands, cmdctl_port)

@step('send bundy(?: with cmdctl port (\d+))?( ignoring failure)? the command (.+)')
def send_command(step, cmdctl_port, ignore_failure, command):
    """
    Run bundyctl, send the given command, and exit bundyctl.
    Parameters:
    command ('the command <command>'): The command to send.
    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
                the command to. Defaults to 56175.
    ignore_failure ('ignoring failure', optional): set to None if bundyctl
    is expected to succeed (normal case, which is the default); if it is
    not None, it means bundyctl is expected to fail (and it's acceptable).

    Fails if bundyctl does not exit with status code 0 and ignore_failure
    is not None.

    """
    commands = [command,
                "quit"]
    run_bundyctl(commands, cmdctl_port, ignore_failure is not None)

@step('bundy module (\S+) should( not)? be running')
def module_is_running(step, name, not_str):
    """
    Convenience step to check if a module is running; can only work with
    default cmdctl port; sends a 'help' command with bundyctl, then
    checks if the output contains the given name.
    Parameters:
    name ('module <name>'): The name of the module (case sensitive!)
    not ('not'): Reverse the check (fail if it is running)
    """
    if not_str is None:
        not_str = ""
    step.given('send bundy the command help')
    step.given('last bundyctl output should' + not_str + ' contain ' + name + ' exactly')

@step('Configure BUNDY to run DDNS')
def configure_ddns_on(step):
    """
    Convenience compound step to enable the bundy-ddns module.
    """
    step.behave_as("""
    When I send bundy the following commands
        \"\"\"
        config add Init/components bundy-ddns
        config set Init/components/bundy-ddns/kind dispensable
        config set Init/components/bundy-ddns/address DDNS
        config commit
        \"\"\"
    """)

@step('Configure BUNDY to stop running DDNS')
def configure_ddns_off(step):
    """
    Convenience compound step to disable the bundy-ddns module.
    """
    step.behave_as("""
    When I send bundy the following commands
        \"\"\"
        config remove Init/components bundy-ddns
        config commit
        \"\"\"
    """)

@step('query statistics(?: (\S+))? of bundy module (\S+)(?: with cmdctl port (\d+))?')
def query_statistics(step, statistics, name, cmdctl_port):
    """
    query statistics data via bundyctl.
    Parameters:
    statistics  ('statistics <statistics>', optional) : The queried statistics name.
    name ('module <name>'): The name of the module (case sensitive!)
    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
                the command to.
    """
    port_str = ' with cmdctl port %s' % cmdctl_port \
        if cmdctl_port else ''
    step.given('send bundy%s the command Stats show owner=%s%s'\
        % (port_str, name,\
               ' name=%s' % statistics if statistics else ''))

@step('statistics counters are 0 in category (\S+)( except for the' + \
          ' following items)?')
def check_statistics_items(step, category, has_except_for):
    """
    check the output of bundyctl for statistics of specified counter.
    Parameters:
    category ('category <category>'): The category of counter.
    has_except_for ('except for the following items'): checks values of items
        with the multiline part.

    Expected values of items are taken from the multiline part of the step in
    the scenario. The multiline part has at most four columns: item_name,
    item_value, min_value, and max_value. item_name is a relative name
    to category. item_value is an expected value for
    item_name. min_value and max_value are expected to be used when
    item_value cannot be specified to be item_value. min_value is the
    minimum value in the expected range, and max_value is the maximum
    value in the expected range. Values would be examined if they are
    in columns corresponding to these.
    """

    def flatten(dictionary, prefix=''):
        h = {}
        for k, v in dictionary.items():
            if type(v) is dict:
                h.update(flatten(v, prefix+'.'+k))
            else:
                h[prefix+'.'+k] = v
        return h

    stats = flatten(parse_bundyctl_output_as_data_structure())
    if has_except_for:
        # fetch step tables in the scnario as hashes
        for item in step.hashes:
            name = category+'.'+item['item_name']
            assert stats.has_key(name), \
                'Statistics item %s was not found' % (name)
            found = stats[name]
            if 'item_value' in item and item['item_value']:
                value = item['item_value']
                assert int(found) == int(value), \
                    'Statistics item %s has unexpected value %s (expect %s)' % \
                    (name, found, value)
            if 'min_value' in item and item['min_value']:
                value = item['min_value']
                assert float(value) <= float(found), \
                    'Statistics item %s has unexpected value %s (expect %s or greater than)' % \
                    (name, found, value)
            if 'max_value' in item and item['max_value']:
                value = item['max_value']
                assert float(found) <= float(value), \
                    'Statistics item %s has unexpected value %s (expect %s or less than)' % \
                    (name, found, value)
            del(stats[name])
    for name, found in stats.items():
        assert int(found) == 0, \
            'Statistics item %s has unexpected value %s (expect %s)' % \
                (name, found, 0)

@step('check initial statistics(?:( not)? containing (\S+))? for (\S+)'
      '( with cmdctl port \d+)?( except for the following items)?')
def check_init_statistics(step, notv, string, name, cmdctl_port, has_except_for):
    """Checks the initial statistics for the module. Also checks a
    string is contained or not contained in them. Statistics counters
      other than zero can follow below.
    Parameters:
    notv ('not'): reverse the check (fail if string is found)
    string ('containing <string>') string to look for
    name ('module <name>'): The name of the module (case sensitive!)
    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
                the command to.
    has_except_for ('except for the following items'): checks values of items
        with the multiline part.
    """
    query_str = 'query statistics of bundy module ' + name
    if cmdctl_port:
        query_str = query_str + cmdctl_port
    notcontain_str = 'last bundyctl output should%s contain "%s"'
    check_str = 'statistics counters are 0 in category .' + name
    if has_except_for:
        check_str = check_str + has_except_for + "\n" \
            + step.represent_hashes()
    step.given(query_str)
    step.given(notcontain_str % (' not', 'error'))
    if string is not None:
        step.given(notcontain_str % (notv, string))
    step.given(check_str)
