import time
import platform
import os
from test_framework._dv_interface import dv_module, start_and_initialize
import hashlib
import requests
from datetime import datetime
import sys
from test_framework._test_params import test_params, skipped_tests, NUM_PRIOR_RESULTS_FOR_PERFORMANCE_TESTS, STATS_TOLERANCE_STD_DEV, NUM_REQUIRED_PRIOR_RESULTS_FOR_STATS_TEST
import numpy as np
import json
import matplotlib.pyplot as plt


def test_modules_with_io(self, modules, input_parameters=None, output_parameters=None):
    modules = prepend_input(modules, input_parameters)
    modules = append_outputs(modules, output_parameters)

    return test_modules(self, modules)


def test_io_with_no_module(self, output_inputs, input_parameters=None, output_parameters=None):
    modules = prepend_input([], input_parameters, output_inputs)
    modules = append_outputs(modules, output_parameters)

    return test_modules(self, modules)


def test_modules_with_input(self, modules, input_parameters=None):
    modules = prepend_input(modules, input_parameters)

    return test_modules(self, modules)


def test_modules_with_outputs(self, modules, output_parameters=None):
    modules = append_outputs(modules, output_parameters)

    return test_modules(self, modules)


def test_modules(self, modules):
    # Check whether this test should be run at all
    check_run(self)

    print("\n")

    test_repetitions = check_perf()

    for i in np.arange(test_repetitions):
        (dv_interface, test_name, start_time, expected_output_hashes, expected_output_file_sizes,
         expected_written_data_sizes) = initialize_test(self, modules)

        create_modules(self, dv_interface, modules)

        stats = run_test(self, dv_interface, modules, expected_output_hashes, expected_output_file_sizes,
                         expected_written_data_sizes)

        finish_test(self, dv_interface, start_time, stats)


def check_perf():
    test_repetitions = 1

    if test_params.get("test_performance", False):
        # performance test is required
        os.environ["DV_PERF_DATA"] = os.environ.get("DV_PERF_OUTPUT_DIR", None) + "/perf_{}.json".format(
            test_params["name"])
        print("\tUsing performance data in: " + os.environ["DV_PERF_DATA"])
        os.environ["DV_WRITE_PERFORMANCE_DATA"] = "0"

        if not os.path.isfile(os.environ["DV_PERF_DATA"]) or os.stat(os.environ["DV_PERF_DATA"]).st_size == 0:
            # performance test required, but performance data not available
            print("\tPerformance data file not found or empty, will initialize data")
            os.environ["DV_WRITE_PERFORMANCE_DATA"] = "1"
            test_params["test_performance"] = False
            test_repetitions = NUM_PRIOR_RESULTS_FOR_PERFORMANCE_TESTS
            f = open(os.environ["DV_PERF_DATA"], 'w')
            f.close()

        if os.stat(os.environ["DV_PERF_DATA"]).st_size != 0 and os.environ.get("DV_PERF_DATA_APPEND", False) != "0":
            # performance data available, but appending to data was requested
            print("\tWill append to existing performance data file")
            os.environ["DV_WRITE_PERFORMANCE_DATA"] = "1"
            test_params["test_performance"] = False
            test_repetitions = NUM_PRIOR_RESULTS_FOR_PERFORMANCE_TESTS

    return test_repetitions


def initialize_test(self, modules):
    test_name = test_params["name"]

    # Start time
    start = time.time()

    # Set expected output data
    output_hashes = {}
    output_file_sizes = {}
    output_written_data_sizes = {}
    output_modules = get_output_modules(modules)
    for output in output_modules:
        name = output.name
        if test_params.get(name + "_hash", None) is not None:
            output_hashes[name] = test_params.get(name + "_hash")
        if test_params.get(name + "_file_size", None) is not None:
            output_file_sizes[name] = test_params.get(name + "_file_size")
        if test_params.get(name + "_written_data_size", None) is not None:
            output_written_data_sizes[name] = test_params.get(output.name + "_written_data_size")

    # Check input file hash
    input_module = get_input_modules(modules)[0]
    input_file = test_params['input_file']
    input_hash = {input_module.name: test_params['input_hash']}
    verify_file_hash(self, input_file, input_module, input_hash)

    # Start runtime and initialize
    dv_interface = start_and_initialize(test_params["name"] + "_log.txt")

    return (dv_interface, test_name, start, output_hashes, output_file_sizes, output_written_data_sizes)


