import traceback
import sys


def test_summary(dispindex, ntests):
    return "Test %d/%d" % (dispindex, ntests)


def show_result(testsummary, testcode, correct, got, expected, verbosity):
    """ Pretty-print(test results """
    if correct:
        if verbosity > 0:
            print("%s: Correct." % testsummary)
        if verbosity > 1:
            print("\t", testcode)
            print()
    else:
        print("%s: Incorrect." % testsummary)
        print("\t", testcode)
        print("Got:     ", got)
        print("Expected:", expected)


def show_exception(testsummary, testcode):
    """ Pretty-print(exceptions (including tracebacks) """
    print("%s: Error." % testsummary)
    print("While running the following test case:")
    print("\t", testcode)
    print("Your code encountered the following error:")
    traceback.print_exc()
    print()


def get_lab_module():
    # Try the easy way first
    try:
        from tests import lab_number
    except ImportError:
        lab_number = None

    if lab_number is not None:
        lab = __import__("lab%s" % lab_number)
        return lab

    lab = None

    for labnum in range(10):
        try:
            lab = __import__("lab%s" % labnum)
        except ImportError:
            pass

    if lab is None:
        raise ImportError(
            "Cannot find your lab; or, error importing it. "
            "Try loading it by running 'python labN.py' (for the appropriate value of 'N')."
        )

    if not hasattr(lab, "LAB_NUMBER"):
        lab.LAB_NUMBER = 5

    return lab


def type_decode(arg, lab):
    """
    XMLRPC can only pass a very limited collection of types.
    Frequently, we want to pass a subclass of 'list' in as a test argument.
    We do that by converting the sub-type into a regular list of the form:
    [ 'TYPE', (data) ] (ie., AND(['x','y','z']) becomes ['AND','x','y','z']).
    This function assumes that TYPE is a valid attr of 'lab' and that TYPE's
    constructor takes a list as an argument; it uses that to reconstruct the
    original data type.
    """
    if (
        isinstance(arg, list) and len(arg) >= 1
    ):  # We'll leave tuples reserved for some other future magic
        try:
            mytype = arg[0]
            data = arg[1:]
            return getattr(lab, mytype)([type_decode(x, lab) for x in data])
        except AttributeError:
            return [type_decode(x, lab) for x in arg]
        except TypeError:
            return [type_decode(x, lab) for x in arg]
    else:
        return arg


def type_encode(arg):
    """
    Encode trees as lists in a way that can be decoded by 'type_decode'
    """
    if isinstance(arg, list):
        return [arg.__class__.__name__] + [type_encode(x) for x in arg]
    elif hasattr(arg, "__class__") and arg.__class__.__name__ == "IF":
        # noinspection PyProtectedMember
        return [
            "IF",
            type_encode(arg._conditional),
            type_encode(arg._action),
            type_encode(arg._delete_clause),
        ]
    else:
        return arg


def run_test(test, lab):
    """
    Takes a 'test' tuple as provided by the online tester
    (or generated by the offline tester) and executes that test,
    returning whatever output is expected (the variable that's being
    queried, the output of the function being called, etc)

    'lab' (the argument) is the module containing the lab code.

    'test' tuples are in the following format:
      'id': A unique integer identifying the test
      'type': One of 'VALUE', 'FUNCTION', 'MULTIFUNCTION', or 'FUNCTION_ENCODED_ARGS'
      'attr_name': The name of the attribute in the 'lab' module
      'args': a list of the arguments to be passed to the function; [] if no args.
      For 'MULTIFUNCTION's, a list of lists of arguments to be passed in
    """
    ans_id, mytype, attr_name, args = test

    attr = getattr(lab, attr_name)

    if mytype == "VALUE":
        return attr
    elif mytype == "FUNCTION":
        try:
            return attr(*args)
        except NotImplementedError:
            print(
                "NotImplementedError: You have to implement this function before we can test it!"
            )
            return None
    elif mytype == "MULTIFUNCTION":
        return [run_test((ans_id, "FUNCTION", attr_name, FN), lab) for FN in args]
    elif mytype == "FUNCTION_ENCODED_ARGS":
        return run_test((ans_id, "FUNCTION", attr_name, type_decode(args, lab)), lab)
    else:
        raise Exception(
            "Test Error: Unknown TYPE '%s'. "
            "Please make sure you have downloaded the latest version of the tester script. "
            "If you continue to see this error, contact a TA."
        )


def test_offline(verbosity=1):
    """ Run the unit tests in 'tests.py' """
    import tests as tests_module

    #    tests = [ (x[:-8],
    #               getattr(tests_module, x),
    #               getattr(tests_module, "%s_testanswer" % x[:-8]),
    #               getattr(tests_module, "%s_expected" % x[:-8]),
    #               "_".join(x[:-8].split('_')[:-1]))
    #              for x in tests_module.__dict__.keys() if x[-8:] == "_getargs" ]

    tests = tests_module.get_tests()

    ntests = len(tests)
    ncorrect = 0

    for (
        index,
        (testname, getargs, testanswer, expected, fn_name, test_type),
    ) in enumerate(tests):
        dispindex = index + 1
        summary = test_summary(dispindex, ntests)

        try:
            if callable(getargs):
                getargs = getargs()

            answer = run_test((index, test_type, fn_name, getargs), get_lab_module())
        # noinspection PyBroadException
        except Exception:
            show_exception(summary, testname)
            continue

        correct = testanswer(answer)
        show_result(summary, testname, correct, answer, expected, verbosity)
        if correct:
            ncorrect += 1

    print("Passed %d of %d tests." % (ncorrect, ntests))
    if ncorrect == ntests:
        print(
            "You're done! Run 'python %s submit' to submit your code and have it graded."
            % sys.argv[0]
        )


if __name__ == "__main__":
    test_offline()


def make_test_counter_decorator():
    tests = []

    def make_test(getargs, testanswer, expected_val, name=None, test_type="FUNCTION"):
        if name is not None:
            getargs_name = name
        elif not callable(getargs):
            getargs_name = "_".join(getargs[:-8].split("_")[:-1])
            getargs = lambda: getargs
        else:
            getargs_name = "_".join(getargs.__name__[:-8].split("_")[:-1])

        tests.append(
            (getargs_name, getargs, testanswer, expected_val, getargs_name, test_type)
        )

    def get_tests():
        return tests

    return make_test, get_tests


make_test, get_tests = make_test_counter_decorator()
