"""
The python implementation of SolMOSA that works on compiled Solidity \
Smart Contracts.

Prerequisites:
    - An Ethereum blockchain simulator should be running.
    - All the settings need to be specified in a config file
"""
import os
import json
import ast
import subprocess
import datetime
import logging
import numpy as np
import sys

from CDG import CDG
from SmartContract import SmartContract
from Test_Suite import TestSuite

np.set_printoptions(threshold=sys.maxsize)


def SolMOSA(config):
    """
    Apply genetic algorithm to iteratively generate an optimal test suite.

    Arguments:
        - config_file_location: The location of a config_file that specifies \
        all the parameters necessary for the SolMOSA algorithm.
    Outputs:
        - archives: A list containing the best test cases for each branch at \
        each generation of the algorithm,
        the final archive in the list contains the best test cases found by \
        the algorithm.
        - tSuite: The TestSuite object that was used by the SolMOSA algorithm.
        - runtime: The time it took to generate the tests.
        - iterations: The number of times the genetic loop was executed.
    """
    # Register the time when the algorithm starts
    start_time = datetime.datetime.now()
    dir_path = os.path.dirname(os.path.realpath(__file__))

    # Read Configuration. Parameters for initiating the Blockchain.
    ETH_port = config['Blockchain']['ETH_port']
    max_accounts = int(config['Parameters']['max_accounts'])
    accounts_file_location = dir_path + "/"\
        + config['Files']['accounts_file_location']

    # Parameters for creating a Control-Dependency-Graph
    contract_json_location = config['Files']['contract_json_location']
    predicates = eval(config['CFG']['Predicates'])
    ignoreFallback = config['CFG']['ignoreFallback'] == "True"

    # Parameters for creating Test-Cases
    population_size = int(config['Parameters']['population_size'])
    max_method_calls = int(config['Parameters']['max_method_calls'])
    min_method_calls = int(config['Parameters']['min_method_calls'])
    maxArrayLength = int(config['Parameters']['maxArrayLength'])
    minArrayLength = int(config['Parameters']['minArrayLength'])
    maxWei = int(config['Parameters']['maxWei'])
    addresspool = eval(config['Parameters']['addresspool'])
    ETHpool = eval(config['Parameters']['ETHpool'])
    intpool = eval(config['Parameters']['intpool'])
    stringpool = eval(config['Parameters']['stringpool'])
    passBlocks = config['Parameters']['passBlocks'] == "True"
    passTime = config['Parameters']['passTime'] == "True"
    passTimeTime = int(config['Parameters']['passTimeTime'])
    ignoreFunctionNames = eval(config['Parameters']['IgnoreFunctions'])
    ignoreStateVariables = eval(config['Parameters']['ignoreStateVariables'])
    zeroAddress = config['Parameters']['zeroAddress'] == "True"
    nonExistantAccount = config['Parameters']['nonExistantAccount']
    if nonExistantAccount == 'None':
        nonExistantAccount = None

    # Parameters for mutating test cases
    # crossover_probability\
    #     = float(config['Parameters']['crossover_probability'])
    # remove_probability = float(config['Parameters']['remove_probability'])
    # change_probability = float(config['Parameters']['change_probability'])
    # insert_probability = float(config['Parameters']['insert_probability'])
    # tournament_size = int(config['Parameters']['tournament_size'])

    # Parameters that specify the scope of the experiment
    search_budget = int(config['Parameters']['search_budget'])
    memory_efficient = config['Parameters']['memory_efficient'] == "True"

    accounts, contract_json, contract_name, deployed_bytecode, bytecode, abi\
        = get_ETH_properties(ETH_port, max_accounts, accounts_file_location,
                             contract_json_location)

    if eval(config['Parameters']['deploying_accounts']) == []:
        deploying_accounts = accounts
    else:
        deploying_accounts = eval(config['Parameters']['deploying_accounts'])

    if ignoreStateVariables:
        ignoreFunctionNames, functionNames = update_ignoreFunctionNames(
            ignoreFunctionNames, contract_json, ignoreFallback)
    if len(ignoreFunctionNames) > 0:
        logging.info(f"Ignoring the following functions: ")
        for ignoreFunctionName in ignoreFunctionNames:
            logging.info(f"{ignoreFunctionName}")
    logging.info(f"Looking only at the following functions: ")
    for functionName in functionNames:
        logging.info(f"{functionName}")

    # Create the CDG
    logging.info("Creating CDG...")
    cdg = CDG(contract_name, deployed_bytecode, predicates)
    cdg.LT(predicates)
    logging.info("Contract CDG has been created and looks as follows:\n")
    cdg.Show_CDG(log=True)

    logging.info("Creating Smart Contract Instance...")
    sc = SmartContract(contract_json, cdg, ignoreFunctionNames, functionNames)
    functionNames = functionNames + sc.methodSigs

    logging.info("Initialising Random Test Suite...")
    tSuite = TestSuite(sc, accounts, _maxArrayLength=maxArrayLength,
                       _deploying_accounts=deploying_accounts,
                       _addresspool=addresspool,
                       _ETHpool=ETHpool,
                       _intpool=intpool,
                       _stringpool=stringpool,
                       _pop_size=population_size, _random=True, _tests=[],
                       _max_method_calls=max_method_calls,
                       _min_method_calls=min_method_calls,
                       _passBlocks=passBlocks, _passTime=passTime,
                       _passTimeTime=passTimeTime, _zeroAddress=zeroAddress,
                       _nonExistantAccount=nonExistantAccount,
                       _maxWei=maxWei, _minArrayLength=minArrayLength)

    logging.info("Smart Contract Under investigation: {}"
                 .format(contract_json_location))
    relevant_targets = determine_relevant_targets(
        cdg.CompactEdges, cdg.CompactNodes, ignoreFunctionNames,
        functionNames, log=True, _ignoreFallback=ignoreFallback)

    if sum(relevant_targets) == 0:
        logging.info("No branching paths were detected!")
        return [], tSuite, (datetime.datetime.now()
                            - start_time).total_seconds(), 0, 0, []

    test_inputs = tSuite.generate_test_inputs()
    with open("tests.txt", "w") as f:
        f.write(test_inputs)

    callstring = "node SC_interaction.js".split(
    ) + ["--abi"] + [abi] + ["--bytecode"] + [bytecode] + ["--ETH_port"]\
        + [ETH_port]

    blockchain_start_time = datetime.datetime.now()
    logging.info("Deploying and calling smart contracts for the first time...")

    with open("Ganache_Interaction.log", "a") as f:
        subprocess.call(callstring, stdout=f)
    blockchain_end_time = datetime.datetime.now()
    blockchain_time = blockchain_end_time - blockchain_start_time

    logging.info("Reading Debugs...")
    with open('debugs.txt', 'r') as f:
        callResults = f.read()

    logging.info("Reading Returnvals...")

    with open('returnvals.txt', 'r') as f:
        returnvals = f.read().split(",")

    results = ast.literal_eval(callResults)

    logging.info("Updating test distances...")
    tSuite.update_test_distances(results, returnvals)

    init_archive = [None] * len(tSuite.smartContract.CDG.CompactEdges)
    parents = set(tSuite.tests)
    offspring = parents

    archive = update_archive(parents, init_archive, relevant_targets,
                             tSuite.smartContract.CDG.CompactEdges) 
    archives = [archive]
    testSuites = [tSuite]
    # updated_targets = update_targets(parents, archive, relevant_targets)

    # Fs = preference_sorting(parents, updated_targets, population_size)

    # for F in Fs:
    # subvector_dist(F, updated_targets)

    # poss_methods = tSuite.smartContract.methods[1:]

    # Keep track of number of iterations necessary to achieve branch coverage
    iterations = 1

    for i in range(search_budget):
        logging.info("\nEntering main loop iteration {}/{} at {}:{}"
                     .format(i + 1,
                             search_budget, datetime.datetime.now().date(),
                             datetime.datetime.now().time()))

        logging.info("{} out of {} branches have been covered"
                     .format(len([test for test, relevant in
                                  zip(archive, relevant_targets) if
                                  (test is not None) & (relevant)]),
                             len([test for test, relevant in
                                  zip(archive, relevant_targets) if
                                  relevant])))

        # Cancel if branch coverage has already been achieved.
        # Otherwise, log the branches that still need to be covered.
        finished = True
        logging.info("Still need to cover:")
        for k, relTest in enumerate(archive):
            if (relTest is None) & (relevant_targets[k]):
                cdg.CompactEdges[k].show_CompactEdge(log=True)
                finished = False
        if finished:
            if i == 0:
                logging.info("Branch coverage was achieved after random"
                             "initialisation")
            else:
                logging.info("Branch coverage was achieved at iteration {}"
                             .format(i))
            break

        # Update the iteration counter
        iterations += 1

        # logging.info("\tGenerating Offspring...")
        # offspring = generate_offspring(
        #     parents, sc, accounts, maxArrayLength, addresspool, ETHpool,
        #     intpool, stringpool, deploying_accounts, poss_methods,
        #     population_size, min(tournament_size, population_size),
        #     max_method_calls, crossover_probability, remove_probability,
        #    change_probability, insert_probability, passTimeTime, zeroAddress,
        #     nonExistantAccount, maxWei, minArrayLength)

        tSuite = TestSuite(sc, accounts, _maxArrayLength=maxArrayLength,
                           _deploying_accounts=deploying_accounts,
                           _addresspool=addresspool,
                           _ETHpool=ETHpool,
                           _intpool=intpool,
                           _stringpool=stringpool,
                           _pop_size=population_size, _random=True, _tests=[],
                           _max_method_calls=max_method_calls,
                           _min_method_calls=min_method_calls,
                           _passBlocks=passBlocks, _passTime=passTime,
                           _passTimeTime=passTimeTime,
                           _zeroAddress=zeroAddress,
                           _nonExistantAccount=nonExistantAccount,
                           _maxWei=maxWei, _minArrayLength=minArrayLength)

        offspring = set(tSuite.tests)

        test_inputs = tSuite.generate_test_inputs()
        with open("tests.txt", "w") as f:
            f.write(test_inputs)

        logging.info("\tDeploying and testing...")

        blockchain_start_time = datetime.datetime.now()
        if memory_efficient:
            if i % 10 == 1:
                # We restart the Ganache blockchain for memory efficiency
                logging.info("\tResetting Blockchain...")
                callstring = 'screen -S ganache -X stuff "^C"'
                os.system(callstring)
                # Clear old blockchain from the /tmp directory
                callstring = "rm -r /tmp/tmp-*"
                subprocess.call(callstring, shell=True)
                #  Start new instance of Ganache with a ridiculus amount of
                # ether for each account
                callstring = 'screen -S ganache -X stuff \
                "ganache-cli -d -e 100000000000000000000\r"'
                os.system(callstring)
                callstring = "node get_accounts --ETH_port".split()\
                    + [ETH_port] + ["--max_accounts"]\
                    + ["{}".format(max_accounts)]\
                    + ["--accounts_file_location"] + [accounts_file_location]
                with open("Ganache_Interaction.log", "a") as f:
                    subprocess.call(callstring, stdout=f)

        callstring = "node SC_interaction.js".split() + ["--abi"] + [abi]\
            + ["--bytecode"] + [bytecode] + ["--ETH_port"] + [ETH_port]

        with open("Ganache_Interaction.log", "a") as f:
            subprocess.call(callstring, stdout=f)
        blockchain_time += datetime.datetime.now() - blockchain_start_time

        logging.info("\tReading Results...")
        with open('debugs.txt', 'r') as f:
            callResults = f.read()

        with open('returnvals.txt', 'r') as f:
            returnvals = f.read().split(",")

        results = ast.literal_eval(callResults)

        logging.info("\tUpdating test distances...")
        tSuite.update_test_distances(results, returnvals)

        archive = update_archive(offspring, archive, relevant_targets,
                                 tSuite.smartContract.CDG.CompactEdges)
    # updated_targets = update_targets(offspring, archive, relevant_targets)
        # R = parents.union(offspring)
        #
        # Fs = preference_sorting(R, updated_targets, population_size)
        # parents = set()
        # dom_front = 0
        # while len(parents) + len(Fs[dom_front]) < population_size:
        #     subvector_dist(Fs[dom_front], updated_targets)
        #     parents = parents.union(Fs[dom_front])
        #     dom_front += 1
        # F = Fs[dom_front]
        # subvector_dist(F, updated_targets)
        # G = list(F)
        # G.sort(key=lambda tCase: tCase.subvector_dist)
        # parents = parents.union(set(G[:population_size - len(parents)]))
        # assert len(parents) == population_size,\
        #     "The set of new parents should be of a size equal to the \
        #     population size."
        archives = archives + [archive]
        testSuites = testSuites + [tSuite]

    archive = update_archive(offspring, archive, relevant_targets,
                             tSuite.smartContract.CDG.CompactEdges)
    runtime = datetime.datetime.now() - start_time
    return archives, tSuite, runtime.total_seconds(), \
        blockchain_time.total_seconds(), iterations, relevant_targets