def run_test(self, dv_interface, modules, expected_output_hashes, expected_output_file_sizes,
             expected_written_data_sizes):
    stats = []
    for i in np.arange(test_params.get("num_runs", 1)):
        dv_interface.run_modules(modules[::-1] if is_input_module(modules[0]) else modules)

        dv_interface.wait_until_stopped(modules)

        stats.append(collect_stats(dv_interface, modules))

    check_output_files(self, dv_interface, get_output_modules(modules), expected_output_hashes,
                       expected_output_file_sizes, expected_written_data_sizes)

    stats = concatenate_stats(stats)

    if test_params.get("test_performance", False):
        prior_stats = read_stats_from_file()

        plot_stats(stats, prior_stats)

        verify_stats(self, stats, prior_stats)

    return stats


def finish_test(self, dv_interface, start_time, stats):
    dv_interface.remove_all_modules()

    runtime_output = []  # We use a list because can be modified as if the reference was passed as a parameter
    closing = dv_interface.close(runtime_output=runtime_output)
    assert_close(self, closing)

    expected_to_fail = test_params.get("expected_to_fail", False)
    ignore_fail = test_params.get("ignore_fail", False)

    check_runtime_output(self, runtime_output, ignore_fail, expected_to_fail)

    test_time = round((time.time() - start_time) * 1000)

    print("\tTest executed in {}s".format(test_time / 1000))

    if os.environ.get("DV_WRITE_PERFORMANCE_DATA", "0") == "1":
        dump_stats_to_file(stats)


def prepend_input(modules, input_parameters=None, input_outputs=None):
    default_input_parameters = [["timeDelay", "0"], ["file", test_params["input_file"]]]
    params = []
    if input_parameters is not None:
        params = default_input_parameters + input_parameters
    else:
        params = default_input_parameters

    input_outputs = get_all_required_input_ports(modules) if input_outputs is None else input_outputs
    input = dv_module("input", outputs=input_outputs, config_options=params, library="dv_input_file")

    modules = [input] + modules

    return modules


def append_outputs(modules, output_parameters=None):
    modules_under_test = get_modules_under_test(modules)

    output_index = 0
    for module in modules_under_test:
        for output in module.outputs:
            default_output_parameters = [["compression", "NONE"], ["directory",
                                                                   os.getenv('DV_TEST_OUTPUT_DIR')],
                                         [
                                             "prefix", "{}_{}_output{}_{}".format(test_params["name"], module.name,
                                                                                  output_index, output.name)
                                         ]]
            params = []
            if output_parameters is not None:
                params = default_output_parameters + output_parameters
            else:
                params = default_output_parameters

            inputs = [["output0", "{}[{}]".format(module.name, output.name)]]
            output_module = dv_module("output{}".format(output_index),
                                      config_options=params,
                                      inputs=inputs,
                                      library="dv_output_file")

            modules.append(output_module)

            output_index += 1

    return modules


def get_output_modules(modules):
    outputs = []
    for module in modules:
        if is_output_module(module):
            outputs.append(module)

    return outputs


def is_output_module(module):
    return "dv_output_file" in module.library or "dv_export_csv" in module.library or "dv_image_output" in module.library or "dv_livestream" in module.library or "dv_output_net_socket" in module.library or "dv_output_net_tcp_server" in module.library or "dv_video_output" in module.library or "dv_image_output" in module.library


def get_input_modules(modules):
    inputs = []
    for module in modules:
        if is_input_module(module):
            inputs.append(module)

    return inputs


