'''
Created on 20-Jan-2022

@author: bthakur
'''

import codecs
import inspect
import traceback
import random
import numpy as np
import pandas as pd



from sympy import (latex, Symbol, symbols,
                   Matrix, Identity, nsimplify, sympify,
                    Rational,
                    sin, cos, tan,
                    asin, acos, atan,
                    sinh, cosh, tanh,
                    log, exp, sqrt,
                   diff, factor, 
                   Function, Eq,
                   Add, 
                   simplify, solve, factorial,
                   Derivative,
                   UnevaluatedExpr as ueval,
                   
                   
                   )
import sympy
from sympy.core.numbers import One, Integer
from sympy.series import series

from xv.km.util import latex_fraction


from xv.km.ui import SvgHelper
from xv.util.data import CacheUtil

import matplotlib.pyplot as plt
from xv.util import CategorizedSampleData, list_to_str

# _func_classification_helper
# FuncClassificationHelper


from xv.util import UtilClass

class FuncClassificationHelper(UtilClass):
    
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.x = Symbol('x')
        self.f = Function('f')(self.x)
        self.g = Function('g')(self.x)
        pass
    
    def get_random_functions(self, x):
        a = random.randint(-9, 9)
        b = random.randint(-9, 9)
        c = random.randint(-9, 9)
        d = random.randint(-9, 9)
        
        funcs = [
            (ueval(x * random.choice([1, -1]))) ** random.randint(1, 4),
            a * x,
            a * x + b,
            a * x**2, b * x + c,
            a * x**3, b * x**2 + c * x + d,
            sin(x),
            cos(x),
            tan(x),
            exp(sympy.I * x)
            ]
        return random.choice(funcs)
        pass
    
    def get_func_types(self):
        clsf_names = [
            'even',
            'odd',
            'periodic',
            'linear',
            'quadratic',
            'cubic',
            ]
        return clsf_names[-1:]
        pass
    
    def get_paired_func_types(self):
        paired_func_types = [
            'inverse',
            ]
        return paired_func_types
        pass

    def get_paired_funcs(self, expr, x = None, paired_func_types = None):
        if x is None:
            x = self.x
        
        if paired_func_types is None:
            paired_func_types = self.get_paired_func_types()
        
        soln_items = []
        
        paired_funcs_dict = {}
        paired_func_types_false = []
        for paired_func_type in paired_func_types:
            proof_details = self.get_paired_func_type_proof(expr, x, paired_func_type)
            
            exists_paired_func = True
            if not proof_details:
                exists_paired_func = False
            else:
                paired_expr, proof_soln_items = proof_details
                if paired_expr is None:
                    exists_paired_func = False
                pass 
            
            if not exists_paired_func:
                soln_item = f"""
                <b>{paired_func_type.capitalize()} function:</b>
                Unable to determine {paired_func_type} of $\\displaystyle
                {latex(expr)}$.
                """
                soln_items.append(soln_item.strip())
                paired_func_types_false.append(paired_func_type)
                continue

            sOut = f"""
            paired_func_type = {paired_func_type}
            proof_details = {proof_details}
            """
            #print(sOut)s

            soln_item = f"""
            <b>{paired_func_type.capitalize()} function:</b>
            """
            soln_items.append(soln_item.strip())
            soln_items.extend(proof_soln_items)
            paired_funcs_dict[paired_func_type] = paired_expr
            pass
        
        
        paired_funcs_dict_str = [f"<b>{k.capitalize()}: $\\displaystyle {self.ltx(v)}$</b>" 
                                 for k,v in paired_funcs_dict.items()]

        paired_funcs_dict_str = list_to_str(paired_funcs_dict_str, sep = "\n\n")

        paired_func_types_false = list_to_str(paired_func_types_false, last_sep = " and ")
        
        if paired_func_types_false:
            soln_item = f"""
            <b>The function $\\displaystyle {latex(expr)}$ does not have the followings:</b>
            {paired_func_types_false}
            """
            soln_items.append(soln_item)
            pass
        
        
        soln_s = "\n\n".join([f"{soln_item}" for soln_item in soln_items])
        
        answer_s = f"""
        {paired_funcs_dict_str}
        """
        
        result = {
            'soln_s': soln_s,
            'answer_s': answer_s.strip(),
            }
        
        return result
        pass

    def get_paired_func_type_proof(self, expr, x, paired_func_type):
        try:
            proof_method_name = f"_get_{paired_func_type}_func_details"
            if hasattr(self, proof_method_name):
                proof_method = getattr(self, proof_method_name)
                #self._log(f"Calling method {proof_method.__name__}({expr}, {x})")
    
                proof_details = proof_method(expr, x)
                
                sOut = f"""
                paired_func_type = {paired_func_type}
                proof_details = {proof_details}
                """
                
                if proof_details:
                    paired_expr, soln_items = proof_details
                    return paired_expr, soln_items
                pass
        except Exception as e:
            sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}: {e}
            """
            self._log(sError)
            self._log(traceback.format_exc())
            pass
        
        
        return None
        pass
    
    

    def _get_inverse_func(self, expr, x):
        reverse_expr = Symbol('f^{-1}')
        eq = Eq(expr * reverse_expr, 1)
        soln_reverse_expr = solve(eq, reverse_expr)
        
        if not soln_reverse_expr:
            return None
        
        if len(soln_reverse_expr) == 1:
            soln_reverse_expr = soln_reverse_expr[0]
            
        return soln_reverse_expr
        pass

    def _get_inverse_func_details(self, expr, x):
        paired_func_type = 'inverse'
        
        paired_func = self._get_inverse_func(expr, x)

        soln_items = []

        f = self.f.subs(self.x, x)
        g = self.g.subs(self.x, x)
        
        soln_item = f"""
        If $\\displaystyle 
        {latex(f)} * {latex(g)} = 1, 
        \\text{{ the }} {latex(f)} \\text{{ and }}  {latex(g)} 
        \\text{{ are }} {paired_func_type} \\text{{ of each other. }}
        $
        """
        soln_items.append(soln_item.strip())
        
        
        soln_item = f"""
        Let,
        $\\displaystyle
        {latex(f)} * {latex(g)} = 1
        $
        
        $\\displaystyle
        \\implies {latex(expr)} * {latex(g)} = 1
        $
        """
        soln_items.append(soln_item.strip())
        
        if paired_func:
            soln_item = f"""
            Solving for $\\displaystyle
            {latex(g)}
            $, we get
            
            $\\displaystyle
            {latex(g)} = {self.ltx(paired_func)}
            $
            
            Hence, {paired_func_type} of 
            $\\displaystyle
            {latex(f)} = {self.ltx(paired_func)}
            $
            """
        else:
            soln_item = f"""
            $\\displaystyle
            \\neq {latex(f)}
            $
            
            Hence, $\\displaystyle
            {latex(f)}
            $ is not an {paired_func_type} function.
            """
            pass
        
        soln_items.append(soln_item.strip())
        
        return paired_func, soln_items
        pass

    
    def classify_funcs(self, expr, x = None, func_types = None):
        if x is None:
            x = self.x
        
        if func_types is None:
            func_types = self.get_func_types()
        
        soln_items = []
        
        func_types_true = []
        func_types_false = []
        for func_type in func_types:
            proof_details = self.get_func_type_proof(expr, x, func_type)
            if not proof_details:
                soln_item = f"""
                <b>{func_type.capitalize()} function:</b>
                Unable to determine $\\displaystyle
                {latex(expr)}$ is {func_type} function or not.
                """
                soln_items.append(soln_item.strip())
                func_types_false.append(func_type)
                continue

            sOut = f"""
            func_type = {func_type}
            proof_details = {proof_details}
            """
            #print(sOut)

            is_func_type, proof_soln_items = proof_details
            
            soln_item = f"""
            <b>{func_type.capitalize()} function:</b>
            """
            soln_items.append(soln_item.strip())
            soln_items.extend(proof_soln_items)
            if is_func_type:
                func_types_true.append(func_type)
            else:
                func_types_false.append(func_type)
            pass
        

        func_types_true = list_to_str(func_types_true, last_sep = " and ")
        func_types_false = list_to_str(func_types_false, last_sep = " and ")
        
        if func_types_false:
            soln_item = f"""
            <b>The function $\\displaystyle 
            {latex(expr)}$ does not belong to the following types:</b>
            {func_types_false}
            """
            soln_items.append(soln_item)
            pass
        
        soln_s = "\n\n".join([f"{soln_item}" for soln_item in soln_items])
        
        if func_types_true:
            answer_s = f"""
            {func_types_true}
            """
        else:
            answer_s = f"""
            None
            """
        
        result = {
            'soln_s': soln_s,
            'answer_s': answer_s.strip(),
            }
        
        return result
        pass
    
    
    def get_func_type_proof(self, expr, x, func_type):
        try:
            proof_method_name = f"_proof_{func_type}_func"
            if hasattr(self, proof_method_name):
                proof_method = getattr(self, proof_method_name)
                #self._log(f"Calling method {proof_method.__name__}({expr}, {x})")
    
                proof_details = proof_method(expr, x)
                
                sOut = f"""
                func_type = {func_type}
                proof_details = {proof_details}
                """
                
                if proof_details:
                    is_func_type, soln_items = proof_details
                    return is_func_type, soln_items
                pass
        except Exception as e:
            sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}: {e}
            """
            self._log(sError)
            self._log(traceback.format_exc())
            pass
        
        
        return None
        pass
    
    def _is_even_func(self, expr, x):
        if simplify(expr.subs(x, -x)) != simplify(expr):
            return False
        return True
        pass

    def _proof_even_func(self, expr, x):
        func_type = 'even'
        
        is_func_type = self._is_even_func(expr, x)

        soln_items = []

        f = self.f.subs(self.x, x)
        x_neg = -x
        f_subs = f.subs(x, ueval(x_neg), evaluate = False)
        
        soln_item = f"""
        If $\\displaystyle 
        {self.ltx(f_subs)} = {latex(f)}
        $, the function is {func_type} function.
        """
        soln_items.append(soln_item.strip())
        
        expr_subs = expr.subs(x, ueval(x_neg), evaluate = False)
        expr_subs_eval = simplify(expr_subs)
        #expr_subs_f = expr_subs_eval.subs(expr, f)
        
        soln_item = f"""
        Now,
        $\\displaystyle
        {self.ltx(f_subs)}
        $
        
        $\\displaystyle
        = {self.ltx(expr_subs)} 
        $
        """
        soln_items.append(soln_item.strip())
        

        if self.ltx(expr_subs_eval) != self.ltx(expr_subs):    
            soln_item = f"""
            $\\displaystyle
            = {self.ltx(expr_subs_eval)} 
            $
            """
            soln_items.append(soln_item.strip())
        
        if is_func_type:
            soln_item = f"""
            $\\displaystyle
            = {latex(f)}
            $
            
            Hence, $\\displaystyle
            {latex(f)}
            $ is an {func_type} function.
            """
        else:
            soln_item = f"""
            $\\displaystyle
            \\neq {latex(f)}
            $
            
            Hence, $\\displaystyle
            {latex(f)}
            $ is not an {func_type} function.
            """
            pass
        
        soln_items.append(soln_item.strip())
        
        return is_func_type, soln_items
        pass

    def _is_odd_func(self, expr, x):
        if simplify(expr.subs(x, -x)) != - simplify(expr):
            return False
        return True
        pass

    def _proof_odd_func(self, expr, x):
        func_type = 'odd'
        
        is_func_type = self._is_odd_func(expr, x)

        soln_items = []

        f = self.f.subs(self.x, x)
        x_neg = -x
        f_subs = f.subs(x, ueval(x_neg), evaluate = False)
        
        soln_item = f"""
        If $\\displaystyle 
        {self.ltx(f_subs)} = - {latex(f)}
        $, the function is {func_type} function.
        """
        soln_items.append(soln_item.strip())
        
        expr_subs = expr.subs(x, ueval(x_neg), evaluate = False)
        expr_subs_eval = simplify(expr_subs)
        #expr_subs_f = expr_subs_eval.subs(expr, f)
        
        soln_item = f"""
        Now,
        $\\displaystyle
        {self.ltx(f_subs)}
        $
        
        $\\displaystyle
        = {self.ltx(expr_subs)} 
        $
        """
        soln_items.append(soln_item.strip())
        

        if self.ltx(expr_subs_eval) != self.ltx(expr_subs):    
            soln_item = f"""
            $\\displaystyle
            = {self.ltx(expr_subs_eval)} 
            $
            """
            soln_items.append(soln_item.strip())
        
        
        if is_func_type:
            soln_item = f"""
            $\\displaystyle
            = - {latex(f)}
            $
            
            Hence, $\\displaystyle
            {latex(f)}
            $ is an {func_type} function.
            """
        else:
            soln_item = f"""
            $\\displaystyle
            \\neq - {latex(f)}
            $
            
            Hence, $\\displaystyle
            {latex(f)}
            $ is not an {func_type} function.
            """
            pass
        
        soln_items.append(soln_item.strip())
        
        return is_func_type, soln_items
        pass

   
    def _is_periodic_func(self, expr, x):
        if type(expr).__name__ in ['sin', 'cos', 'tan',
                                'csc', 'sec', 'cot']:
            return True

        f = self.f.subs(self.x, x)

        df_dx = Derivative(expr, x)
        df2_dx2 = Derivative(df_dx, x)
        df2_dx2_doit_subs = df2_dx2.doit().subs(expr, f)
        expr_ratio = sympify(df2_dx2_doit_subs / f)
        
        sOut = f"""
        df2_dx2 = {df2_dx2}
        
        df2_dx2_doit_subs = {df2_dx2_doit_subs}
        
        expr_ratio = {expr_ratio}
        
        expr_ratio.is_constant() = {expr_ratio.is_constant()}
        """
        
        if expr_ratio == 0:
            return False
        
        if expr_ratio.is_constant():
            return True
        
        return False
        pass

    def _proof_periodic_func(self, expr, x):
        func_type = 'periodic'

        is_func_type = self._is_periodic_func(expr, x)

        f = self.f.subs(self.x, x)

        T = Symbol('T')
        f_subs = f.subs(x, x + T)

        soln_items = []
        soln_item = f"""
        If $\\displaystyle 
        {self.ltx(f_subs)} = {latex(f)}
        $, the function is {func_type} function with time period $\\displaystyle 
        {latex(T)}$.
        """
        soln_items.append(soln_item.strip())

        if type(expr).__name__ in ['sin', 'cos', 'tan',
                                'csc', 'sec', 'cot']:
            sub_soln_items = self._proof_periodic_func_trigonometric(expr, x, T, is_func_type)
            soln_items.extend(sub_soln_items)
            return is_func_type, soln_items
            pass
        
        sub_soln_items = self._proof_periodic_func_non_trigonometric(expr, x, T, is_func_type)
        soln_items.extend(sub_soln_items)
        return is_func_type, soln_items
        pass

    def _proof_periodic_func_non_trigonometric(self, expr, x, T, is_func_type):
        soln_items = []

        f = self.f.subs(self.x, x)
        
        soln_item = f"""
        Now,
        $\\displaystyle 
        {latex(f)} = {latex(expr)}
        $
        """
        soln_items.append(soln_item.strip())

        F1 = Derivative(f, x)
        F2 = Derivative(F1, x)
        df_dx = Derivative(expr, x).doit()
        df2_dx2 = Derivative(df_dx, x).doit()

        df2_dx2_subs = df2_dx2.subs(expr, f)
        
        soln_item = f"""
        $\\displaystyle 
        \\implies {self.ltx(F1)} = {self.ltx(df_dx)}
        $
        """
        soln_items.append(soln_item.strip())

        if self.ltx(df2_dx2) != self.ltx(df2_dx2_subs):
            soln_item = f"""
            $\\displaystyle 
            \\implies {self.ltx(F2)} = {self.ltx(df2_dx2)} = {self.ltx(df2_dx2_subs)}
            $
            """
            soln_items.append(soln_item.strip())
        else:
            soln_item = f"""
            $\\displaystyle 
            \\implies {self.ltx(F2)} = {self.ltx(df2_dx2)}
            $
            """
            soln_items.append(soln_item.strip())
            
        if is_func_type:
            soln_item = f"""
            $\\displaystyle 
            \\implies {self.ltx(F2)} \\propto {latex(f)}
            $
            
            Hence, $\\displaystyle {latex(expr)}$ is a periodic function.
            """
            soln_items.append(soln_item.strip())
        else:
            soln_item = f"""
            $\\displaystyle 
            \\implies {self.ltx(F2)} \\not \\propto {latex(f)}
            $
            
            Hence, $\\displaystyle {latex(expr)}$ is not a periodic function.
            """
            soln_items.append(soln_item.strip())
            pass
            

        return soln_items
        pass

    def _proof_periodic_func_trigonometric(self, expr, x, T, is_func_type):
        soln_items = []

        n = Symbol('n')
        
        if type(expr).__name__ in ['tan', 'cot']:
            T_val = Symbol(f'{latex(n)}{latex(sympy.pi)}')
            T_period = sympy.pi
        else:
            T_val = Symbol(f'2{latex(n)}{latex(sympy.pi)}')
            T_period = 2 * sympy.pi
            
        f = self.f.subs(self.x, x)
        f_subs = f.subs(x, x + T_val)

        expr_subs = expr.subs(x, x + T_val, evaluate = False)
        expr_subs_eval = simplify(expr_subs)

        soln_item = f"""
        Now,
        $\\displaystyle 
        {self.ltx(f_subs)} \\text{{ for }}  {latex(n)} \\in \\mathbb{{Z}}
        $ 
        
        $\\displaystyle 
        = {self.ltx(expr_subs)} 
        $ 
        """
        soln_items.append(soln_item.strip())


        if self.ltx(expr_subs) != self.ltx(expr_subs_eval):
            soln_item = f"""
            $\\displaystyle 
            = {self.ltx(expr_subs_eval)} 
            $ 
            """
            soln_items.append(soln_item.strip())


        if self.ltx(expr_subs_eval) != latex(expr):
            soln_item = f"""
            $\\displaystyle 
            = {latex(expr)} 
            $ 
            """
            soln_items.append(soln_item.strip())


        if is_func_type:
            soln_item = f"""
            $\\displaystyle 
            = {latex(f)} 
            $ 
            
            Hence, $\\displaystyle 
            {latex(expr)}$ the function is a periodic function with time period $\\displaystyle 
            {latex(T_period)}$.
            """
        else:
            soln_item = f"""
            $\\displaystyle 
            \\neq {latex(f)} 
            $ 
            
            Hence, $\\displaystyle 
            {latex(expr)}$ the function is not a periodic function.
            """
            pass
        
        soln_items.append(soln_item.strip())
        
        soln_item = f"""
        Note: $\\displaystyle 
        \\mathbb{{Z}}
        $ is set of integers.
        """
        soln_items.append(soln_item.strip())
    
        return soln_items
        pass

    def _is_linear_func(self, expr, x):
        f = self.f.subs(self.x, x)

        df_dx = Derivative(expr, x)
        df_dx_doit = df_dx.doit()
        
        sOut = f"""
        df_dx_doit = {df_dx_doit}
        """

        if not hasattr(df_dx_doit, 'free_symbols'):
            return True
        
        if x in df_dx_doit.free_symbols:
            return False
        
        return True
        pass

    def _proof_linear_func(self, expr, x):
        func_type = 'linear'
        latex_x = f"$\\displaystyle {latex(x)} $"

        is_func_type = self._is_linear_func(expr, x)

        f = self.f.subs(self.x, x)


        soln_items = []
        soln_item = f"""
        If the slope of $\\displaystyle {latex(f)}
        $ with respect to {latex_x} is not dependent on {latex_x}, it is a linear function.
        """
        soln_items.append(soln_item.strip())


        soln_item = f"""
        Now,
        $\\displaystyle 
        {latex(f)} = {latex(expr)}
        $
        """
        soln_items.append(soln_item.strip())

        F1 = Derivative(f, x)
        df_dx = Derivative(expr, x)
        df_dx_doit = df_dx.doit()
        
        soln_item = f"""
        Therefore, the slope is, 
        
        $\\displaystyle 
        {self.ltx(F1)} = {self.ltx(df_dx)} = {self.ltx(df_dx_doit)}
        $
        """
        soln_items.append(soln_item.strip())

            
        if is_func_type:
            soln_item = f"""
            As $\\displaystyle 
            {self.ltx(df_dx_doit)}
            $ does not depend on {latex_x}, it is a linear function.
            """
            soln_items.append(soln_item.strip())
        else:
            soln_item = f"""
            As $\\displaystyle 
            {self.ltx(df_dx_doit)}
            $ depends on {latex_x}, it is not a linear function.
            """
            soln_items.append(soln_item.strip())
            pass

        return is_func_type, soln_items
        pass

    def get_polynomial_func_degree(self, expr, x):
        try:
            poly_expr = sympy.poly(expr, x, domain='ZZ')
            expr_degree = sympy.degree(poly_expr, gen=x)
            return poly_expr, expr_degree
        except Exception as e:
            sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}: {e}
            expr = {expr}
            x = {x}
            """
            self._log(sError)
            self._log(traceback.format_exc())
            return None, None
        pass

    def _proof_quadratic_func(self, expr, x):
        n  = 2
        func_type = 'quadratic'
        return self._proof_nth_degree_polynomial_func(expr, x, n, func_type)

    def _proof_cubic_func(self, expr, x):
        n  = 3
        func_type = 'cubic'
        return self._proof_nth_degree_polynomial_func(expr, x, n, func_type)

    def _proof_nth_degree_polynomial_func(self, expr, x, n, func_type):
        latex_x = f"$\\displaystyle {latex(x)} $"

        poly_expr, expr_degree = self.get_polynomial_func_degree(expr, x)
        soln_items = []

        if poly_expr is None:
            soln_item = f"""
            The function $\\displaystyle 
            {latex(expr)}
            $ is not a polynomial of {latex_x}.
            """
            soln_items.append(soln_item.strip())
            is_func_type = False
            return is_func_type, soln_items
            pass
       
        
        soln_item = f"""
        $\\displaystyle 
        {latex(expr)}
        $
        
        $\\displaystyle 
        = {latex(poly_expr)}
        $
        
        The highest degree of {latex_x} is {expr_degree}.
        """
        soln_items.append(soln_item.strip())
        pass
        
            
        if expr_degree == n:
            soln_item = f"""
            As the degree of $\\displaystyle 
            {latex(expr)} 
            $ is equal to  {n}, it is a {func_type} function.
            """
            soln_items.append(soln_item.strip())
            is_func_type = True
        else:
            soln_item = f"""
            As the degree of $\\displaystyle 
            {latex(expr)}
            $ is not equal to  {n}, it is not a {func_type} function.
            """
            soln_items.append(soln_item.strip())
            is_func_type = False
            pass

        return is_func_type, soln_items
        pass


    def ltx(self, expr, mul_symbol = 'dot'):
        expr_latex = latex(expr, mul_symbol = mul_symbol)
        #expr_latex = expr_latex.replace("\\partial", "d")
        return expr_latex
        pass

        
    pass
#class FuncClassificationHelper:
