import os
from os.path import join as pjoin
import sys
import platform
import textwrap
import re
import subprocess
import difflib
import time
import types
import shutil

import SCons.Errors

class DefineDict(object):
    """
    A dictionary-like object which generates appropriate preprocessor
    define statements from its dict of variable / value
    pairs. Variables whose value is None or that are not in the dict
    are left undefined.
    """
    def __init__(self, data):
        self.data = data
        self.undefined = set()

    def __getitem__(self, key):
        if key not in self.data:
            self.undefined.add(key)
            return '/* #undef %s */' % key
        elif self.data[key] is None:
            return '/* #undef %s */' % key
        else:
            return '#define %s %s' % (key, self.data[key])


class ConfigBuilder(object):
    """
    Used along with DefineDict to generate a customized config.h file
    from a config.h.in file using the variables given in 'defines'.
    """
    def __init__(self, defines):
        self.defines = DefineDict(defines)

    def __call__(self, source, target, env):
        for s, t in zip(source, target):
            config_h_in = file(str(s), "r")
            config_h = file(str(t), "w")

            config_h.write(config_h_in.read() % self.defines)
            config_h_in.close()
            config_h.close()
            self.print_config(str(t))

    def print_config(self, filename):
        print 'Generating %s with the following settings:' % filename
        for key, val in sorted(self.defines.data.iteritems()):
            if val is not None:
                print "    %-35s %s" % (key, val)
        for key in sorted(self.defines.undefined):
            print "    %-35s %s" % (key, '*undefined*')


class TestResults(object):
    """
    A class that stores information about all the regression tests
    that are defined and which ones have passed / failed in order to
    print a summary at the end of the build process.
    """
    def __init__(self):
        self.tests = {}
        self.passed = {}
        self.failed = {}

    def printReport(self, target, source, env):
        total = len(self.passed) + len(self.failed)
        print """
**********************************
*** Regression Testing Summary ***
**********************************

Tests passed: %(passed)s
Tests failed: %(failed)s
Up-to-date tests skipped: %(skipped)s

**********************************""" % dict(
            passed=len(self.passed),
            failed=len(self.failed),
            skipped=len(self.tests))

        if self.failed:
            raise SCons.Errors.BuildError(self, 'One or more tests failed.')


testResults = TestResults()


def regression_test(target, source, env):
    """
    Run a regression test comparing the output of a test program with
    existing "blessed" output files.

    target - The name of an output file that will be generated if
    the test is successful.

    source - A list containing the name of the program to run and
    (optionally) a list of file names to be passed as command line
    arguments.

    The test also relies on several parameters that are passed in via
    variables in the SCons environment:

    env['test_command_options'] - non-file command line options
    to be passed to to the test program
    """
    # unpack:
    program = source[0]
    if len(source) > 1:
        clargs = [s.name for s in source[1:]]
    else:
        clargs = []

    # Name to use for the output file
    blessedName = env['test_blessed_file']
    if blessedName is not None and 'blessed' in blessedName:
        outputName = blessedName.replace('blessed', 'output')
    else:
        outputName = 'test_output.txt'

    # command line options
    clopts = env['test_command_options'].split()

    # Run the test program
    dir = str(target[0].dir.abspath)
    with open(pjoin(dir,outputName), 'w') as outfile:
        code = subprocess.call([program.abspath] + clopts + clargs,
                               stdout=outfile, stderr=outfile,
                               cwd=dir)

    diff = 0
    # Compare output files
    comparisons = env['test_comparisons']
    if blessedName is not None:
        comparisons.append((blessedName,outputName))

    for blessed,output in comparisons:
        print """Comparing '%s' with '%s'""" % (blessed, output)
        diff |= compareFiles(env, pjoin(dir, blessed), pjoin(dir, output))

    del testResults.tests[env['active_test_name']]

    if diff or code:
        print 'FAILED'

        if os.path.exists(target[0].abspath):
            os.path.unlink(target[0].abspath)

        testResults.failed[env['active_test_name']] = 1
    else:
        print 'PASSED'
        open(target[0].path, 'w').write(time.asctime()+'\n')
        testResults.passed[env['active_test_name']] = 1


def compareFiles(env, file1, file2):
    """
    Compare the contents of two files, using a method chosen based on
    their file extensions.
    """
    if file1.endswith('csv') and file2.endswith('csv'):
        return compareCsvFiles(env, file1, file2)
    else:
        return compareTextFiles(env, file1, file2)