def update_ignoreFunctionNames(_ignoreFunctionNames, _contract_json,
                               _ignoreFallback=False):
    """Add the stateVariables to the ignoreFunctionNames."""
    ignoreFunctionNames = _ignoreFunctionNames
    functionNames = []
    contract_json = _contract_json
    stateVariables = []
    infoNode = next((node for node in contract_json['ast']['nodes'] if
                     node['nodeType'] == "ContractDefinition"), None)
    for node in infoNode['nodes']:
        if "stateVariable" in node.keys():
            name = node["name"]
            if not node["stateVariable"]:
                logging.warning(f"There was a node with stateVariable in the "
                                "json but the value is not True!")
            else:
                recurNode = node["typeName"]
                inputvars = ""
                while recurNode['nodeType'] == "Mapping":
                    if len(inputvars) > 1:
                        inputvars = inputvars + ","
                    assert "keyType" in recurNode.keys(), \
                        "A mapping node was found without a 'typeName' field"
                    assert "valueType" in recurNode.keys(), \
                        "A mapping node was found without a 'valueType' field"
                    inputvars = inputvars + recurNode["keyType"]["name"]
                    recurNode = recurNode["valueType"]
                stateVariables.append(name + "(" + inputvars + ")")
        elif node["nodeType"] == "FunctionDefinition":
            if "isConstructor" in node.keys():
                if not node["isConstructor"]:
                    name = node["name"]
                    if len(name) > 0:
                        functionNames.append(name)
                    elif not _ignoreFallback:
                        functionNames.append("_fallback")
            else:
                name = node["name"]
                if (len(name) > 0) & (name.lower() != "constructor"):
                    functionNames.append(name)

    return list(set(ignoreFunctionNames).union(set(stateVariables))), \
        functionNames


