#!/usr/bin/env python
#
# Copyright (c) 2013 Red Hat, Inc.
#
# 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.

"""Dynamically load Python modules, run taskrunner with a pipeline from them.

The task configurations (or pipelines, or a combination of them) specified on
the command line will be read from the dynamically loaded Python modules, put
into a pipeline and executed by taskrunner.
"""

import datetime
import os
import imp
import sys
import logging
from copy import copy
from optparse import OptionParser

import taskrunner

LOG = logging.getLogger('taskrunner')


def parse_args():
    """Get positional arguments and options.

    Prints a help message if the arguments are incorrect.
    :returns: filepath, task_names, options
    """
    parser = OptionParser(usage="%prog -f FILE TASKS [OPTIONS]")
    parser.add_option("-f", "--file", dest="files",
                      action="append",
                      help="Python source code with the task configurations."
                           " You can use more than one of them")
    choices = ['always', 'never', 'pronto', 'on_success', 'on_failure']
    parser.add_option("-c", "--cleanup", dest="cleanup",
                      type="choice", choices=choices, default='always',
                      help="'always' - default behaviour;"
                           " 'never' - don't run the cleanups;"
                           " 'pronto' - only do the cleanups;"
                           " 'on_success' - only clean up if the run was OK;"
                           " 'on_failure' - only clean up if the run failed;")
    parser.add_option("-D", "--redefine", dest="redefine",
                      action="append",
                      help="Can be used multiple times. In the format"
                           " 'taskname.key=newvalue' or"
                           " 'taskname.key1.key2.key3...=newvalue', where"
                           " 'taskname' is either the name of the task given"
                           " in its config dictionary or the name of the"
                           " class. If more tasks have the same name, all will"
                           " be redefined.")
    choices = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
    parser.add_option("--log-level", dest="log_level",
                      type="choice", choices=choices, default='INFO',
                      help="Select logging level out of %s. Default is 'INFO'"
                           % choices)
    parser.add_option("--no-timestamp", dest="log_time",
                      action="store_false", default=True,
                      help="Do not write timestamp at the beggining of each"
                      " log line.")

    options, tasks = parser.parse_args()
    if not tasks:
        print "No task was specified"
        parser.print_help()
        exit(1)
    if not options.files:
        print "No files were specified"
        parser.print_help()
        exit(1)

    return tasks, options


def import_python_file(filepath):
    """Dynamically import python source file.

    :returns: (module_name, module)
    """
    name = os.path.basename(filepath).split('.')[0]
    return (name, imp.load_source(name, filepath))


def get_var_from_modules(modules, var):
    """Find the variable in one of the modules

    If `var` is 'moduleA.varname', where 'moduleA' is a name of one of the
    modules in the `modules` dict, it will load it from there. If it is just
    'varname', it will try to find it in all of them. If it is
    'moduleX.varname' and 'moduleX' isn't in the `modules` dict, it will try to
    find it in all the modules.

    :param var: string with the variable name, or something like `moduleA.name`
    :returns: variable content
    """
    loaded_var = None
    parts = var.split('.', 1)
    if len(parts) > 1 and parts[0] in modules:
        loaded_var = get_var_from_module(modules[parts[0]], parts[1])
    else:
        for module in modules.values():
            loaded_var = get_var_from_module(module, var)
            if loaded_var is not None:
                break

    if loaded_var is not None:
        return loaded_var
    else:
        raise Exception("Variable '%s' was not found" % var)


def get_var_from_module(module, var):
    """Get module.var

    If var is something like `moduleA.moduleB.name`, it will return
    `module.moduleA.moduleB.name`.

    :param var: string with the variable name, or something like `moduleA.name`
    """
    parts = var.split('.', 1)
    if len(parts) == 1:
        if var in vars(module):
            return vars(module)[var]
        else:
            return None
    else:
        # the var is in an included module
        module = vars(module)[parts[0]]
        return get_var_from_module(module, parts[1])