def compareTextFiles(env, file1, file2):
    """
    Compare the contents of two text files while:
       - ignoring trailing whitespace
       - ignoring any lines starting with strings specified in the
         variable env['test_ignoreLines'].
       - comparing floating point numbers only up to the printed precision
    """
    text1 = [line.rstrip() for line in open(file1).readlines()
             if not line.startswith(tuple(env['test_ignoreLines']))]
    text2 = [line.rstrip() for line in open(file2).readlines()
             if not line.startswith(tuple(env['test_ignoreLines']))]

    # Try to compare the files without testing the floating point numbers
    diff = list(difflib.unified_diff(text1, text2))
    if not diff:
        return 0

    # Replace nearly-equal floating point numbers with exactly equivalent
    # representations to avoid confusing difflib
    reFloat = re.compile(r'(\s*)([+-]{0,1}\d+\.{0,1}\d*[eE]{0,1}[+-]{0,1}\d*)')
    for i in range(min(len(text1), len(text2))):
        line1 = text1[i]
        line2 = text2[i]
        if line1 == line2:
            continue

        # group(1) is the left space padding
        # group(2) is the number
        floats1 = [(m.group(1),m.group(2))
                   for m in list(reFloat.finditer(line1))]
        floats2 = [(m.group(1),m.group(2))
                   for m in list(reFloat.finditer(line2))]

        # If the lines don't contain the same number of numbers,
        # we're not going to pass the diff comparison no matter what
        if len(floats1) != len(floats2):
            continue

        allMatch = True
        for j in range(len(floats1)):
            if floats1[j] == floats2[j]:
                # String representations match, so replacement is unnecessary
                continue

            delta = max(getPrecision(floats1[j][1]), getPrecision(floats2[j][1]))
            num1 = float(floats1[j][1])
            num2 = float(floats2[j][1])
            if not num1 - 1.1*delta < num2 < num1 + 1.1*delta:
                allMatch = False
                break

        # All the values are sufficiently close, so replace the string
        # so that the diff of this line will succeed
        text2[i] = line1

    # Try the comparison again
    diff = list(difflib.unified_diff(text1, text2))
    if diff:
        print 'Found differences between %s and %s:' % (file1, file2)
        print '>>>'
        print '\n'.join(diff)
        print '<<<'
        return 1

    return 0


def getPrecision(x):
    """
    Return the number corresponding to the least significant digit of
    the number represented by the string 'x'.
    """
    x = x.lower()
    decimalPt = x.find('.')
    if decimalPt == -1:
        decimalPt = 0

    if x.find('e') != -1:
        precision = decimalPt - x.find('e') + 1
        precision += int(x[x.find('e')+1:])
    else:
        precision = decimalPt - len(x) + 1

    return 10**precision


def compareCsvFiles(env, file1, file2):
    """
    Compare the contents of two .csv file to see if they are
    similar. Similarity is defined according to tolerances stored in
    the environment as:

        env['test_csv_threshold']
        env['test_csv_tolerance']

    The comparison for each variable is:

        |a-b|/(max(|a|,|b|) + threshold) < tolerance

    Returns 0 if all variables in the files are similar and 1 if the
    files are dissimilar. Lines containing non-numeric data are
    automatically ignored.
    """
    try:
        import numpy as np
    except ImportError:
        print 'WARNING: skipping .csv diff because numpy is not installed'
        return 0

    # decide how many header lines to skip
    f = open(file1)
    headerRows = 0
    goodChars = set('0123456789.+-eE, ')
    for line in f:
        if not set(line[:-1]).issubset(goodChars):
            headerRows += 1
        else:
            break

    try:
        data1 = np.genfromtxt(file1, skiprows=headerRows, delimiter=',')
        data2 = np.genfromtxt(file2, skiprows=headerRows, delimiter=',')
    except (IOError, StopIteration) as e:
        print e
        return 1

    relerror = (np.abs(data2-data1) /
                (np.maximum(np.abs(data2), np.abs(data1)) +
                 env['test_csv_threshold']))
    maxerror = np.nanmax(relerror.flat)
    tol = env['test_csv_tolerance']
    if maxerror > tol: # Threshold based on printing 6 digits in the CSV file
        print ("Files differ. %i / %i elements above specified tolerance" %
               (np.sum(relerror > tol), relerror.size))
        return 1
    else:
        return 0


def regression_test_message(target, source, env):
    """
    Determines the message printed by SCons while building a
    RegressionTest target.
    """
    return """* Running test '%s'...""" % env['active_test_name']


def add_RegressionTest(env):
    """
    Add "RegressionTest" as a Builder in the specified Scons Environment.
    """
    env['BUILDERS']['RegressionTest'] = env.Builder(
        action=env.Action(regression_test, regression_test_message))


def quoted(s):
    """ Returns the given string wrapped in double quotes."""
    return '"%s"' % s


def mglob(env, subdir, *args):
    """
    Each arg in args is assumed to be file extension,
    unless the arg starts with a '^', in which case the remainder
    of the arg is taken to be a complete pattern.
    """
    matches = []
    for ext in args:
        if ext.startswith('^'):
            matches += env.Glob(pjoin(subdir, ext[1:]))
        else:
            matches += env.Glob(pjoin(subdir, '*.%s' % ext))
    return matches


