# -*- coding: utf-8 -*-
"""
Perform command autotest for selected command(s).
"""

__author__ = 'Grzegorz Latuszek', 'Michal Ernst', 'Michal Plichta'
__copyright__ = 'Copyright (C) 2018-2019, Nokia'
__email__ = 'grzegorz.latuszek@nokia.com', 'michal.ernst@nokia.com', 'michal.plichta@nokia.com'

import collections
import sys
from argparse import ArgumentParser
from datetime import datetime
from importlib import import_module
from os import walk, sep
from os.path import abspath, join, relpath, exists, split, dirname
from pprint import pformat

from moler.command import Command
from moler.event import Event
from moler.helpers import compare_objects


def _buffer_connection():
    """External-io based on memory FIFO-buffer"""
    from moler.io.raw.memory import ThreadedFifoBuffer
    from moler.threaded_moler_connection import ThreadedMolerConnection

    class RemoteConnection(ThreadedFifoBuffer):
        def remote_inject_response(self, input_strings, delay=0.0):
            """
            Simulate remote endpoint that sends response.
            Response is given as strings.
            """
            try:
                in_bytes = [data.encode("utf-8") for data in input_strings]
            except UnicodeDecodeError:
                in_bytes = [data.decode("utf-8").encode("utf-8") for data in input_strings]
            self.inject_response(in_bytes, delay)

        def remote_inject(self, input_strings, delay=0.0):
            """
            Simulate remote endpoint that sends response.
            Response is given as strings.
            """
            try:
                in_bytes = [data.encode("utf-8") for data in input_strings]
            except UnicodeDecodeError:
                in_bytes = [data.decode("utf-8").encode("utf-8") for data in input_strings]
            self.inject(in_bytes, delay)

    moler_conn = ThreadedMolerConnection(encoder=lambda data: data.encode("utf-8"),
                                         decoder=lambda data: data.decode("utf-8"))
    ext_io_in_memory = RemoteConnection(moler_connection=moler_conn,
                                        echo=False)  # we don't want echo on it
    return ext_io_in_memory


def _walk_moler_python_files(path, *args):
    """
    Walk thru directory with commands and search for python source code (except __init__.py)
    Yield relative filepath to parameter path

    :param path: relative path do directory with commands
    :type path:
    :rtype: str
    """
    repo_path = abspath(join(path, '..', '..'))

    observer = "event" if "events" in split(path) else "command"
    print("Processing {}s test from path: '{}'".format(observer, path))

    for (dirpath, _, filenames) in walk(path):
        for filename in filenames:
            if filename.endswith('__init__.py'):
                continue
            if filename.endswith('.py'):
                abs_path = join(dirpath, filename)
                in_moler_path = relpath(abs_path, repo_path)
                yield in_moler_path


def _walk_moler_commands(path, base_class):
    for fname in _walk_moler_python_files(path=path):
        pkg_name = fname.replace(".py", "")
        parts = pkg_name.split(sep)
        pkg_name = ".".join(parts)
        moler_module = import_module(pkg_name)
        for _, cls in moler_module.__dict__.items():
            if not isinstance(cls, type):
                continue
            if not issubclass(cls, base_class):
                continue
            module_of_class = cls.__dict__['__module__']
            # take only Commands
            # take only the ones defined in given file (not imported ones)
            if (cls != base_class) and (module_of_class == pkg_name):
                yield moler_module, cls


def _walk_moler_nonabstract_commands(path, base_class):
    """
    We don't require COMMAND_OUTPUT/COMMAND_RESULT for base classes
    however, they should be abstract to block their instantiation.

    :param path: path to python module
    :type path: str
    """
    for moler_module, moler_class in _walk_moler_commands(path, base_class):
        try:
            _ = moler_class()
        except TypeError as err:
            if "Can't instantiate abstract class" in str(err):
                continue  # ABSTRACT BASE-CLASS COMMAND - skip it
        except Exception as err:
            print(str(err))
            pass  # other error of class instantiation, maybe missing args
        yield moler_module, moler_class