def is_input_module(module):
    return "dv_input_file" in module.library or "dv_davis" in module.library or "dv_dvs128" in module.library or "dv_dvs132s" in module.library or "dv_dvxplorer" in module.library or "dv_edvs" in module.library or "dv_mvcamera" in module.library or "dv_samsung_evk" in module.library or "dv_converter" in module.library


def get_modules_under_test(modules):
    modules_under_test = []
    inputs = get_input_modules(modules)
    outputs = get_output_modules(modules)

    if len(inputs) + len(outputs) == len(modules):
        modules_under_test = modules
    else:
        for module in modules:
            is_input_or_output = False
            for input in inputs:
                if module.name in input.name:
                    is_input_or_output = True

            for output in outputs:
                if module.name in output.name:
                    is_input_or_output = True

            if not is_input_or_output:
                modules_under_test.append(module)

    return modules_under_test


def get_number_of_actual_outputs(modules):
    return len(get_output_modules(modules))


def get_number_of_required_outputs(modules):
    required_outputs = 0

    actual_outputs = get_number_of_actual_outputs(modules)
    required_outputs -= actual_outputs

    modules_under_test = get_modules_under_test(modules)

    for module in modules_under_test:
        if not is_output_module(module):
            required_outputs += len(module.outputs)

    return required_outputs


def get_number_of_inputs(modules):
    return len(get_input_modules(modules))


def collect_stats(dv_interface, modules):
    stats = {"cpu_time": {}, "cycle_time": {}, "throughput": {}}

    for module in modules:
        stats["cpu_time"][module.name] = dv_interface.get_cpu_time(module)
        stats["cycle_time"][module.name] = dv_interface.get_cycle_time_stats(module)
        stats["throughput"][module.name] = dv_interface.get_throughput_stats(module)

    return stats


def verify_stats(self, stats, prior_stats):
    if len(prior_stats) > 0:
        for module in prior_stats["cpu_time"]:
            if len(prior_stats["cpu_time"][module]) >= NUM_REQUIRED_PRIOR_RESULTS_FOR_STATS_TEST:
                threshold = get_threshold_max(prior_stats["cpu_time"][module])
                print("\tVerify cpu time for module {}".format(module))
                print("\t\tExpected maximum: {}".format(threshold))
                print("\t\tActual:           {}".format(np.mean(stats["cpu_time"][module])))

                self.assertLessEqual(np.mean(stats["cpu_time"][module]), threshold)

            if len(prior_stats["cycle_time"][module]["mean"]) >= NUM_REQUIRED_PRIOR_RESULTS_FOR_STATS_TEST:
                threshold = get_threshold_max(prior_stats["cycle_time"][module]["mean"])
                print("\tVerify cycle time for module {}".format(module))
                print("\t\tExpected maximum: {}".format(threshold))
                print("\t\tActual:           {}".format(np.mean(stats["cycle_time"][module]["mean"])))

                self.assertLessEqual(np.mean(stats["cycle_time"][module]["mean"]), threshold)

            for input in prior_stats["throughput"][module]:
                if len(prior_stats["throughput"][module][input]["mean"]) >= NUM_REQUIRED_PRIOR_RESULTS_FOR_STATS_TEST:
                    threshold = get_threshold_min(prior_stats["throughput"][module][input]["mean"])
                    print("\tVerify throughput for input {} of module {}".format(input, module))
                    print("\t\tExpected minimum: {}".format(threshold))
                    print("\t\tActual:           {}".format(np.mean(stats["throughput"][module][input]["mean"])))

                    self.assertGreaterEqual(np.mean(stats["throughput"][module][input]["mean"]), threshold)


