# 
# General hint system for edX
#
# Hints are specified by defining a list of hint dicts.
# Each hint dict should contain the following key/value pairs:
#
#    hint    - value = hint string to display if any matches found
#
#    string  - match on string present for in the student's answer
#    symbol  - match on math symbol in the answer
#    func    - match on math function in the answer
#    isnum   - match on answer being numerical (value ignored)
#    val     - match on numerical value of student's answer
#              value = number ...or
#              value = {'expect': number, 'tolerance': tolerance}
#    magdif  - match on difference in magnitude between given and expected numerical values being too large
#              value = expected number ...or
#              value = {'expect': number, 'max': maximum_magnitude_difference_in_log10}
#    range   - match on numerical answer being within a certain range
#              value = [min, max]
#    formula - match on formula equality (via numerical sampling);
#              value = as <expr>!<variables>@<lower_range>:<upper_range>#<num_samples>
#    parens  - match on un-balanced parentheses
#              value = ignored (anything ok)
#    eval    - match on evaluated expression, which may contain calls to other hint functions
#              value = expression to evaluate, e.g. "not string('*') and string('x')"
#
# Examples:
#
#         hints = [ {'parens': '', 'hint': 'Missing parenthesis?'},
#                   {'eval': 'not string("*")',
#                    'hint': 'Please indicate multiplication explicitly using *'},
#                   {'symbol': 'L', 'hint': 'Should your answer depend on L?'},
#                   {'string': 'D^2_eg', 'hint': 'Enter D_eg^2 and not D^2_eg'},
#                   {'symbol': 'D', 'hint': 'Enter D_eg and not D'},
#                   {'eval': 'not string("hbar")', 'hint': "Are your units correct?"},
#                   {'eval': 'string("-")', 'hint': "Are your sign(s) correct?"},
#                  ]
# 
#         ch0 = HintSystem(anum=0, hints=hints2).check_hint
#
#  <hintgroup hintfn="ch0"/>
#
# Usage:
#
# Simple case, for a single input field: define the global variable "hints" with list of hint dicts
# use "check_hint" as the hintfn in the edX capa problem.
#
# Advanced case: for multiple input fields
#
# Instantiate HintSystem with configuration parameters anum=answer number, hints = list of hint dicts
# 
#        ch1 = HintSystem(anum=0, hints=hints1).check_hint
#        ch2 = HintSystem(anum=1, hints=hints2).check_hint
#
# then use ch1 and ch2 as the hintfn in the edX capa problem.

from math import log10
from functools import partial

from calc import ParseAugmenter

#-----------------------------------------------------------------------------
# provide compare_with_tolerance and formula_test, if they could not be imported

if True:

    import numpy
    import numbers
    import random
    # from math import *
    from calc import evaluator

    default_tolerance = '0.01%'
    
    def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, relative_tolerance=False):
        """
        Compare complex1 to complex2 with maximum tolerance tol.
    
        If tolerance is type string, then it is counted as relative if it ends in %; otherwise, it is absolute.
    
         - complex1    :  student result (float complex number)
         - complex2    :  instructor result (float complex number)
         - tolerance   :  string representing a number or float
         - relative_tolerance: bool, used when`tolerance` is float to explicitly use passed tolerance as relative.
    
         Default tolerance of 1e-3% is added to compare two floats for
         near-equality (to handle machine representation errors).
         Default tolerance is relative, as the acceptable difference between two
         floats depends on the magnitude of the floats.
         (http://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/)
         Examples:
            In [183]: 0.000016 - 1.6*10**-5
            Out[183]: -3.3881317890172014e-21
            In [212]: 1.9e24 - 1.9*10**24
            Out[212]: 268435456.0
        """
        def myabs(elem):
            if isinstance(elem, numpy.matrix):
                return numpy.sum(abs(elem))
            return abs(elem)
    
        if isinstance(tolerance, numbers.Number):
            tolerance = str(tolerance)
        if relative_tolerance:
            tolerance = tolerance * max(myabs(complex1), myabs(complex2))
        elif tolerance.endswith('%'):
            tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01
            tolerance = tolerance * max(myabs(complex1), myabs(complex2))
        else:
            tolerance = evaluator(dict(), dict(), tolerance)
    
        try:
            if numpy.isinf(complex1).any() or numpy.isinf(complex2).any():
                # If an input is infinite, we can end up with `abs(complex1-complex2)` and
                # `tolerance` both equal to infinity. Then, below we would have
                # `inf <= inf` which is a fail. Instead, compare directly.
                cmp = (complex1 == complex2)
                if isinstance(cmp, numpy.matrix):
                    return cmp.all()
                return cmp
            else:
                # v1 and v2 are, in general, complex numbers:
                # there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
                # return abs(complex1 - complex2) <= tolerance
                #
                # sum() used to handle matrix comparisons
                return numpy.sum(abs(complex1 - complex2)) <= tolerance
        except Exception as err:
            print "failure in comparison, complex1=%s, complex2=%s" % (complex1, complex2)
            print "err = ", err
            raise
    
