#!/usr/bin/python
# -*- mode: Python; -*-

import sys
import os
import os.path
from optparse import OptionParser, make_option
from shutil import copy
from subprocess import call, Popen, PIPE
from xml.dom.minidom import parse
import hashlib
import itertools
import linecache

test_function_prefix = 'test_'

visited_tests = []
performed_tests = []
performed_tests_count = 0
retest_conf_file = '.retest'
gccxmlprogname = 'gccxml'

cxx_flags_loaded = False

SHELL = "/bin/bash"
MAKE_CMD = None
CXXFLAGS = None
LDFLAGS = None

# Utility functions
def basefilename(file_name):
    name = os.path.splitext(file_name)[0]
    while name[0] == '.':
        name = name[1:]
    return name

def orig_source_name(file_name):
    name = '%s.c' % basefilename(file_name)
    if os.path.exists(name):
        return name
    elif os.path.exists(name + 'c'):
        return name + 'c'
    else:
        # oh well...
        return name + 'c'

def source_name(file_name):
    return '.%s' % orig_source_name(file_name)

def exec_name(file_name):
    return '.%s.exec' % basefilename(file_name)

def conf_name(file_name):
    return '.%s.conf' % basefilename(file_name)

def test_cmp(x, y):
    return x[1]['line'] - y[1]['line']

def gen_fn_hash(source, node):
    begin = int(node.getAttribute('line'))
    if(node.getAttribute('endline') != ''):
        end = int(node.getAttribute('endline'))
    else:
        end = begin
    m = hashlib.md5()
    for i in range(begin, end+1):
        line = linecache.getline(source, i)
        if line == '':
            raise IOError('failed to get line %d from file %s' % (i, source))
        m.update(line)
    return m.hexdigest()

# Inspect the test file source code and automatically generate a file
# that can run unit tests dynamically.
def gen_main_file(test_file, dom):
    config = { 'test_list' : [], 'tests' : {} }
    for node in dom.getElementsByTagName("Function"):
        fn_name = node.getAttribute('name')
        if fn_name.startswith(test_function_prefix):
            config['tests'][fn_name] = { 'known_state' : None,
                                         'hash' : gen_fn_hash(test_file, node),
                                         'line' : int(node.getAttribute('line'))}
    # Generate a sorted test list
    test_list = sorted(config['tests'].items(), test_cmp)
    for (fn, test) in test_list:
        config['test_list'].append(fn)
        del config['tests'][fn]['line']
    # Generate the source file
    copy(test_file, source_name(test_file))
    with open(source_name(test_file), 'a') as gen:
        gen.write('\n/*** GENERATED CODE ***/\n\n')
        gen.write('#include <string.h>\n\n')
        gen.write('int main(int argc, char *argv[]) {\n')
        gen.write('    if(argc != 2) {\n')
        gen.write('        return -1;\n')
        gen.write('    }\n\n')
        for test in config['test_list']:
            gen.write('    if(strcmp(argv[1], "%s") == 0) {\n'
                      % test[len(test_function_prefix):])
            gen.write('        %s();\n' % test)
            gen.write('    }\n')
        gen.write('    return 0;\n')
        gen.write('}\n')
    return config

def gen_conf_file(test_file, conf):
    with open(conf_name(test_file), 'w') as gen:
        gen.write(repr(conf))

def load_config(conf_file):
    with open(conf_file, 'r') as old_conf_file:
        conf = eval(old_conf_file.readline())
    return conf

def compare_configs(new_conf, conf_file):
    if os.path.exists(conf_file):
        old_conf = load_config(conf_file)
        for test, data in new_conf['tests'].iteritems():
            if (test in old_conf['tests'] and
                old_conf['tests'][test]['hash'] == data['hash']):
                data['known_state'] = old_conf['tests'][test]['known_state']
    return new_conf

def print_test_status(test, res, options, linelength):
    global performed_tests_count
    spaces = reduce(lambda s, _: s + ' ', range(linelength - len(test)), '')
    if options.show_skips or res != 'skip':
        print '%s%s : %s' % (spaces, test, res)
    if res != 'skip':
        performed_tests_count += 1