def plot_stats(stats, prior_stats):
    if len(prior_stats) > 0:
        colours = ["black", "blue", ["red", "green"]]
        labels = ["error", "success"]

        for module in stats["cycle_time"]:
            fig, axs = plt.subplots(2 + len(stats["throughput"][module]), 1, constrained_layout=True)
            fig.suptitle('Performance stats ' + test_params["name"] + "/" + module)

            threshold = get_threshold_max(prior_stats["cpu_time"][module])
            result = np.mean(stats["cpu_time"][module]) <= threshold
            axs[0].hist(prior_stats["cpu_time"][module], bins=30, color=colours[0], label="prior runs")
            axs[0].axvline(threshold, label="maximum expected value")
            axs[0].axvline(np.mean(stats["cpu_time"][module]),
                           color=colours[2][result],
                           label="current run: " + labels[result])
            axs[0].set_ylabel("cpu_time")
            axs[0].legend()

            threshold = get_threshold_max(prior_stats["cycle_time"][module]["mean"])
            result = np.mean(stats["cycle_time"][module]["mean"]) <= threshold
            axs[1].hist(prior_stats["cycle_time"][module]["mean"], bins=30, color=colours[0], label="prior runs")
            axs[1].axvline(threshold, label="maximum expected value")
            axs[1].axvline(np.mean(stats["cycle_time"][module]["mean"]),
                           color=colours[2][result],
                           label="current run: " + labels[result])
            axs[1].set_ylabel("cycle_time")
            axs[1].legend()

            i = 2
            for input in stats["throughput"][module]:
                threshold = get_threshold_min(prior_stats["throughput"][module][input]["mean"])
                result = np.mean(stats["throughput"][module][input]["mean"]) >= threshold
                axs[i].hist(prior_stats["throughput"][module][input]["mean"],
                            bins=30,
                            color=colours[0],
                            label="prior runs")
                axs[i].axvline(threshold, label="minimum expected value")
                axs[i].axvline(np.mean(stats["throughput"][module][input]["mean"]),
                               color=colours[2][result],
                               label="current run: " + labels[result])
                axs[i].set_ylabel(input + "/throughput")
                axs[i].legend()
                i += 1

            fig.savefig(os.environ['DV_PERF_OUTPUT_DIR'] + os.path.sep + test_params["name"] + "_" + module +
                        "_perf.png")
            plt.close(fig)


def concatenate_stats(stats):
    concatenated_stats = {"cpu_time": {}, "cycle_time": {}, "throughput": {}}

    num_runs = len(stats)

    for module in stats[0]["cpu_time"]:
        concatenated_stats["cpu_time"][module] = []
        concatenated_stats["cycle_time"][module] = {"mean": [], "var": [], "min": [], "max": []}
        concatenated_stats["throughput"][module] = {}

        for input in stats[0]["throughput"][module]:
            concatenated_stats["throughput"][module][input] = {"mean": [], "var": [], "min": [], "max": []}

        for i in np.arange(num_runs):
            if isinstance(stats[i]["cpu_time"][module], list):
                concatenated_stats["cpu_time"][module].extend(stats[i]["cpu_time"][module])
            else:
                concatenated_stats["cpu_time"][module].append(stats[i]["cpu_time"][module])

            for stat in stats[0]["cycle_time"][module]:
                if isinstance(stats[i]["cycle_time"][module][stat], list):
                    concatenated_stats["cycle_time"][module][stat].extend(stats[i]["cycle_time"][module][stat])
                else:
                    concatenated_stats["cycle_time"][module][stat].append(stats[i]["cycle_time"][module][stat])

                for input in stats[0]["throughput"][module]:
                    if isinstance(stats[i]["throughput"][module][input][stat], list):
                        concatenated_stats["throughput"][module][input][stat].extend(
                            stats[i]["throughput"][module][input][stat])
                    else:
                        concatenated_stats["throughput"][module][input][stat].append(
                            stats[i]["throughput"][module][input][stat])

    return concatenated_stats


def dump_stats_to_file(stats):
    if os.path.exists(os.environ["DV_PERF_DATA"]) and os.stat(os.environ["DV_PERF_DATA"]).st_size != 0:
        # remove trailing ] and replace with comma
        with open(os.environ["DV_PERF_DATA"], 'rb+') as f:
            f.seek(-1, os.SEEK_END)
            f.truncate()
        with open(os.environ["DV_PERF_DATA"], 'a+') as f:
            f.write(",\n")
    else:
        with open(os.environ["DV_PERF_DATA"], 'w') as f:
            f.write("[\n")

    with open(os.environ["DV_PERF_DATA"], "a+") as f:
        f.write(json.dumps(stats))
        f.write("\n]")


