#!/usr/bin/env python3
import pickle
import sys
import os
import zipfile
from optparse import OptionParser
import testcommon
import time

#Prevent user settings or plugins from impacting unit tests
os.environ["BN_DISABLE_USER_PLUGINS"] = "True"
os.environ["BN_DISABLE_USER_SETTINGS"] = "True"
os.environ["BN_DISABLE_REPOSITORY_PLUGINS"] = "True"

if sys.version_info.major == 2:
    print("Generate unit tests on Python 3. Python 2 is not compatible.")
    sys.exit(1)

unit_test_template = """#!/usr/bin/env python3
# This is an auto generated unit test file do not edit directly
import os
import sys
import unittest
import pickle
import zipfile
import difflib
from collections import Counter

#Prevent user settings or plugins from impacting unit tests
os.environ["BN_DISABLE_USER_PLUGINS"] = "True"
os.environ["BN_DISABLE_USER_SETTINGS"] = "True"
os.environ["BN_DISABLE_REPOSITORY_PLUGINS"] = "True"

api_suite_path = os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), {4}))
sys.path.append(api_suite_path)
# support direct invocation of configuration unit.py
commondir = os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", ".."))
sys.path.append(commondir)

debugger_suite_path = os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "..",
                                                    "public", "debugger", "test"))
sys.path.append(debugger_suite_path)

import config
import testcommon
import api_test
import debugger_test


class TestBinaryNinja(unittest.TestCase):
    # Returns a tuple of:
    #   bool   : Two lists are equal
    #   string : The string diff
    # Args:
    #   list
    #   list   : (compare list one vs list two)
    #   string : anything additional wanted to be printed before the string diff
    #   bool   : the ordering of the items in the two lists must be the same
    def report(self, oracle, test, firstText='', strictOrdering = False):
        stringDiff = ""

        equality = False
        if not strictOrdering:
            equality = (Counter(oracle) == Counter(test))
        else:
            equality = (oracle == test)

        if equality:
            return (True, '')
        elif not strictOrdering:
            try:
                for elem in oracle:
                    test.remove(elem)
                    oracle.remove(elem)  # If it's not in the test, it won't get here!
            except ValueError:
                pass

        differ = difflib.Differ(charjunk=difflib.IS_CHARACTER_JUNK)
        skipped_lines = 0
        for delta in differ.compare(oracle, test):
            if delta[0] == ' ':
                skipped_lines += 1
                continue
            if skipped_lines > 0:
                stringDiff += "<---" + str(skipped_lines) + ' same lines--->\\n'
                skipped_lines = 0
            delta = delta.replace(\'\\n\', '')
            stringDiff += delta + \'\\n\'

        stringDiffList = stringDiff.split(\'\\n\')

        if len(stringDiffList) > 10:
            if not config.verbose:
                stringDiff = \'\\n\'.join(line if len(line) <= 100 else line[:100] + "...and " + str(len(line) - 100) + " more characters" for line in stringDiffList[:10])
                stringDiff += \'\\n\\n### And ' + str(len(stringDiffList)) + " more lines, use '-v' to show ###"
        elif not config.verbose:
            stringDiff = \'\\n\'.join(line if len(line) <= 100 else line[:100] + "...and " + str(len(line) - 100) + " more characters" for line in stringDiffList)
        stringDiff = \'\\n\\n\' + firstText + stringDiff
        return (equality, stringDiff)

    @classmethod
    def setUpClass(self):
        self.builder = testcommon.TestBuilder("{3}")
        pickle_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "oracle.pkl")
        try:
            # Python 2 does not have the encodings option
            self.oracle_test_data = pickle.load(open(pickle_path, "rb"), encoding='utf8')
        except TypeError:
            self.oracle_test_data = pickle.load(open(pickle_path, "rb"))
        self.verifybuilder = testcommon.VerifyBuilder("{3}")

    def run_binary_test(self, testfile, oracle_suffix="", config_settings=None):
        testname = None
        with zipfile.ZipFile(os.path.join(api_suite_path, testfile), "r") as zf:
            testname = zf.namelist()[0]
            zf.extractall(path=api_suite_path)

        pickle_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), testname + oracle_suffix + ".pkl")
        self.assertTrue(pickle_path, "Test pickle doesn't exist")
        try:
            # Python 2 does not have the encodings option
            binary_oracle = pickle.load(open(pickle_path, "rb"), encoding='utf8')
        except TypeError:
            binary_oracle = pickle.load(open(pickle_path, "rb"))

        test_builder = testcommon.BinaryViewTestBuilder(testname, config_settings)
        for method in test_builder.methods():
            test = getattr(test_builder, method)()
            oracle = binary_oracle[method]
            if test == oracle:
                continue

            result = getattr(test_builder, method).__doc__
            result += ":\\n"
            report = self.report(oracle, test, result)
            self.assertTrue(report[0], report[1])  # Test does not agree with oracle
        os.unlink(os.path.join(api_suite_path, testname))
{1}{2}

def filter_test_suite(suite, keyword):
    result = unittest.TestSuite()
    for child in suite._tests:
        if type(child) == unittest.suite.TestSuite:
            result.addTest(filter_test_suite(child, keyword))
        elif keyword.lower() in child._testMethodName.lower():
            result.addTest(child)
    return result

def main():
    test_keyword = None
    if len(sys.argv) > 1:
        for i in range(1, len(sys.argv)):
            if sys.argv[i] == '-v' or sys.argv[i] == '-V' or sys.argv[i] == '--verbose':
                config.verbose = True
            elif sys.argv[i] == '--api-only':
                config.api_only = True
            elif sys.argv[i] == '--debugger-only':
                config.debugger_only = True
            else:
                # otherwise the argument is taken as a test case search keyword
                test_keyword = sys.argv[i]

    if config.api_only:
        runner = unittest.TextTestRunner(verbosity=2)
        test_suite = unittest.defaultTestLoader.loadTestsFromModule(api_test)
    elif config.debugger_only:
        runner = unittest.TextTestRunner(verbosity=2)
        test_suite = unittest.defaultTestLoader.loadTestsFromModule(debugger_test)
    else:
        runner = unittest.TextTestRunner(verbosity=2)
        test_suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestBinaryNinja)
        test_suite.addTest(unittest.defaultTestLoader.loadTestsFromModule(api_test))

    # if test keyword supplied, filter
    if test_keyword:
        test_suite = filter_test_suite(test_suite, test_keyword)

    runner.run(test_suite)

if __name__ == "__main__":
    main()
"""