def get_pipeline(modules, var_names):
    """Get a list of task configurations from the module.

    Find var_names in the module and add them to the pipeline.

    :param var_names: list of strings with the names of the task
        configurations, or their module name + their name, like
        'moduleA.mytask' (you don't need to specify the module name if you
        don't have any task name conflicts)
    :returns: a list of dictionaries (aka task configurations)
    """
    pipeline = []
    for name in var_names:
        var = get_var_from_modules(modules, name)
        if type(var) is list or type(var) is tuple:
            pipeline += var
        else:
            pipeline.append(var)
    return pipeline


def redefine_configuration(pipeline, redefinitions):
    """Using strings like `taskname.key=newvalue`, redefine task configs.

    :param pipeline: list of task configurations
    :param redefinitions: list of strings in the format `taskname.key=newvalue`
        or `taskname.key1.key2=newvalue`, where 'taskname' is either the 'name'
        provided in the task configuration, or the class name of the task. It
        will overwrite all tasks which match the name.
    """
    if not redefinitions:
        return
    for redef in redefinitions:
        try:
            keys, value = redef.split('=', 1)
        except ValueError:
            raise RedefinitionError(
                "Config redefinition has to contain at least one '='")

        try:
            task_name, keys = keys.split('.', 1)
        except ValueError:
            raise RedefinitionError(
                "Config redefinition has to contain at least one '.'"
                " in the key section")

        tasks = [t for t in pipeline
                 if ('name' in t and t['name'] == task_name)
                 or t['task'].__name__ == task_name]
        if not tasks:
            raise RedefinitionError("No task with name '%s' found in pipeline"
                                    % task_name)
        LOG.info("Redefining '%s.%s' into '%s' for %d tasks"
                 % (task_name, keys, value, len(tasks)))
        for task in tasks:
            _set_deep(task, keys.split('.'), value)


def _set_deep(dictionary, keys, value):
    """Set value in a multidimensional dictionary with a list of keys.

    If len(keys) is 3, it will do the equivalent of:
        dictionary[key[0]][key[1]][key[2]] = value
    :param dictionary: a dict or Python module
    :param keys: list of keys in the dictionary
    """
    keys = copy(keys)
    while len(keys) > 1:
        key = keys.pop(0)
        dictionary = dictionary[key]
    dictionary[keys[0]] = value


def _log_errors(run_failures, cleanup_failures):
    """Log a shortened description for each error that occured, in order"""
    if run_failures or cleanup_failures:
        LOG.info("========================================================")
        LOG.info("Tasks finished unsuccessfully with the following errors:")
    if run_failures:
        for error in run_failures:
            LOG.error("Exception '%s' in '%s.run': %s",
                      error['name'], error['task'], error['msg'])
    if cleanup_failures:
        for error in cleanup_failures:
            LOG.error("Exception '%s' in '%s.cleanup': %s",
                      error['name'], error['task'], error['msg'])


class RedefinitionError(Exception):
    pass


if __name__ == '__main__':
    task_names, options = parse_args()

    if options.log_time:
        log_format = taskrunner.logs.FORMAT_TIME
    else:
        log_format = taskrunner.logs.FORMAT_SIMPLE
    taskrunner.logs.set_logging_options(
        log_format=log_format,
        log_level=options.log_level)

    LOG.info("************* TaskRunner start [%s UTC] *************"
             % datetime.datetime.utcnow())

    modules = dict(import_python_file(f) for f in options.files)
    pipeline = get_pipeline(modules, task_names)
    redefine_configuration(pipeline, options.redefine)

    try:
        taskrunner.execute(pipeline, cleanup=options.cleanup)
        LOG.info("************* TaskRunner end [%s UTC] *************"
                 % datetime.datetime.utcnow())
    except taskrunner.TaskExecutionException, ex:
        _log_errors(ex.context['_taskrunner']['run_failures'],
                    ex.context['_taskrunner']['cleanup_failures'])
        LOG.info("************* TaskRunner failed [%s UTC] *************"
                 % datetime.datetime.utcnow())
        sys.exit(1)