if True:
    
    def is_formula_equal(expected, given, samples, cs=True, tolerance='0.01', evalfun=None, cmpfun=None, debug=False):
        '''
        expected = instructor's expression
        given = student's expression
        samples = sample string for numerical checking (see below)
        cs = case_sensitive flag
        tolerance = tolerance specification string
        evalfun = function for doing evaluation (defaults to using evaluator from calc2)
        cmpfun = comparison function for testing equality (defaults to compare_with_tolerance)
        debug = flag for verbosity of debugging output
    
        samples examples:
    
        samples="m_I,m_J,I_z,J_z@1,1,1,1:20,20,20,20#50" 
        samples="J,m,Delta,a,h,x,mu_0,g_I,B_z@0.5,1,1,1,1,1,1,1,1:0.5,20,20,20,20,20,20,20,20#50"
    
        matrix sampling:
    
        samples="x,y@[1|2;3|4],[0|2;4|6]:[5|5;5|5],[8|8;8|8]#50"
    
        complex numbers:
    
        'x,y,i@[1|2;3|4],[0|2;4|6],0+1j:[5|5;5|5],[8|8;8|8],0+1j#50'
    
        '''
        if evalfun is None:
            evalfun = evaluator
            #evalfun = EvaluateWithKets
        if cmpfun is None:
            def cmpfun(a, b, tol):
                return compare_with_tolerance(a, b, tol)
            
        variables = samples.split('@')[0].split(',')
        numsamples = int(samples.split('@')[1].split('#')[1])
    
        def to_math_atom(sstr):
            '''
            Convert sample range atom to float or to matrix
            '''
            if '[' in sstr:
                return numpy.matrix(sstr.replace('|',' '))
            elif 'j' in sstr:
                return complex(sstr)
            else:
                return float(sstr)
    
        sranges = zip(*map(lambda x: map(to_math_atom, x.split(",")),
                           samples.split('@')[1].split('#')[0].split(':')))
        ranges = dict(zip(variables, sranges))
    
        if debug:
            print "ranges = ", ranges
    
        for i in range(numsamples):
            vvariables = {}
            for var in ranges:
                value = random.uniform(*ranges[var])
                vvariables[str(var)] = value
            if debug:
                print "vvariables = ", vvariables
            try:
                instructor_result = evalfun(vvariables, dict(), expected, case_sensitive=cs)
            except Exception as err:
                #raise Exception("is_formula_eq: vvariables=%s, err=%s" % (vvariables, str(err)))
                #raise Exception("-- %s " % str(err))
                raise Exception("Error evaluating instructor result, expected=%s, vv=%s -- %s " % (expected, vvariables, str(err)))
            try:
                student_result = evalfun(vvariables, dict(), given, case_sensitive=cs)
            except Exception as err:
                #raise Exception("is_formula_eq: vvariables=%s, err=%s" % (vvariables, str(err)))
                raise Exception("-- %s " % str(err))
                # raise Exception("Error evaluating your input, given=%s, vv=%s -- %s " % (given, vvariables, str(err)))
            #print "instructor=%s, student=%s" % (instructor_result, student_result)
            cfret = cmpfun(instructor_result, student_result, tolerance)
            if debug:
                print "comparison result = %s" % cfret
            if not cfret:
                return False
        return True
    
    def formula_test(expect, ans, options=None):
        '''
        expect and ans are math expression strings.
        Check for equality using random sampling.
        options should be like samples="m_I,m_J,I_z,J_z@1,1,1,1:20,20,20,20#50"!tolerance=0.3
        i.e. a sampling range for the equality testing, in the same
        format as used in formularesponse.
    
        options may also include altanswer, an alternate acceptable answer.  Example:
    
        options="samples='X,Y,i@[1|2;3|4],[0|2;4|6],0+1j:[5|5;5|5],[8|8;8|8],0+1j#50'!altanswer='-Y*X'"
    
        note that the different parts of the options string are to be spearated by a bang (!).
        '''
        samples = None
        tolerance = '0.1%'
        acceptable_answers = [expect]
        for optstr in options.split('!'):
            if 'samples=' in optstr:
                samples = eval(optstr.split('samples=')[1])
            elif 'tolerance=' in optstr:
                tolerance = eval(optstr.split('tolerance=')[1])
            elif 'altanswer=' in optstr:
                altanswer = eval(optstr.split('altanswer=')[1])
                acceptable_answers.append(altanswer)
        if samples is None:
            return {'ok': False, 'msg': 'Oops, problem authoring error, samples=None'}
    
        # for debugging
        # return {'ok': False, 'msg': 'altanswer=%s' % altanswer}
    
        # filter the answer if the global function "ans_filter" is defined
        if 'ans_filter' in globals() or 'ans_filter' in locals():
            try:
                global ans_filter
                ans = ans_filter(ans)
            except Exception as err:
                #raise
                pass
    
        # for debuging
        # return {'ok': False, 'msg': 'ans=%s' % ans}
    
        # local version - no matrix evaluation
        matrix_evaluator = evaluator

        for acceptable in acceptable_answers:
            try:
                ok = is_formula_equal(acceptable, ans, samples, cs=True, tolerance=tolerance, evalfun=matrix_evaluator, debug=False)
                # ok = is_formula_equal(acceptable, ans, samples, cs=True, tolerance=tolerance)
            except Exception as err:
                return {'ok': False, 'msg': "Sorry, could not evaluate your expression.  Error %s" % str(err)}
            if ok:
                return {'ok':True, 'msg': ''}
    
        return {'ok':ok, 'msg': ''}
    