binary_test_string = """
    def test_binary__{0}(self):
        self.run_binary_test('{1}', oracle_suffix='{2}', config_settings={3})
"""

test_string = """
    def {0}(self):
        oracle = self.oracle_test_data['{0}']
        test = self.builder.{0}()
        report = self.report(oracle, test)
        self.assertTrue(report[0], report[1])  # Test does not agree with oracle
"""

verify_string = """
    def {0}(self):
        self.assertTrue(self.verifybuilder.{0}(), self.{0}.__doc__)
"""


class OracleTestFile:
    def __init__(self, filename):
        self.f = open(filename + ".pkl", "wb")
        self.pkl = pickle.Pickler(self.f, protocol=2)
        self.filename = filename
        self.oracle_test_data = {}

    def add_entry(self, builder, test_name):
        self.oracle_test_data[test_name] = getattr(builder, test_name)()

    def close(self):
        self.pkl.dump(self.oracle_test_data)
        self.f.close()


class UnitTestFile:
    def __init__(self, filename, outdir, test_store):
        self.filename = filename
        self.test_store = test_store
        self.outdir = outdir
        self.f = open(filename, "wb")
        self.template = unit_test_template
        self.tests = ""
        self.binary_tests = ""

    def close(self):
        api_path = os.path.relpath(os.path.dirname(os.path.realpath(__file__)), start=self.outdir)
        api_path = os.path.normpath(api_path)
        api_path = map(lambda x: '"{0}"'.format(x), api_path.split(os.sep))
        api_path = '{0}'.format(', '.join(api_path))
        test_store = self.test_store.replace(os.sep, '/') if os.name == 'nt' else self.test_store
        self.f.write(self.template.format(self.outdir, self.tests, self.binary_tests, test_store, api_path).encode('utf8'))
        self.f.close()

    def add_verify(self, test_name):
        self.tests += verify_string.format(test_name)

    def add_test(self, test_name):
        self.tests += test_string.format(test_name)

    def add_binary_test(self, test_store, binary, oracle_suffix="", config_settings=None):
        name = binary[len(test_store):].replace(os.path.sep, "_").replace(".", "_")
        if os.name == 'nt':
            binary = binary.replace(os.sep, '/')
        name += oracle_suffix
        self.binary_tests += binary_test_string.format(name, binary + ".zip", oracle_suffix, config_settings)


quiet = False
def myprint(stuff):
    if not quiet:
        print(stuff)