def _retrieve_command_documentation(moler_module, observer_type):
    test_data = {}
    for attr, value in moler_module.__dict__.items():
        for info in ['{}_OUTPUT'.format(observer_type),
                     '{}_KWARGS'.format(observer_type),
                     '{}_RESULT'.format(observer_type)]:
            if attr.startswith(info):
                variant = attr[len(info):]
                if variant not in test_data:
                    test_data[variant] = {}
                test_data[variant][info] = value
    return test_data


def _validate_documentation_existence(moler_module, test_data, observer_type):
    """Check if module has at least one variant of output documented"""
    if len(test_data.keys()) == 0:
        expected_info = '{}_OUTPUT/{}_KWARGS/{}_RESULT'.format(observer_type, observer_type, observer_type)
        error_msg = "{} is missing documentation: {}".format(moler_module, expected_info)
        return error_msg
    return ""


def _validate_documentation_consistency(moler_module, test_data, variant, observer_type):
    errors = []
    for attr in ['{}_OUTPUT'.format(observer_type), '{}_KWARGS'.format(observer_type),
                 '{}_RESULT'.format(observer_type)]:
        if attr in test_data[variant]:
            if '{}_OUTPUT'.format(observer_type) not in test_data[variant]:
                error_msg = "{} has {} but no {}_OUTPUT{}".format(moler_module, attr + variant, observer_type, variant)
                errors.append(error_msg)
            if '{}_KWARGS'.format(observer_type) not in test_data[variant]:
                error_msg = "{} has {} but no {}_KWARGS{}".format(moler_module, attr + variant, observer_type, variant)
                errors.append(error_msg)
            if '{}_RESULT'.format(observer_type) not in test_data[variant]:
                error_msg = "{} has {} but no {}_RESULT{}".format(moler_module, attr + variant, observer_type, variant)
                errors.append(error_msg)
    return errors


def _get_doc_variant(test_data, variant, observer_type):
    cmd_output = test_data[variant]['{}_OUTPUT'.format(observer_type)]
    # COMMAND_KWARGS is optional? missing == {}
    # or we should be direct "zen of Python"
    if '{}_KWARGS'.format(observer_type) in test_data[variant]:
        cmd_kwargs = test_data[variant]['{}_KWARGS'.format(observer_type)]
    else:
        cmd_kwargs = {}
    cmd_result = test_data[variant]['{}_RESULT'.format(observer_type)]
    return cmd_output, cmd_kwargs, cmd_result


def _create_command(moler_class, moler_connection, cmd_kwargs):
    """Can we construct instance with given params?"""
    arguments = ", ".join(["{}={}".format(param, value) for (param, value) in cmd_kwargs.items()])
    constructor_str = "{}({})".format(moler_class.__name__, arguments)
    try:
        moler_cmd = moler_class(connection=moler_connection, **cmd_kwargs)
        return moler_cmd, constructor_str
    except Exception as err:
        error_msg = "Can't create command instance via {} : {}".format(constructor_str, str(err))
        raise Exception(error_msg)


def _reformat_str_to_unicode(cmd_result):
    if (isinstance(cmd_result, dict)):
        for key, value in cmd_result.items():
            if (key in cmd_result and isinstance(cmd_result[key], dict) and isinstance(cmd_result[key],
                                                                                       collections.Mapping)):
                _reformat_str_to_unicode(cmd_result[key])
            else:
                if isinstance(cmd_result[key], str):
                    cmd_result[key] = cmd_result[key].decode('utf-8')
                    print(cmd_result[key])

    return cmd_result


def _convert_str_to_unicode(input):
    if isinstance(input, dict):
        return {_convert_str_to_unicode(key): _convert_str_to_unicode(value) for key, value in input.iteritems()}
    elif isinstance(input, list):
        return [_convert_str_to_unicode(element) for element in input]
    elif isinstance(input, str):
        return input.decode('utf-8')
    else:
        return input