def read_stats_from_file():
    stats = []
    if os.path.exists(os.environ["DV_PERF_DATA"]) and os.stat(os.environ["DV_PERF_DATA"]).st_size != 0:
        with open(os.environ["DV_PERF_DATA"], "r") as f:
            stats = json.load(f)

    if stats is not None and stats != []:
        stats = concatenate_stats(stats)

    return stats


def create_modules(self, dv_interface, module_infos):
    for module in module_infos:
        dv_interface.add_module(module)


def check_output_files(self,
                       dv_interface,
                       output_modules,
                       expected_output_hashes=None,
                       expected_output_file_sizes=None,
                       expected_written_data_sizes=None):

    for module in output_modules:
        saved_file = get_output_file_name(dv_interface, module)

        if expected_output_hashes is not None and expected_output_hashes != {}:
            verify_file_hash(self, saved_file, module, expected_output_hashes)

        if expected_output_file_sizes is not None and expected_output_file_sizes != {}:
            verify_file_size(self, saved_file, module, expected_output_file_sizes)

        if output_modules is not None and output_modules != {} and expected_written_data_sizes is not None and expected_written_data_sizes != {}:
            verify_written_data_size(self, dv_interface, module, expected_written_data_sizes)


def get_output_file_name(dv_interface, module):
    if "dv_output_file" in module.library:
        return dv_interface.get("/mainloop/{}/".format(module.name), "file")
    elif "dv_export_csv" in module.library:
        return dv_interface.get("/mainloop/{}/".format(module.name), "fileName")
    elif "dv_video_output" in module.library:
        return dv_interface.get("/mainloop/{}/".format(module.name), "fileName")
    else:
        return dv_interface.get("/mainloop/{}/".format(module.name), "file")


def verify_file_hash(self, file, module, expected_hashes):
    self.assertEqual(os.path.exists(file), True, "check if file exists: " + file)
    actual_hash = hash_file(file)
    print("\tVerify file hash for module " + module.name)
    print("\t\tExpected: {}".format(expected_hashes[module.name]))
    print("\t\tActual:   {}".format(actual_hash))

    self.assertIn(actual_hash, expected_hashes[module.name], "check hashcode")

    return actual_hash


def verify_file_size(self, file, module, expected_sizes):
    self.assertEqual(os.path.exists(file), True, "check if file exists: " + file)
    actual_size = os.stat(file).st_size

    if isinstance(expected_sizes[module.name], list):
        expected_sizes[module.name].sort()

        print("\tVerify file size range for module " + module.name)
        print("\t\tExpected: {} .. {}".format(expected_sizes[module.name][0], expected_sizes[module.name][1]))
        print("\t\tActual:   {}".format(actual_size))

        self.assertGreaterEqual(actual_size, expected_sizes[module.name][0], "check file size against range min")
        self.assertLessEqual(actual_size, expected_sizes[module.name][1], "check file size against range max")
    elif expected_sizes[module.name] != -1:
        print("\tVerify file size for module " + module.name)
        print("\t\tExpected: {}".format(expected_sizes[module.name]))
        print("\t\tActual:   {}".format(actual_size))

        self.assertEqual(actual_size, expected_sizes[module.name], "check file size")

    return actual_size


def verify_written_data_size(self, dv_interface, module, expected_sizes):
    actual_size = dv_interface.get('/mainloop/{}/'.format(module.name), 'writtenDataSize')
    if isinstance(expected_sizes[module.name], list):
        expected_sizes[module.name].sort()

        print("\tVerify written data size range for module " + module.name)
        print("\t\tExpected: {} .. {}".format(expected_sizes[module.name][0], expected_sizes[module.name][1]))
        print("\t\tActual:   {}".format(actual_size))

        self.assertGreaterEqual(actual_size, expected_sizes[module.name][0],
                                "check written data size against range min")
        self.assertLessEqual(actual_size, expected_sizes[module.name][1], "check written data size against range max")
    elif expected_sizes[module.name] != -1:
        print("\tVerify written data size for module " + module.name)
        print("\t\tExpected: {}".format(expected_sizes[module.name]))
        print("\t\tActual:   {}".format(actual_size))

        self.assertEqual(actual_size, expected_sizes[module.name], "check written data size")

    return actual_size