def get_ETH_properties(ETH_port, max_accounts, accounts_file_location,
                       contract_json_location):
    """
    Fetch relevant information for the deployment of and interaction with the\
    smart contract.

    Arguments:
    ETH_port:       The port at which the Ethereum blockchain simulator is
                    listening.
    max_accounts:   The number of accounts that can be used to interact with
                    the blockchain.
    accounts_file_location: The location of the file the accounts will be
                            written to by the get_accounts.js procedure.
    Outputs:
    accounts:   The accounts on the blockchain that will be used for the
                deployment of and interaction with the smart contract.
    """
    callstring = "node get_accounts --ETH_port".split() + [ETH_port]\
        + ["--max_accounts"] + ["{}".format(max_accounts)]\
        + ["--accounts_file_location"] + [accounts_file_location]
    with open("Ganache_Interaction.log", "w") as f:
        subprocess.call(callstring, stdout=f)
    with open(accounts_file_location) as f:
        res = f.read()
        accounts = res.split(',')

    with open(contract_json_location) as f:
        contract_json = json.load(f)

    contract_name = contract_json['contractName']
    deployed_bytecode = contract_json['deployedBytecode']
    bytecode = contract_json['bytecode']
    bytecode = '"' + contract_json['bytecode'] + '"'
    abi = str(contract_json['abi'])
    abi = abi.replace("True", "true")
    abi = abi.replace("False", "false")
    return accounts, contract_json, contract_name, \
        deployed_bytecode, bytecode, abi