def _run_command_parsing_test(moler_cmd, creation_str, buffer_io, cmd_output, cmd_result, variant, base_class,
                              observer_type):
    with buffer_io:  # open it (autoclose by context-mngr)
        exclude_types = None

        if base_class is Event:
            moler_cmd.start()
            buffer_io.remote_inject([cmd_output])
            result = moler_cmd.await_done(7)
            exclude_types = {datetime}
        elif base_class is Command:
            buffer_io.remote_inject_response([cmd_output])
            result = moler_cmd()

        if sys.version_info < (3, 0):
            # workaround for python2.7 - convert str to unicode
            cmd_result = _convert_str_to_unicode(cmd_result)
            result = _convert_str_to_unicode(result)

        try:
            diff = compare_objects(cmd_result, result, significant_digits=6, exclude_types=exclude_types)
        except TypeError:
            diff = False if isinstance(cmd_result, type(result)) else True

        if diff:
            expected_result = pformat(cmd_result, indent=4)
            real_result = pformat(result, indent=4)
            diff = pformat(diff, indent=4)
            error_msg = "{} {} {} (see {}{}):\n{}\n{}:\n{}\n{}\n{}".format(observer_type, creation_str,
                                                                           'expected to return',
                                                                           '{}_RESULT'.format(observer_type), variant,
                                                                           expected_result,
                                                                           'but returned', real_result,
                                                                           'difference:', diff)
            return error_msg
    return ""


def check_cmd_or_event(path2cmds):
    if "events" in split(path2cmds):
        observer_type = "EVENT"
        base_class = Event
    else:
        observer_type = "COMMAND"
        base_class = Command

    return observer_type, base_class


def check_if_documentation_exists(path2cmds):
    """
    Check if documentation exists and has proper structure.

    :param path2cmds: relative path to comands directory
    :type path2cmds: str
    :return: True if all checks passed
    :rtype: bool
    """
    observer_type, base_class = check_cmd_or_event(path2cmds)
    wrong_commands = {}
    errors_found = []
    print()
    number_of_command_found = 0
    for moler_module, moler_class in _walk_moler_nonabstract_commands(path=path2cmds, base_class=base_class):
        number_of_command_found += 1
        print("processing: {}".format(moler_class))

        test_data = _retrieve_command_documentation(moler_module, observer_type)

        error_msg = _validate_documentation_existence(moler_module, test_data, observer_type)
        if error_msg:
            wrong_commands[moler_class.__name__] = 1
            errors_found.append(error_msg)
            continue

        for variant in test_data:
            error_msgs = _validate_documentation_consistency(moler_module, test_data, variant, observer_type)
            if error_msgs:
                wrong_commands[moler_class.__name__] = 1
                errors_found.extend(error_msgs)
                continue

            cmd_output, cmd_kwargs, cmd_result = _get_doc_variant(test_data, variant, observer_type)

            buffer_io = _buffer_connection()
            try:
                moler_cmd, creation_str = _create_command(moler_class,
                                                          buffer_io.moler_connection,
                                                          cmd_kwargs)
            except Exception as err:
                wrong_commands[moler_class.__name__] = 1
                errors_found.append(str(err))
                continue

            error_msg = _run_command_parsing_test(moler_cmd, creation_str,
                                                  buffer_io,
                                                  cmd_output, cmd_result,
                                                  variant,
                                                  base_class,
                                                  observer_type)
            if error_msg:
                wrong_commands[moler_class.__name__] = 1
                errors_found.append(error_msg)

    if errors_found:
        print("\n".join(errors_found))
        msg = "Following {} have incorrect documentation:".format(observer_type.lower())
        err_msg = "{}\n    {}".format(msg, "\n    ".join(wrong_commands.keys()))
        print(err_msg)
        return False
    if number_of_command_found == 0:
        err_msg = "No tests run! Not found any {} to test in path: '{}'!".format(observer_type.lower(), path2cmds)
        print(err_msg)
        return False
    print("All of {} processed {}s have correct documentation".format(number_of_command_found, observer_type.lower()))
    return True


if __name__ == '__main__':
    parser = ArgumentParser(description="Moler's Command(s) autotest")
    parser.add_argument('-c', '--cmd_filename', required=True, help='python module implementing given command')
    options = parser.parse_args()

    if not exists(options.cmd_filename):
        print('\n{} path doesn\'t exist!\n'.format(options.cmd_filename))
        parser.print_help()
        exit()
    else:
        check_if_documentation_exists(path2cmds=options.cmd_filename)