def print_results():
    print("Ran %d of %d, %d failed"
          % (len(performed_tests),
             len(visited_tests),
             len(filter(lambda x: not x[1], performed_tests))))

def run_tests(exec_file, conf, options):
    global visited_tests, performed_tests
    print 'Running tests in %s...' % orig_source_name(exec_file)
    linelength = max(len(test) for test in conf['test_list']) + 2
    for test in conf['test_list']:
        visited_tests.append(test)
        if options.full or not conf['tests'][test]['known_state']:
            proc = Popen(['./%s' % exec_file, test[len(test_function_prefix):]],
                         stdout=PIPE, stderr=PIPE)
            out, err = proc.communicate()
            ret = proc.wait()
            print_test_status(test, ret == 0 and 'ok' or 'fail', options, linelength)
            if ret != 0:
                sys.stdout.write(out)
                sys.stderr.write(err)
                print ''
            performed_tests.append((test, (ret == 0)))
            conf['tests'][test]['known_state'] = (ret == 0)
        else:
            print_test_status(test, 'skip', options, linelength)
    try:
        itertools.dropwhile(lambda p: p[1], performed_tests).next()
        print 'Test failure.  Saving "%s" for inspection.' % source_name(exec_file)
    except StopIteration:
        if os.path.exists(source_name(exec_file)):
            os.unlink(source_name(exec_file))
    return conf

def load_compiler_flags():
    global cxx_flags_loaded
    old_cwd = None
    cwd = os.path.normpath(os.path.realpath(os.getcwd()))
    conf = None
    while True:
        temp_path = os.path.join(cwd, retest_conf_file)
        if os.path.exists(temp_path):
            conf = temp_path
        if conf or (cwd == old_cwd) or cxx_flags_loaded:
            break
        old_cwd = cwd
        cwd = os.path.normpath(os.path.realpath(os.path.join(cwd, '..')))
    if conf and (cxx_flags_loaded != conf):
        cxx_flags_loaded = conf
        execfile(conf, globals())
    elif not cxx_flags_loaded:
        print("No configuration file found. Please run 'retest init'")
        sys.exit(-1)

def process_test_file(file, options):
    # Some files
    xml_file = '%s.xml' % file
    source_file = source_name(file)
    exec_file = exec_name(file)
    conf_file = conf_name(file)
    # Check the times
    if(not (os.path.exists(exec_file) and
            os.path.exists(conf_file) and
            os.path.getmtime(exec_file) >= os.path.getmtime(file) and
            os.path.getmtime(conf_file) >= os.path.getmtime(file))):
        # Remove generated files
        if os.path.exists(xml_file):
            os.unlink(xml_file)
        if os.path.exists(source_file):
            os.unlink(source_file)
        if os.path.exists(exec_file):
            os.unlink(exec_file)
        # Generate XML out of the file
        whichproc = Popen(['which', gccxmlprogname], stdout=PIPE)
        gccxmlprog, _ = whichproc.communicate()
        ret = whichproc.wait()
        if ret != 0:
            print >>sys.stderr, "Couldn't find %s.  Please install it." % gccxmlprogname
            sys.exit(-1)
        gccxmlargs = [gccxmlprog.strip(), '-fxml=%s' % xml_file]
        if CXXFLAGS:
            gccxmlargs.append('--gccxml-cxxflags')
            gccxmlargs.extend(CXXFLAGS.rsplit(' '))
        gccxmlargs.append(file)
        ret = call(gccxmlargs)
        if ret != 0:
            sys.exit(-1)
        # Load the XML
        dom = parse(xml_file)
        if os.path.exists(xml_file):
            os.unlink(xml_file)
        # Generate executable
        conf = gen_main_file(file, dom)
        # Compile
        gccargs = "g++ -o %s" % exec_file
        gccargs += " "
        if CXXFLAGS:
            gccargs += CXXFLAGS
            gccargs += " "
        if LDFLAGS:
            gccargs += LDFLAGS
            gccargs += " "
        gccargs += source_file
        ret = call([SHELL, '-c', gccargs])
        if not (ret == 0):
            sys.exit(-1)

        # Compare configuration files
        conf = compare_configs(conf, conf_file)
    else:
        conf = load_config(conf_file)
    # Remove old config file
    if os.path.exists(conf_file):
        os.unlink(conf_file)
    # Run the test
    conf = run_tests(exec_file, conf, options)
    # Generate new configuration file
    gen_conf_file(file, conf)