def psplit(s):
    """
    Split a path given as a string into a list.
    This is the inverse of os.path.join.
    """
    s = s.strip('/\\')
    head, tail = os.path.split(s)
    path = [tail]
    while head:
        head, tail = os.path.split(head)
        path.append(tail)

    path.reverse()
    return path


def stripDrive(s):
    """
    Remove a Windows drive letter specification from a path.
    """
    if len(s) > 1 and s[1] == ':':
        return s[2:]
    else:
        return s


def which(program):
    """ Replicates the functionality of the 'which' shell command """
    def is_exe(fpath):
        for ext in ('', '.exe', '.bat'):
            if os.path.exists(fpath + ext):
                return os.access(fpath + ext, os.X_OK)
        return False

    fpath, fname = os.path.split(program)
    if fpath:
        if is_exe(program):
            return program
    else:
        for path in os.environ["PATH"].split(os.pathsep):
            exe_file = os.path.join(path, program)
            if is_exe(exe_file):
                return exe_file

    return None

optionWrapper = textwrap.TextWrapper(initial_indent='    ',
                                   subsequent_indent='    ',
                                   width=72)


def formatOption(env, opt):
    """
    Print a nicely formatted description of a SCons configuration
    option, its permitted values, default value, and current value
    if different from the default.
    """
    # Extract the help description from the permitted values. Original format
    # is in the format: "Help text ( value1|value2 )" or "Help text"
    if opt.help.endswith(')'):
        parts = opt.help.split('(')
        help = '('.join(parts[:-1])
        values = parts[-1][:-1].strip().replace('|', ' | ')
        if values == '':
            values = 'string'
    else:
        help = opt.help
        values = 'string'

    # Fix the representation of boolean options, which are stored as
    # Python bools, but need to be passed by the user as strings
    default = opt.default
    if default is True:
        default = 'yes'
    elif default is False:
        default = 'no'

    # First line: "* option-name: [ choice1 | choice2 ]"
    lines = ['* %s: [ %s ]' % (opt.key, values)]

    # Help text, wrapped and idented 4 spaces
    lines.extend(optionWrapper.wrap(re.sub(r'\s+', ' ',help)))

    # Default value
    lines.append('    - default: %r' % default)

    # Get the actual value in the current environment
    if opt.key in env:
        actual = env.subst('${%s}' % opt.key)
    else:
        actual = None

    # Fix the representation of boolean options
    if actual == 'True':
        actual = 'yes'
    elif actual == 'False':
        actual = 'no'

    # Print the value if it differs from the default
    if actual != default:
        lines.append('    - actual: %r' % actual)
    lines.append('')

    return lines


def listify(value):
    """
    Convert an option specified as a string to a list.  Allow both
    comma and space as delimiters. Passes lists transparently.
    """
    if isinstance(value, types.StringTypes):
        return value.replace(',', ' ').split()
    else:
        # Already a sequence. Return as a list
        return list(value)


def removeFile(name):
    """ Remove file (if it exists) and print a log message """
    if os.path.exists(name):
        print 'Removing file "%s"' % name
        os.remove(name)

def removeDirectory(name):
    """ Remove directory recursively and print a log message """
    if os.path.exists(name):
        print 'Removing directory "%s"' % name
        shutil.rmtree(name)

def ipdb():
    """
    Break execution and drop into an IPython debug shell at the point
    where this function is called.
    """
    from IPython.core.debugger import Pdb
    from IPython.core import ipapi

    ip = ipapi.get()
    def_colors = ip.colors
    Pdb(def_colors).set_trace(sys._getframe().f_back)


def getSpawn(env):
    """
    A replacement for env['SPAWN'] on Windows that can deal with very long
    commands, namely those generated when linking. This is only used when
    compiling with MinGW, as SCons automatically uses a tempfile for the
    MSVC link command.

    Pass the return value of this function as the SPAWN keyword argument to
    the Library target, e.g.:

        env.SharedLibrary(..., SPAWN=getSpawn(env))

    Adapted from http://www.scons.org/wiki/LongCmdLinesOnWin32
    """

    if sys.platform != 'win32' or env['toolchain'] != 'mingw':
        return env['SPAWN']

    try:
        useShowWindow = subprocess.STARTF_USESHOWWINDOW
    except AttributeError:
        useShowWindow = subprocess._subprocess.STARTF_USESHOWWINDOW

    def ourSpawn(sh, escape, cmd, args, environ):
        newargs = ' '.join(args[1:])
        cmdline = cmd + " " + newargs
        startupinfo = subprocess.STARTUPINFO()
        startupinfo.dwFlags |= useShowWindow
        proc = subprocess.Popen(cmdline,
                                stdin=subprocess.PIPE,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE,
                                startupinfo=startupinfo,
                                shell=False,
                                env=environ)
        data, err = proc.communicate()
        rv = proc.wait()
        if rv:
            print "====="
            print err
            print "====="
        return rv

    return ourSpawn