def check_run(self):
    # Check if test requested
    if test_params["name"] in skipped_tests:
        test_params.clear()
        self.skipTest("Test not requested by user")

    # Check if test is enabled
    if test_params.get('disable', False):
        test_params.clear()
        self.skipTest("Test {} not enabled".format(test_params["name"]))

    # Check if camera required and present if so
    if test_params.get('camera_required', False) and not os.environ['DV_TEST_CAMERA_CONNECTED'] == "True":
        self.assertEqual(False, True, "Camera is not connected but test requires camera")
        test_params.clear()

    # Check if data required and present if so
    if test_params.get('data_required', False) and not os.path.exists(test_params.get("input_file", "")):
        self.assertEqual(
            False, True,
            "Required input file is not present for test. Required file: " + test_params.get("input_file", ""))
        test_params.clear()


def get_all_required_input_ports(modules):
    input_ports = []

    for module in modules:
        for input in module.inputs:
            if input.name not in input_ports:
                input_ports.append(input.name)

    return input_ports


def remove_stats_outliers(data, z_score):
    if isinstance(data, dict):
        d = np.abs(data["mean"] - np.median(data["mean"]))
        mdev = np.median(d)

        inlier_indices = d <= z_score * mdev

        inliers = {
            "mean": np.array(data["mean"])[inlier_indices],
            "var": np.array(data["var"])[inlier_indices],
            "min": np.array(data["min"])[inlier_indices],
            "max": np.array(data["max"])[inlier_indices]
        }
        return inliers
    elif isinstance(data, list) or isinstance(data, np.array):
        d = np.abs(data - np.median(data))
        mdev = np.median(d)

        inlier_indices = d <= z_score * mdev
        return np.array(data)[inlier_indices]
    else:
        raise TypeError("Unsupported type for outlier removal")


def get_threshold_max(data):
    return float(np.mean(data) + STATS_TOLERANCE_STD_DEV * np.std(data))


def get_threshold_min(data):
    return float(np.mean(data) - STATS_TOLERANCE_STD_DEV * np.std(data))


def create_output_file_name_csv():
    return create_output_file_name("csv")


def create_output_file_name_mkv():
    return create_output_file_name("mkv")


def create_output_file_name(file_type):
    return "{}/{}.{}".format(os.getenv('DV_TEST_OUTPUT_DIR'), test_params["name"], file_type)


def disable_print():
    sys.stdout = open(os.devnull, 'w')


def enable_print():
    sys.stdout = sys.__stdout__


def assert_close(class_test, closing):
    # Skip memory issue return-codes on Windows due to external libraries bugs.
    if (platform.system().upper().find("MINGW") != -1) or (platform.system() == "Windows"):
        if closing == 3221225477:
            print("Possible memory issue detected at shut-down. Verify external libraries.")
            return

    class_test.assertEqual(closing, 0)


def hash_file(file):
    hash = hashlib.sha256()

    with open(file, 'rb') as f:
        while True:
            data = f.read(16 * 1024)
            if not data:
                break
            hash.update(data)

    return hash.hexdigest()


def check_runtime_output(self, output, ignoreFail, expectedToFail):
    if output:
        error_in_log = any(err in output for err in ["EMERGENCY", "ALERT", "CRITICAL", "ERROR"])

        if expectedToFail:
            self.assertTrue(error_in_log, "Error is expected in log but didn't occur. Output: \n" + "\n".join(output))
        elif not ignoreFail:
            self.assertFalse(
                error_in_log, "Error is not expected in log, and not explicitly ignored, but occurred. Output: \n" +
                "\n".join(output))