def init_testing():
    # Load up compiler flags for the directory
    load_compiler_flags()
    # Make the main project first
    if MAKE_CMD:
        ret = call([SHELL, "-c", MAKE_CMD])
        if ret != 0:
            sys.exit(-1)

def recursive_retest(dir, options):
    init_testing()
    for root, dirs, files in os.walk(os.path.abspath(dir)):
        # Load up compiler flags for the directory
        load_compiler_flags()
        # Go through the files
        for filename in files:
            if os.path.splitext(filename)[1] in ['.c', '.cc'] and filename[0] != '.':
                os.chdir(os.path.abspath(root))
                process_test_file(filename, options)
    # Results!
    print_results()

def recursive_clean():
    for root, dirs, files in os.walk(os.path.abspath(os.getcwd())):
        for filename in files:
            if os.path.splitext(filename)[1] in ['.c', '.cc'] and filename[0] != '.':
                os.chdir(os.path.abspath(root))
                if os.path.exists('%s.xml' % filename):
                    os.unlink('%s.xml' % filename)
                if os.path.exists(source_name(filename)):
                    os.unlink(source_name(filename))
                if os.path.exists(exec_name(filename)):
                    os.unlink(exec_name(filename))
                if os.path.exists(conf_name(filename)):
                    os.unlink(conf_name(filename))

def init_retest():
    with open(retest_conf_file, 'w') as conf:
        conf.write('''# Shell to use for running the compiler
SHELL = "/bin/bash"
# A shell command to make the main project
MAKE_CMD = ""
# Arguments to pass to the compiler
CXXFLAGS = ""
# Arguments to pass to the linker
LDFLAGS = ""
''')

def main(argv):
    parser = OptionParser(
        usage='Usage: %prog [OPTIONS] [init | clean | [files|dirs]... ]',
        option_list=[
            make_option('-F', '--full', action='store_true', dest='full',
                        default=False,
                        help='run all tests, regardless of previous successes'),
            make_option('-S', '--show-skips', action='store_true', dest='show_skips',
                        default=False, help='show skipped tests')
            ])
    options, args = parser.parse_args()
    if 'clean' in args:
        if len(args) > 1:
            parser.error('clean should be specified alone')
        recursive_clean()
    elif 'init' in args:
        if len(args) > 1:
            parser.error('init should be specified alone')
        init_retest()
    elif len(args) > 0:
        # check all arguments
        for arg in args:
            if not os.path.exists(arg):
                parser.error('%s not found' % arg)
            if not (os.path.isdir(arg) or os.path.isfile(arg)):
                parser.error('%s is not a file or directory' % arg)
        # run tests
        for arg in args:
            if os.path.isfile(arg):
                if not os.path.splitext(arg)[1] in ['.c', '.cc']:
                    print >>sys.stderr, 'Warning: %s is not a C/C++ program!' % arg
                if os.path.split(arg)[0] != '':
                    saved_dir = os.getcwd()
                    os.chdir(os.path.split(arg)[0])
                init_testing()
                process_test_file(os.path.split(arg)[1], options)
                print_results()
                if os.path.split(arg)[0] != '':
                    os.chdir(saved_dir)
            else:
                recursive_retest(arg, options)
    else:
        recursive_retest(os.getcwd(), options)
    try:
        itertools.dropwhile(lambda x: x[1], performed_tests).next()
        return -1
    except StopIteration:
        return 0

if __name__ == '__main__':
    sys.exit(main(sys.argv))