def update_archive(tests, archive, relevant_targets, _edges):
    """
    Replace the archived tests by better tests, given an archive and a set of \
    potentially better tests.

    Arguments:
    tests:              The current generation of tests.
    archive:            The current list of best tests.
    relevant_targets:   Indicators of which tests are important for branch
                        coverage.
    Outputs:
    archive:    The new and updated archive
    """
    j = 0
    for i, (best_test, relevant) in enumerate(zip(archive, relevant_targets)):
        if relevant:
            for test in tests:
                if test.distance_vector[i] == 0:
                    if best_test is None:
                        logging.info(
                            f"There was no best test yet for relevant_target "
                            f"{j} with edge:\n")
                        _edges[i].show_CompactEdge(log=True)
                        logging.info(f"now entering the archive is test: \n")
                        test.show_test(log=True)
                        best_test = test
                    elif len(test.methodCalls) < len(best_test.methodCalls):
                        logging.info(
                            f"A better test was found for relevant_target {i} "
                            "with edge:\n")
                        _edges[i].show_CompactEdge(log=True)
                        logging.info(f"the old test was:\n")
                        best_test.show_test(log=True)
                        logging.info("\nThe new test is:\n")
                        test.show_test(log=True)
                        best_test = test
            j += 1
        archive[i] = best_test
    return archive