def update_progress(complete, total, description, done=False):
    n = 20
    maxdesc = 70
    if total == 0:
        total, complete = 10, 10
    if len(description) > maxdesc:
        description = description[:maxdesc]
    elif len(description) < maxdesc:
        description += ' ' * (maxdesc - len(description))

    if not quiet:
        sys.stdout.write('\r[{0}{1}] {2:10.0f}% - {3}'.format('#' * int(n * (float(complete) / total)), ' ' * (n - int(n * (float(complete) / total))), 100 * float(complete) / total, description))
        if done:
            sys.stdout.write("\n")


class TestStoreError(Exception):
    def __init__(self, *args, **kwargs):
        Exception.__init__(self, *args, **kwargs)


def generate(test_store, outdir, exclude_binaries, config_settings=None):
    if not os.path.isdir(os.path.join(os.path.dirname(__file__), test_store)):
        raise TestStoreError("Specified test store is not a directory")

    if not os.path.exists(outdir):
        os.makedirs(outdir)

    unittest = UnitTestFile(os.path.join(outdir, "unit.py"), outdir, test_store)
    oracle = OracleTestFile(os.path.join(outdir, "oracle"))

    # check all files to see if there is any newly added ones.
    # If so, create a zip archive for it and delete the original file
    allfiles = sorted(testcommon.get_file_list(test_store))
    for progress, testfile in enumerate(allfiles):
        oraclefile = None
        zip_only = False
        if testfile.endswith(".gitignore"):
            continue
        if testfile.endswith(".pkl"):
            continue
        elif testfile.endswith(".DS_Store"):
            continue
        elif testfile.endswith(".zip"):
            continue
        else:
            if os.path.exists(testfile + ".zip"):
                # We've got a zip file for it, skip
                continue

            # create the zip archive for the file
            if not os.path.exists(testfile + ".zip"):
                with zipfile.ZipFile(testfile + ".zip", "w") as zf:
                    zf.write(testfile, os.path.relpath(testfile, start=os.path.dirname(__file__)))

            os.unlink(testfile)

    # Generate the tests that don't involve binaries but do involve oracles
    builder = testcommon.TestBuilder(test_store)
    tests = builder.methods()
    for progress, test_name in enumerate(tests):
        update_progress(progress, len(tests), "Generating test data")
        oracle.add_entry(builder, test_name)
        unittest.add_test(test_name)
    update_progress(len(tests), len(tests), "Generating test data", True)

    # Generate the tests that just verify things work as expected
    verify = testcommon.VerifyBuilder(test_store)
    tests = verify.methods()
    for progress, test_name in enumerate(tests):
        update_progress(progress, len(tests), "Generating verify data")
        unittest.add_verify(test_name)
    update_progress(len(tests), len(tests), "Generating verify data", True)

    # Now generate test that involve binaries
    allfiles = sorted(testcommon.get_file_list(test_store))
    total_progress = len(allfiles)
    for progress, testfile in enumerate(allfiles):
        oraclefile = None
        zip_only = False
        # # Disable the pe_thumb test because it is causing many test failures
        # if 'pe_thumb' in testfile:
        #     continue
        if testfile.endswith(".gitignore"):
            continue
        if testfile.endswith(".pkl"):
            continue
        elif testfile.endswith(".DS_Store"):
            continue
        elif testfile.endswith(".bndb"):
            # For databases, we do not wish to create oracle data for them
            # However, we do wish them to be zipped
            zip_only = True
            oraclefile = testfile
        elif testfile.endswith(".bndb.zip"):
            continue
        elif testfile.endswith(".zip"):
            # We have a zipped binary unzip it so we can rebaseline
            with zipfile.ZipFile(testfile, "r") as zf:
                zf.extractall(path = os.path.dirname(__file__))
            if not os.path.exists(testfile[:-4]):
                print("Error extracting testfile %s from zip: %s" % (testfile[:-4], testfile))
                continue
            oraclefile = testfile[:-4]
        else:
            if os.path.exists(testfile + ".zip"):
                # We've got a binary and zip for that binary just skip it
                continue
            # We have a binary that isn't zipped use it as a new test case
            oraclefile = testfile

        # create the zip archive for the file
        if not os.path.exists(oraclefile + ".zip"):
            with zipfile.ZipFile(oraclefile + ".zip", "w") as zf:
                zf.write(oraclefile, os.path.relpath(oraclefile, start=os.path.dirname(__file__)))

        if zip_only:
            os.unlink(oraclefile)
            continue

        testfile_basename = os.path.basename(oraclefile)
        testfile_rel = os.path.relpath(oraclefile, start=os.path.dirname(__file__))
        oraclefile_basepath = testfile_rel[:-len(testfile_basename)]
        oraclefile_rel = os.path.join(oraclefile_basepath, testfile_basename)

        # Create directory for pickle oracle results
        if not os.path.exists(os.path.join(outdir, oraclefile_basepath)):
            os.makedirs(os.path.join(outdir, oraclefile_basepath))

        config_settings_for_file = config_settings
        if testfile_basename in ['pe_thumb']:
            config_settings_for_file = {**config_settings_for_file, **{'analysis.experimental.gratuitousFunctionUpdate': True}}

        # Now generate the oracle data
        update_progress(progress, len(allfiles), oraclefile_rel)
        unittest.add_binary_test(test_store, testfile_rel, config_settings=config_settings_for_file)
        binary_start_time = time.time()
        if exclude_binaries:
            continue
        test_data = testcommon.BinaryViewTestBuilder(testfile_rel, config_settings_for_file)
        binary_oracle = OracleTestFile(os.path.join(outdir, oraclefile_rel))
        for method in test_data.methods():
            binary_oracle.add_entry(test_data, method)
        binary_oracle.close()
        print("{0:.2f}".format(time.time() - binary_start_time))

        # Generate oracle data for rebasing tests
        if testfile_basename in ["helloworld", "duff", "partial_register_dataflow", "raw"]:
            oracle_suffix = "_rebasing"
            rebasing_options = {**config_settings_for_file, **{'loader.imageBase' : 0xf00000}}
            unittest.add_binary_test(test_store, testfile_rel, oracle_suffix, rebasing_options)
            test_data = testcommon.BinaryViewTestBuilder(testfile_rel, rebasing_options)
            binary_oracle = OracleTestFile(os.path.join(outdir, oraclefile_rel) + oracle_suffix)
            for method in test_data.methods():
                binary_oracle.add_entry(test_data, method)
            binary_oracle.close()

        os.unlink(oraclefile)

    update_progress(total_progress, total_progress, "Generating binary unit tests complete", True)
    unittest.close()
    oracle.close()


