import inspect
import time

from test_junkie.constants import SuiteCategory, TestCategory
from test_junkie.debugger import LogJunkie
from test_junkie.decorators import DecoratorType
from test_junkie.errors import ConfigError, TestJunkieExecutionError, TestListenerError
from test_junkie.parallels import ParallelProcessor


class Runner:

    __STATS = {}

    def __init__(self, suites, **kwargs):

        self.__stats = {}

        self.__suites = suites
        if not isinstance(self.__suites, list):
            self.__suites = [self.__suites]
        self.__kwargs = kwargs

        self.__processor = None
        self.__run_config = {}

        self.__cancel = False

        self.__executed_suites = []

    def get_executed_suites(self):

        return self.__executed_suites

    def cancel(self):

        self.__cancel = True

    def run(self, **kwargs):
        """
        This function initiates the testing process
        :return: None
        """
        initial_start_time = time.time()
        from test_junkie.builder import SuiteBuilder

        self.__run_config = kwargs
        self.__processor = ParallelProcessor(**kwargs)

        while self.__suites:
            for suite in self.__suites:  # TODO wait due to remove call which changes list size?
                LogJunkie.info("1")
                suite_object = SuiteBuilder.get_execution_roster().get(suite, None)
                if suite_object is not None:
                    if self.__processor.suite_multithreading():
                        if self.__processor.suite_qualifies(suite_object):
                            self.__executed_suites.append(suite_object)  # TODO remove?
                            ParallelProcessor.run_suite_in_a_thread(self.__run_suite, suite_object)
                            self.__suites.remove(suite)
                    else:
                        self.__executed_suites.append(suite_object)  # TODO remove?
                        self.__run_suite(suite_object)
                        self.__suites.remove(suite)
                LogJunkie.info("2")
            LogJunkie.debug("{} Suite(s) left in queue.".format(len(self.__suites)))
            time.sleep(5)

        for suite, parallel in self.__processor.get_active_parallels().items():
            parallel["thread"].join()

        print("--- done in {} seconds ---".format(time.time() - initial_start_time))

    def __run_suite(self, suite):
        suite_start_time = time.time()
        unsuccessful_tests = None
        if not suite.can_skip() and not self.__cancel:
            for suite_retry_attempt in range(1, suite.get_retry_limit() + 1):
                if suite_retry_attempt == 1 or suite.get_status() in SuiteCategory.ALL_UN_SUCCESSFUL:
                    LogJunkie.debug("Running suite: {}".format(suite.get_class_object()))
                    LogJunkie.debug("Suite Retry {}/{}".format(suite_retry_attempt, suite.get_retry_limit()))

                    before_class_error = Runner.__run_before_class(suite)

                    if suite_retry_attempt > 1:
                        unsuccessful_tests = suite.get_unsuccessful_tests()
                        LogJunkie.debug("There are {} unsuccessful tests that need to be retried"
                                        .format(len(unsuccessful_tests)))
                        if not unsuccessful_tests:
                            break

                    for test in suite.get_test_objects():
                        test_start_time = time.time()  # will be used in case of a failure in context of this loop

                        suite.get_listener().kwargs.update({"test_meta": test.get_kwargs()})
                        suite.get_listener().kwargs.update({"test_junkie_meta":
                                                           {"function_name": test.get_function_name()}})

                        LogJunkie.debug("===============Running test: {}=================="
                                        .format(test.get_function_object()))

                        if not Runner.__positive_skip_condition(suite, test) and \
                                Runner.__runnable_tags(test_tags=test.get_tags(),
                                                       tag_config=self.__run_config.get("tag_config", None)):
                            parameters = test.get_parameters()
                            if not parameters or not isinstance(parameters, list):  # Validating parameters
                                # If parameters are invalid, will raise a meaningful exception
                                try:
                                    if isinstance(parameters, list):
                                        raise Exception("Empty parameters list in Class: {} Function: {}"
                                                        .format(suite.get_class_module(), test.get_function_name()))
                                    else:
                                        raise Exception("Wrong parameters data type. Expected: {} was: {} in Class: {} "
                                                        "Function: {}".format(list, type(parameters),
                                                                              suite.get_class_module(),
                                                                              test.get_function_name()))
                                except Exception as error:
                                    LogJunkie.print_traceback()
                                    test.metrics.update_metrics(status=TestCategory.IGNORE,
                                                                start_time=test_start_time,
                                                                exception=error)
                                    Runner.__process_event_listener(suite.get_listener().on_ignore, error=error)
                            else:  # If parameters are valid, will continue processing the test
                                for param in parameters:
                                    if unsuccessful_tests is not None:
                                        if not test._is_qualified_for_retry(param):
                                            # If test does not qualify with current parameter, will move to the next
                                            continue
                                    test_start_time = time.time()  # actual start time of the test

                                    if self.__processor.test_multithreading():
                                        if self.__processor.test_limit_reached(suite):
                                            time.sleep(5)
                                        self.__processor.run_test_in_a_thread(self.__run_test,
                                                                              suite, test, test_start_time,
                                                                              param, before_class_error)
                                    else:
                                        self.__run_test(suite=suite, test=test,
                                                        parameter=param,
                                                        test_start_time=test_start_time,
                                                        before_class_error=before_class_error)
                        else:
                            test.metrics.update_metrics(status=TestCategory.SKIP, start_time=test_start_time)
                            Runner.__process_event_listener(suite.get_listener().on_skip)

                    if self.__processor.test_multithreading():
                        tests = self.__processor.get_active_parallels()[suite.get_class_object()]["tests"]
                        for test, parallel in tests.items():
                            parallel.join()
                    Runner.__run_after_class(suite)
                    suite.metrics.update_suite_metrics(status=SuiteCategory.FAIL
                                                       if suite.has_unsuccessful_tests() else SuiteCategory.SUCCESS,
                                                       start_time=suite_start_time)
        elif self.__cancel:
            suite.metrics.update_suite_metrics(status=SuiteCategory.CANCEL, start_time=suite_start_time)
            Runner.__process_event_listener(suite.get_listener().on_class_cancel)
        else:
            suite.metrics.update_suite_metrics(status=SuiteCategory.SKIP, start_time=suite_start_time)
            Runner.__process_event_listener(suite.get_listener().on_class_skip)

    def __run_test(self, suite, test, test_start_time, parameter=None, before_class_error=None):

        suite.get_listener().kwargs["test_junkie_meta"].update({"parameter": parameter})

        if before_class_error is not None or self.__cancel:
            _status = TestCategory.IGNORE if not self.__cancel else TestCategory.CANCEL
            _event = suite.get_listener().on_ignore if not self.__cancel else suite.get_listener().on_cancel
            test.metrics.update_metrics(status=_status,
                                        start_time=test_start_time,
                                        param=parameter,
                                        exception=before_class_error)
            Runner.__process_event_listener(_event, error=before_class_error)
            return

        for retry_attempt in range(1, test.get_retry_limit() + 1):
            LogJunkie.debug(">> Test Case: {} Param: {} Retry Attempt: {}/{}"
                            .format(test.get_function_object(), parameter, retry_attempt, test.get_retry_limit()))
            try:
                suite.get_rules().before_test()
                Runner.__process_decorator(decorator_type=DecoratorType.BEFORE_TEST, suite=suite)
                Runner.__process_decorator(decorator_type=DecoratorType.TEST_CASE, suite=suite,
                                           test=test, parameter=parameter)
                Runner.__process_decorator(decorator_type=DecoratorType.AFTER_TEST, suite=suite)
                suite.get_rules().after_test()
                Runner.__process_event_listener(suite.get_listener().on_success)
                return
            except Exception as error:

                if not isinstance(error, TestJunkieExecutionError):
                    LogJunkie.print_traceback()
                    __category = TestCategory.ERROR
                    __event = suite.get_listener().on_error
                    if isinstance(error, AssertionError):
                        __category = TestCategory.FAIL
                        __event = suite.get_listener().on_failure
                    test.metrics.update_metrics(status=__category,
                                                start_time=test_start_time,
                                                param=parameter,
                                                exception=error)
                    Runner.__process_event_listener(__event, error=error)
                else:
                    raise error

    @staticmethod
    def __run_before_class(suite):
        before_class_error = None
        try:
            suite.get_rules().before_class()
            Runner.__process_decorator(decorator_type=DecoratorType.BEFORE_CLASS, suite=suite)
        except Exception as error:
            if isinstance(error, AssertionError):
                Runner.__process_event_listener(suite.get_listener().on_before_class_failure, error=error)
            else:
                Runner.__process_event_listener(suite.get_listener().on_before_class_error, error=error)
            before_class_error = error
        return before_class_error

    @staticmethod
    def __run_after_class(suite):
        try:
            Runner.__process_decorator(decorator_type=DecoratorType.AFTER_CLASS, suite=suite)
            suite.get_rules().after_class()
        except Exception as error:
            if isinstance(error, AssertionError):
                Runner.__process_event_listener(suite.get_listener().on_after_class_failure, error=error)
            else:
                Runner.__process_event_listener(suite.get_listener().on_after_class_error, error=error)

    @staticmethod
    def __process_decorator(decorator_type, suite, test=None, parameter=None):
        start_time = time.time()
        if DecoratorType.TEST_CASE != decorator_type:
            functions_list = suite.get_decorated_definition(decorator_type)
            for func in functions_list:
                try:
                    func["decorated_function"](func["decorated_function"])
                    suite.metrics.update_decorator_metrics(decorator_type, start_time)
                except Exception as error:
                    suite.metrics.update_decorator_metrics(decorator_type, start_time, error)
                    raise error
        else:
            if "parameter" in inspect.getargspec(test.get_function_object()).args:  # supports Python 2
                test.get_function_object()(test.get_function_object(), parameter=parameter)
            else:
                test.get_function_object()(test.get_function_object())
            test.metrics.update_metrics(status=TestCategory.SUCCESS, start_time=start_time, param=parameter)

    @staticmethod
    def __process_event_listener(event_function, error=None):
        try:
            if error is not None:
                event_function(error)
            else:
                event_function()
        except Exception:
            LogJunkie.print_traceback()
            raise TestListenerError("Exception occurred while processing custom event listener with: {}"
                                    .format(event_function))

    @staticmethod
    def __runnable_tags(test_tags, tag_config):
        """
        This function evaluates if test should be executed based on its tags and the tag config
        :param test_tags: LIST of tags for the test case
        :param tag_config: DICT tag configuration
        :return: BOOLEAN
        """
        if not test_tags and tag_config is not None:
            # tests without tags will be skipped if tags were provided to the runner
            return False

        if tag_config is not None:
            try:
                if tag_config.get("skip_on_match_all", None) is not None:
                    for tag in tag_config["skip_on_match_all"]:
                        if tag not in test_tags:
                            return True
                    return False
                elif tag_config.get("skip_on_match_any", None) is not None:
                    for tag in tag_config["skip_on_match_any"]:
                        if tag in test_tags:
                            return False
                elif tag_config.get("run_on_match_all", None) is not None:
                    for tag in tag_config["run_on_match_all"]:
                        if tag not in test_tags:
                            return False
                elif tag_config.get("run_on_match_any", None) is not None:
                    for tag in tag_config["run_on_match_any"]:
                        if tag in test_tags:
                            return True
                    return False
                else:
                    raise ConfigError("Incorrect configuration is set for `tag_config`")
            except Exception as error:
                if not isinstance(error, ConfigError):
                    raise ConfigError("Failed to parse `tag_config`. Make sure tags are provided as a list")
                raise error
        return True

    @staticmethod
    def __positive_skip_condition(suite, test):
        """
        This function will evaluate inputs for skip parameter whether its a function or a boolean
        :param suite: SuiteObject object
        :param test: TestObject object
        :return: BOOLEAN
        """
        val = test.can_skip()
        if inspect.isfunction(val):
            try:
                if "meta" in inspect.getargspec(val).args:  # deprecated but supports Python 2
                    val = val(meta=suite.get_listener().kwargs)
                else:
                    val = val()
                assert isinstance(val, bool), "Function: {} must return a boolean. Got: {}".format(val, type(val))
            except Exception:
                LogJunkie.print_traceback()
                raise TestJunkieExecutionError("Encountered error while processing skip condition")
        return val

    def get_stats(self):
        from test_junkie.metrics import StatsJunkie
        # pprint.pprint(self.__stats)
        return StatsJunkie(self.__stats)