#'    
#-----------------------------------------------------------------------------

class HintSystem(object):

    def __init__(self, anum=0, hints=None, verbose_fail=False, 
               extra_hint_functions=None, color="orange",
               do_not_catch_exceptions=False):
        '''
        anum = answer_id index number, to base hint off of
        verbose_fail = flag for verbose error messages (bool)
        extra_hint_functions = None, or dict of extra hint functions and hint types
        the_hints = hints to use (defaults to global variable "hints")
        color = color to use for hints
        '''
        
        self.anum = anum
        self.verbose_fail = verbose_fail
        self.extra_hint_functions = extra_hint_functions
        self.hints = hints
        self.color = color
        self.do_not_catch_exceptions = do_not_catch_exceptions

    @staticmethod
    def hint_check_unbalanced_parens(ans, term):
        '''
        ans = student answer
        term = ignored
    
        Returns True if parentheses are unbalanced
        '''
        stack = []
        pushChars, popChars = "({[", ")}]"
        for c in ans :
            if c in pushChars :
                stack.append(c)
            elif c in popChars :
                if not len(stack) :
                    return True
                else:
                    stackTop = stack.pop()
                    balancingBracket = pushChars[popChars.index(c)]
                    if stackTop != balancingBracket:
                        return True
        return len(stack)>0
    
    @staticmethod
    def hint_check_formula(ans, term):
        '''
        ans = student answer
        term = formula to check equality with, as <expr>!<variables>@<lower_range>:<upper_range>#<num_samples>
    
        Returns True if answer equals formula
        '''
        expect, samples = term.split('!')
        options = "samples='%s'" % samples
        ret = formula_test(expect, ans, options=options)
        return ret['ok']
    
    
    @staticmethod
    def hint_check_numerical(ans, term):
        '''
        return True if ans is numerical
        '''
        try:
            x = float(ans)
            return True
        except Exception as err:
            return False
        return False

    @staticmethod
    def hint_check_val(ans, term):
        '''
        ans = student answer
        term = search term to look for
    
        val: check for numerical value of answer matching "term"
    
        don't worry about errors: those are caught by the caller
        '''
        if isinstance(term, dict):
            expect = term['expect']
            tolerance = term['tolerance']
        else:
            expect = term
            tolerance = '5%'
        if not type(expect)==float:
            expect = float(eval(expect))
    
        nans = float(eval(ans))
        ok = compare_with_tolerance(expect, nans, tolerance=tolerance)
        if ok:
            return True
        return False
    
    
    @staticmethod
    def hint_check_magdif(ans, term):
        # compute difference of ans and expected, in terms of order of magnitude
        if isinstance(term, dict):
            expect = term['expect']
            max_magdif = term['max']
        else:
            expect = term
            max_magdif = 2
        try:
            magdif = abs( log10(abs(float(ans)))-log10(abs(expect)) )
        except Exception as err:
            return False
        if magdif > max_magdif:
            return True
        return False
    
    
    @staticmethod
    def hint_check_string(ans, term):
        '''
        ans = student answer
        term = search term to look for
    
        string: search for string in ans
    
        don't worry about errors: those are caught by the caller
        '''
        return ans.count(term)
    
    
    @staticmethod
    def hint_check_symbol(ans, term):
        '''
        ans = student answer
        term = search term to look for
    
        sym: search for math symbol in ans
    
        don't worry about errors: those are caught by the caller
        '''
        case_sensitive = True
        # parse expression
        math_interpreter = ParseAugmenter(ans, case_sensitive)
        math_interpreter.parse_algebra()
        found = term in math_interpreter.variables_used
        # for debugging
        # print 'for %s, found=%s, variables = %s' % (ans, found, math_interpreter.variables_used)
        return found
        
    
    @staticmethod
    def hint_check_function_used(ans, term):
        '''
        ans = student answer
        term = search term to look for
    
        search for function used in ans
    
        don't worry about errors: those are caught by the caller
        '''
        case_sensitive = True
        # parse expression
        math_interpreter = ParseAugmenter(ans, case_sensitive)
        math_interpreter.parse_algebra()
        found = term in math_interpreter.functions_used
        # for debugging
        # print 'for %s, found=%s, variables = %s' % (ans, found, math_interpreter.variables_used)
        return found
        
    
    @staticmethod
    def hint_check_range(ans, term):
        '''
        ans = student answer
        term = list [bot, top] giving numerical range to check to see if answer is within
        '''
        nans = float(eval(ans))
        [bot, top] = term
        if nans < bot:
            return False
        if nans > top:
            return False
        return True
    
    
    def check_hint(self, answer_ids, student_answers, new_cmap, old_cmap, anum=None, the_hints=None):
        '''
        answer_ids = list of indexes into student_answers
        student_answers = dict of student answers
        new_cmap = new correct_map object (see capa package)
        old_cmap = old correct_map object (see capa package)
        '''
    
        if the_hints is None:
            if self.hints is None:
                global hints
                the_hints = hints
            else:
                the_hints = self.hints
    
        # hints should be a list of dicts.  If it's a dict, then the keys give the answer number
        # and each of those dict elements should be called one at a time.
        if isinstance(the_hints, dict):
            for anum, hint in the_hints.items():
                self.check_hint(answer_ids, student_answers, new_cmap, old_cmap, anum=anum, the_hints=hint)
                # print "--> anum=%s, hint=%s, ncm=%s" % (anum, hint, new_cmap.hints)	# for debugging
            return

        if anum is None:
            anum = self.anum
        try:
            aid = answer_ids[anum]
        except Exception as err:
            raise Exception('cannot get answer_ids[%d], answer_ids=%s, new_cmap=%s, err=%s' % (self.anum, answer_ids, new_cmap, err))
    
        ans = student_answers[aid]
    
        # for debugging
        #new_cmap.set_hint_and_mode(aid, "hello world", 'always')
        #return
    
        htypes = {'val': partial(self.hint_check_val, ans),
                  'range': partial(self.hint_check_range, ans),
                  'magdif': partial(self.hint_check_magdif, ans),
                  'string': partial(self.hint_check_string, ans),
                  'symbol': partial(self.hint_check_symbol, ans),
                  'func': partial(self.hint_check_function_used, ans),
                  'formula': partial(self.hint_check_formula, ans),
                  'parens': partial(self.hint_check_unbalanced_parens, ans),
                  'isnum': partial(self.hint_check_numerical, ans),
                  'debug': None,
                  'eval': None,
              }
        if self.extra_hint_functions is not None:
            for key, ehf in self.extra_hint_functions.items():
                htypes[key] = partial(ehf, ans)
    
        # print "using the_hints = %s" % the_hints
        the_hint = None
        for hintinfo in the_hints:
            for htype, hfun in htypes.items():
                if htype in hintinfo:
                    try:
                        term = hintinfo[htype]
                        if htype=='eval':
                            ret = eval(term, htypes)	# evaluate the expression - can have function calls in it!
                        elif htype=='debug':
                            ret = True
                        else:
                            ret = hfun(term)
                        if htype=='debug':
                            hintinfo['hint'] = "Answer submitted=%s" % ans
                        if ret:
                            the_hint = hintinfo['hint']
                            if '<font' not in the_hint:
                                the_hint = ('<font color="%s">' % self.color) + the_hint + '</font>'
                            # return on first matching hint
                            new_cmap.set_hint_and_mode(aid, the_hint, 'always')
                            return
                    except Exception as err:
                        if self.do_not_catch_exceptions:
                            raise
                        if self.verbose_fail:
                            raise Exception("Error %s checking hint %s ans=%s, term=%s" % (err, htype, ans, term))