def update_targets(tests, archive, relevant_targets):
    """
    Identify the targets that are reached but not satisfied by the current \
    generation of tests.

    Inputs:
    tests:              The current generation of tests.
    archive:            The list of best test cases that satisfy specific
                        targets.
    relevant_targets:   The list of targets that should be taken into account
                        for branch coverage.
    Outputs:
    updated_targets:    The list of targets that are reached but not satisfied
                        by the current generation of tests.
    """
    updated_targets = [False] * len(archive)
    for i, (best_test, relevant) in enumerate(zip(archive, relevant_targets)):
        if relevant:
            if best_test is not None:
                pass
            else:
                for test in tests:
                    if (test.distance_vector[i] > 0) & \
                            (test.distance_vector[i] <= 1):
                        updated_targets[i] = True
                        break
    return updated_targets


def determine_relevant_targets(_compactEdges, _compactNodes,
                               _ignoreFunctionNames, _functionNames,
                               log=False, _ignoreFallback=True):
    """
    Ignore the edges in the dispactcher or the fallback function if it has \
    not been explicitly defined, as well as the edges in functions that can be\
    ifnored.

    Inputs:
    _compactEdges: The edges of the CDG of the smart contract.
    _ignoreFunctionNames: The names of the functions that can be ignored.
    _FunctionNames: The list of names of the functions that all the relevant
                    Functions are in.
    log:           Indication of whether the relevant targets should be logged.
    Outputs:
    relevant_targets: An ordered list of Booleans indicating whether each edge
                      is relevant for branch coverage.
    """
    relevant_targets = [True] * len(_compactEdges)

    for i, cEdge in enumerate(_compactEdges):
        startNode = next((cNode for cNode in _compactNodes if
                          cNode.node_id == cEdge.startNode_id), None)
        endNode = next((cNode for cNode in _compactNodes if
                        cNode.node_id == cEdge.endNode_id), None)
        assert (startNode is not None) & (endNode is not None), \
            "Failed to find a startNode or endNode in "\
            "determine_relevant_targets!"
        if (cEdge.endNode_id[0] == "_dispatcher") |\
                (cEdge.startNode_id[0] in _ignoreFunctionNames) |\
                (cEdge.endNode_id[0] in _ignoreFunctionNames) |\
                (not any([id[0].startswith(tuple(_functionNames)) for id in
                          endNode.all_node_ids]))\
                | (not any([id[0].startswith(tuple(_functionNames)) for id in
                            endNode.all_node_ids])):
            relevant_targets[i] = False
        elif (cEdge.endNode_id[0] == "_fallback") & (_ignoreFallback):
            relevant_targets[i] = False
    if log:
        logging.info("Relevant targets have been identified as follows: \n")
        j = 0
        for relevant, cEdge in zip(relevant_targets, _compactEdges):
            if relevant:
                logging.info(f"Target {j}")
                cEdge.show_CompactEdge(log=True)
                j += 1
    return relevant_targets


def show_relevant_targets(_compactEdges, _relevant_targets):
    """Show the relevant targets and their order."""
    logging.debug("Showing Relevant Targets")
    for cEdge, rt in zip(_compactEdges, _relevant_targets):
        if rt:
            cEdge.show_CompactEdge(log=True)


def log_du(path):
    """Log disk usage in human readable format (e.g. '2,1GB')."""
    for file in os.listdir(path):
        logging.debug(
            "file" + subprocess.check_output(['du', '-hcs', path + file])
            .split()[0].decode('utf-8'))