def main():
    usage = "usage: %prog [-q] [-x] [-o <dir>] [-i <dir>]"
    parser = OptionParser(usage=usage)
    default_output = os.path.relpath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, "suite", "generated"))
    parser.add_option("-q", "--quiet",
                      dest="quiet", action="store_true",
                      default=False, help="Don't print anything")
    parser.add_option("-x", "--exclude",
                      dest="exclude_binary", action="store_true",
                      default=False, help="Exclude regeneration of binaries")
    parser.add_option("-o", "--outputdir", default=default_output,
                      dest="outputdir", action="store", type="string",
                      help="output directory where the unit.py and oracle.py files will be stored (relative to cwd)")
    parser.add_option("-i", "--inputdir", default=os.path.join("binaries", "test_corpus"),
                      dest="test_store", action="store", type="string",
                      help="input directory containing the binaries you which to generate unit tests from (relative to this file)")
    parser.add_option("-a", "--analysismodes",
                      dest="analysis_modes", action="store_true",
                      default=False, help="Generate additional oracle files to support analysis mode testing")

    options, args = parser.parse_args()

    test_store_location = os.path.relpath(os.path.join(testcommon.BinaryViewTestBuilder.get_root_directory(), options.test_store))
    myprint(f"[+] INFO: Input Test Corpus: {test_store_location}")
    if len(testcommon.get_file_list(options.test_store)) == 0:
        myprint(f"ERROR: Test Corpus is empty: {testcommon.get_file_list(options.test_store)}")
        sys.exit(1)

    configurations = {}
    configurations['default'] = {}
    if options.analysis_modes:
        configurations['mode_controlflow'] = {'analysis.mode' : 'controlFlow'}
        configurations['mode_basic'] = {'analysis.mode' : 'basic'}
        configurations['mode_intermediate'] = {'analysis.mode' : 'intermediate'}

    myprint("[+] INFO: Generating Automated Unit Tests and Oracle Results")
    for (name, config_settings) in configurations.items():
        oracle_target = os.path.join(options.outputdir, name)
        myprint(f"[+] INFO: Oracle Target Directory: {oracle_target}")
        try:
            generate(options.test_store, oracle_target, options.exclude_binary, config_settings)
            myprint(f"[+] SUCCESS: Generated Results for the '{name}' Configuration")
        except TestStoreError as te:
            myprint(f"[-] ERROR: Failed to Generate Results for the '{name}' Configuration: {te.message}")
            sys.exit(1)
    sys.exit(0)


if __name__ == "__main__":
    main()
