import math
import numbers
from typing import Optional, List

import numpy as np
from sympy.parsing.latex import parse_latex
from warnings import warn

from . import (
    consts,
    defaults,
    exceptions,
    utils,
    _version
)


def about() -> None:
    """Print version, author and documentation information."""
    print("Multiscale Modelling Tool (MuMoT): Version " + _version.__version__)
    print("Authors: James A. R. Marshall, Andreagiovanni Reina, Thomas Bose")
    print("Contributors: Robert Dennison, Will Furnass")
    print("Documentation: https://mumot.readthedocs.io/")


def _greekPrependify(s: str) -> str:
    """Prepend two backslash symbols in front of Greek letters to enable proper LaTeX rendering."""
    for i, letter in enumerate(consts.GREEK_LETT_LIST_1):
        if 'eta' in s:
            s = _greekReplace(s, 'eta', '\\eta')
        if letter in s:
            s = _greekReplace(s, letter, consts.GREEK_LETT_LIST_2[i])
            # if s[s.find(letter+'_')-1] !='\\':
            #    s = s.replace(letter+'_',GREEK_LETT_LIST_2[i]+'_')
    return s


def _greekReplace(s: str, sub: str, repl: str) -> str:
    """Auxiliary function for _greekPrependify()."""
    # if find_index is not minus1 we have found at least one match for the substring
    find_index = s.find(sub)
    # loop util we find no (more) match
    while find_index != -1:
        if find_index == 0 or (s[find_index - 1] != '\\' and not s[find_index - 1].isalpha()):
            if sub != 'eta':
                s = s[:find_index] + repl + s[find_index + len(sub):]
            else:
                if s[find_index - 1] != 'b' and s[find_index - 1] != 'z':
                    if s[find_index - 1] != 'h':
                        s = s[:find_index] + repl + s[find_index + len(sub):]
                    elif s[find_index - 1] == 'h':
                        if s[find_index - 2] != 't' and s[find_index - 2] != 'T':
                            s = s[:find_index] + repl + s[find_index + len(sub):]
        # find + 1 means we start at the last match start index + 1
        find_index = s.find(sub, find_index + 1)
    return s


def _doubleUnderscorify(s: str) -> str:
    """Set underscores in expressions which need two indices to enable proper LaTeX rendering."""
    ind_list = [kk for kk, char in enumerate(s)
                if char == '_' and s[kk + 1] != '{']
    if len(ind_list) == 0:
        return s
    else:
        index_MinCharLength = 1
        index_MaxCharLength_init = 20
        s_list = list(s)

        for ind in ind_list:
            ind_diff = len(s_list) - 1 - ind
            if ind_diff > 5:
                index_MaxCharLength = min(index_MaxCharLength_init, ind_diff - 5)
                # the following requires that indices consist of 1 or 2 charcter(s) only
                for nn in range(4 + index_MinCharLength, 5 + index_MaxCharLength):
                    if s_list[ind + nn] == '}' and s_list[ind + nn + 1] != '}':
                        s_list[ind] = '_{'
                        s_list[ind + nn] = '}}'
                        break

    return ''.join(s_list)


def _count_sig_decimals(digits: str, maximum: Optional[int] = 7) -> int:
    """Return the number of significant decimals of the input digit string (up to a maximum of 7)."""
    _, _, fractional = digits.partition(".")

    if fractional:
        return min(len(fractional), maximum)
    else:
        return 0


def _format_advanced_option(optionName: str, inputValue, initValues, extraParam=None, extraParam2=None):
    """Check if the user-specified values are within valid range (appropriate subfunctions are called depending on the parameter).

    parameters for slider widgets return list of length 5 as [value, min, max, step, fixed]

    parameters for boolean, dropbox, or input fields return list of lenght two as [value, fixed]

    values is the initial value, (min,max,step) are for sliders, and fixed is a boolean that indciates if the parameter is fixed or the widget should be displayed

    """
    if optionName == 'initialState':
        (allReactants, _) = extraParam
        #fixSumTo1 = extraParam2[0] # until we have better information, all views should sum to 1, then use system size to scale
        fixSumTo1 = True
        idleReactant = extraParam2[1]
        initialState = {}
        # handle initialState dictionary (either convert or generate a default one)
        if inputValue is not None:
            for reactant in sorted(inputValue.keys(), key=str):
                pop = inputValue[reactant]
                initPop = initValues.get(reactant) if initValues is not None else None

                # Convert string into SymPy symbol
                initialState[parse_latex(reactant)] = _parse_input_keyword_for_numeric_widgets(
                    inputValue=pop,
                    defaultValueRangeStep=[defaults.MuMoTdefault._agents,
                                           defaults.MuMoTdefault._agentsLimits[0],
                                           defaults.MuMoTdefault._agentsLimits[1],
                                           defaults.MuMoTdefault._agentsStep],
                    initValueRangeStep=initPop,
                    validRange=(0.0, 1.0) if fixSumTo1 else (0, float("inf")))
                fixedBool = True
        else:
            first = True
            initValuesSympy = ({parse_latex(reactant): pop
                                for reactant, pop in initValues.items()}
                               if initValues is not None else {})
            for reactant in sorted(allReactants, key=str):
                defaultV = defaults.MuMoTdefault._agents if first else 0
                first = False
                initialState[reactant] = _parse_input_keyword_for_numeric_widgets(
                    inputValue=None,
                    defaultValueRangeStep=[defaultV,
                                           defaults.MuMoTdefault._agentsLimits[0],
                                           defaults.MuMoTdefault._agentsLimits[1],
                                           defaults.MuMoTdefault._agentsStep],
                    initValueRangeStep=initValuesSympy.get(reactant),
                    validRange=(0.0, 1.0) if fixSumTo1 else (0, float("inf")))
                fixedBool = False

        # Check if the initialState values are valid
        if fixSumTo1:
            sumValues = sum([initialState[reactant][0] for reactant in allReactants])
            minStep = min([initialState[reactant][3] for reactant in allReactants])
            
            # first thing setting the values of the idleReactant
            if idleReactant is not None: 
                idleValue = initialState[idleReactant][0]
                if idleValue > 1: 
                    wrn_msg = f"WARNING! the initial value of reactant {idleReactant} has been changed to {new_val}\n"
                    warn(wrn_msg, exceptions.MuMoTWarning)
                    initialState[idleReactant][0] = new_val
                # the idleValue have range min-max reset to [0,1]
                initialState[idleReactant][1] = 0
                initialState[idleReactant][2] = 1
                initialState[idleReactant][3] = minStep
            else:
                idleValue = 0
            for reactant in sorted(allReactants, key=str):
                if reactant not in allReactants:
                    error_msg = (f"Reactant '{reactant}' does not exist in this model.\n"
                                 f"Valid reactants are {allReactants}. Please, correct the value and retry.")
                    raise exceptions.MuMoTValueError(error_msg)
    
                # check if the proportions sum to 1
                if reactant != idleReactant:
                    pop = initialState[reactant]
                    # modify (if necessary) the initial value
                    if sumValues > 1:
                        new_val = max(0, pop[0] + (1 - sumValues))
                        if not _almostEqual(pop[0], new_val):
                            wrn_msg = f"WARNING! the initial value of reactant {reactant} has been changed to {new_val}\n"
                            warn(wrn_msg, exceptions.MuMoTWarning)
                            sumValues -= pop[0]
                            sumValues += new_val
                            initialState[reactant][0] = new_val
                    # modify (if necessary) min-max
                    if idleReactant is not None:
                        pop = initialState[reactant]
                        sumNorm = sumValues if sumValues <= 1 else 1
                        if pop[2] > (1 - sumNorm + pop[0] + idleValue):  # max
                            if pop[1] > (1 - sumNorm + pop[0] + idleValue):  # min
                                initialState[reactant][1] = (1 - sumNorm + pop[0] + idleValue)
                            initialState[reactant][2] = (1 - sumNorm + pop[0] + idleValue)
                        if pop[1] > (1 - sumNorm + pop[0]):  # min
                            initialState[reactant][1] = (1 - sumNorm + pop[0])
                        # initialState[reactant][3] = minStep
            if not _almostEqual(sumValues, 1):
                reactantToFix = sorted(allReactants, key=str)[0] if idleReactant is None else idleReactant
                new_val = 1 - sum([initialState[reactant][0]
                                   for reactant in allReactants
                                   if reactant != reactantToFix])
                wrn_msg = f"WARNING! the initial value of reactant {reactantToFix} has been changed to {new_val}\n"
                warn(wrn_msg, exceptions.MuMoTWarning)
                initialState[reactantToFix][0] = new_val
        return [initialState, fixedBool]
        # print("Initial State is " + str(initialState))
    if optionName == 'maxTime':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=([2, 0.1, 3, 0.1]
                                   if extraParam == 'asNoise'
                                   else [defaults.MuMoTdefault._maxTime,
                                         defaults.MuMoTdefault._timeLimits[0],
                                         defaults.MuMoTdefault._timeLimits[1],
                                         defaults.MuMoTdefault._timeStep]),
            initValueRangeStep=initValues,
            validRange=(0, float("inf")))
    if optionName == 'randomSeed':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=np.random.randint(consts.MAX_RANDOM_SEED),
            initValueRangeStep=initValues,
            validRange=(1, consts.MAX_RANDOM_SEED), onlyValue=True)
    if optionName == 'motionCorrelatedness':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=[0.5, 0.0, 1.0, 0.05],
            initValueRangeStep=initValues,
            validRange=(0, 1))
    if optionName == 'particleSpeed':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=[0.01, 0.0, 0.1, 0.005],
            initValueRangeStep=initValues,
            validRange=(0, 1))

    if optionName == 'timestepSize':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=[1, 0.01, 1, 0.01],
            initValueRangeStep=initValues,
            validRange=(0, float("inf")))

    if optionName == 'netType':
        # check validity of the network type or init to default
        if inputValue is not None:
            decodedNetType = utils._decodeNetworkTypeFromString(inputValue)
            if decodedNetType is None:  # terminating the process if the input argument is wrong
                error_msg = (f"The specified value for netType ={inputValue} is not valid. \n"
                             "Accepted values are: 'full',  'erdos-renyi', 'barabasi-albert', and 'dynamic'.")
                raise exceptions.MuMoTValueError(error_msg)

            return [inputValue, True]
        else:
            decodedNetType = utils._decodeNetworkTypeFromString(initValues) if initValues is not None else None
            if decodedNetType is not None:  # assigning the init value only if it's a valid value
                return [initValues, False]
            else:
                return ['full', False]  # as default netType is set to 'full'
    # @todo: avoid that these value will be overwritten by _update_net_params()
    if optionName == 'netParam':
        netType = extraParam
        systemSize = extraParam2
        # if netType is not fixed, netParam cannot be fixed
        if (not netType[-1]) and inputValue is not None:
            error_msg = ("If netType is not fixed, netParam cannot be fixed. "
                         "Either leave free to widget the 'netParam' or fix the 'netType'.")
            raise exceptions.MuMoTValueError(error_msg)
        # check if netParam range is valid or set the correct default range (systemSize is necessary)
        if utils._decodeNetworkTypeFromString(netType[0]) == consts.NetworkType.FULLY_CONNECTED:
            return [0, 0, 0, False]
        elif utils._decodeNetworkTypeFromString(netType[0]) == consts.NetworkType.ERSOS_RENYI:
            return _parse_input_keyword_for_numeric_widgets(
                inputValue=inputValue,
                defaultValueRangeStep=[0.1, 0.1, 1, 0.1],
                initValueRangeStep=initValues,
                validRange=(0.1, 1.0))
        elif utils._decodeNetworkTypeFromString(netType[0]) == consts.NetworkType.BARABASI_ALBERT:
            maxEdges = systemSize - 1
            return _parse_input_keyword_for_numeric_widgets(
                inputValue=inputValue,
                defaultValueRangeStep=[min(maxEdges, 3), 1, maxEdges, 1],
                initValueRangeStep=initValues,
                validRange=(1, maxEdges))
        elif utils._decodeNetworkTypeFromString(netType[0]) == consts.NetworkType.SPACE:
            pass  # method is not implemented
        elif utils._decodeNetworkTypeFromString(netType[0]) == consts.NetworkType.DYNAMIC:
            return _parse_input_keyword_for_numeric_widgets(
                inputValue=inputValue,
                defaultValueRangeStep=[0.1, 0.0, 1.0, 0.05],
                initValueRangeStep=initValues,
                validRange=(0, 1.0))
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=[0.5, 0, 1, 0.1],
            initValueRangeStep=initValues,
            validRange=(0, float("inf")))

    if optionName == 'plotProportions':
        return _parse_input_keyword_for_boolean_widgets(
            inputValue=inputValue,
            defaultValue=False,
            initValue=initValues,
            paramNameForErrorMsg=optionName)
    if optionName == 'realtimePlot':
        return _parse_input_keyword_for_boolean_widgets(
            inputValue=inputValue,
            defaultValue=False,
            initValue=initValues,
            paramNameForErrorMsg=optionName)
    if optionName == 'showTrace':
        return _parse_input_keyword_for_boolean_widgets(
            inputValue=inputValue,
            defaultValue=False,
            initValue=initValues,
            paramNameForErrorMsg=optionName)
    if optionName == 'showInteractions':
        return _parse_input_keyword_for_boolean_widgets(
            inputValue=inputValue,
            defaultValue=False,
            initValue=initValues,
            paramNameForErrorMsg=optionName)

    if optionName == 'visualisationType':
        if extraParam is not None:
            if extraParam == 'multiagent':
                validVisualisationTypes = ['evo', 'graph', 'final', 'barplot']
            elif extraParam == "SSA":
                validVisualisationTypes = ['evo', 'final', 'barplot']
            elif extraParam == "multicontroller":
                validVisualisationTypes = ['evo', 'final']
        else:
            validVisualisationTypes = ['evo', 'graph', 'final']
        if inputValue is not None:
            if inputValue not in validVisualisationTypes:  # terminating the process if the input argument is wrong
                errorMsg = (f"The specified value for visualisationType = {inputValue} is not valid.\n"
                            f"Valid values are: {validVisualisationTypes}. Please correct it and retry.")
                raise exceptions.MuMoTValueError(errorMsg)
            return [inputValue, True]
        else:
            if initValues in validVisualisationTypes:
                return [initValues, False]
            else:
                return ['evo', False]  # as default visualisationType is set to 'evo'

    if optionName in ('final_x', 'final_y'):
        reactants_str = [str(reactant) for reactant in sorted(extraParam, key=str)]
        if inputValue is not None:
            inputValue = inputValue.replace('\\', '')
            if inputValue not in reactants_str:
                error_msg = (f"The specified value for {optionName} = {inputValue} is not valid.\n"
                             f"Valid values are the reactants: {reactants_str}. Please correct it and retry.")
                raise exceptions.MuMoTValueError(error_msg)
            else:
                return [inputValue, True]
        else:
            if initValues is not None:
                initValues = initValues.replace('\\', '')
            if initValues in reactants_str:
                return [initValues, False]
            else:
                if optionName == 'final_x' or len(reactants_str) == 1:
                    return [reactants_str[0], False]  # as default final_x is set to the first (sorted) reactant
                else:
                    return [reactants_str[1], False]  # as default final_y is set to the second (sorted) reactant

    if optionName == 'runs':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=([20, 5, 100, 5]
                                   if extraParam == 'asNoise'
                                   else [1, 1, 20, 1]),
            initValueRangeStep=initValues,
            validRange=(1, float("inf")))

    if optionName == 'aggregateResults':
        return _parse_input_keyword_for_boolean_widgets(
            inputValue=inputValue,
            defaultValue=True,
            initValue=initValues,
            paramNameForErrorMsg=optionName)

    if optionName == 'initBifParam':
        return _parse_input_keyword_for_numeric_widgets(
            inputValue=inputValue,
            defaultValueRangeStep=[defaults.MuMoTdefault._initialRateValue,
                                   defaults.MuMoTdefault._rateLimits[0],
                                   defaults.MuMoTdefault._rateLimits[1],
                                   defaults.MuMoTdefault._rateStep],
            initValueRangeStep=initValues,
            validRange=(0, float("inf")))

    return [None, False]  # default output for unknown optionName


def _get_item_from_params_list(params: List[str], targetName: str) -> Optional[str]:
    """Params is a list (rather than a dictionary) and this method is necessary to fetch the value by name."""
    for param in params:
        if param[0] == targetName or param[0].replace('\\', '') == targetName or param[0].replace('_', '_{') + '}' == targetName:
            return param[1]
    return None


def _almostEqual(a: float, b: float) -> bool:
    epsilon = 0.0000001
    return abs(a - b) < epsilon


def _parse_input_keyword_for_numeric_widgets(
        inputValue: Optional[object],
        defaultValueRangeStep: List[object],
        initValueRangeStep: List[object],
        validRange: Optional[List[object]] = None,
        onlyValue: Optional[bool] = False) -> List[object]:
    """Parse an input keyword and set initial range and default values (when the input is a slider-widget).

    Check if the fixed value is not None, otherwise it returns the default value (samewise for ``initRange`` and ``defaultRange``).
    The optional parameter ``validRange`` is use to check if the fixedValue has a usable value.
    If the ``defaultValue`` is out of the ``initRange``, the default value is move to the closest of the initRange extremes.

    Parameters
    ----------
    inputValue : object
        if not ``None`` it indicated the fixed value to use
    defaultValueRangeStep : list of object
        Default set of values in the format ``[val,min,max,step]``
    initValueRangeStep : list of object
        User-specified set of values in the format ``[val,min,max,step]``
    validRange : list of object, optional
        The min and max accepted values ``[min,max]``
    onlyValue : bool, optional
        If ``True`` then ``defaultValueRangeStep`` and ``initValueRangeStep`` are only a single value

    Returns
    -------
    values : list of object
        Contains a list of five items (start-value, min-value, max-value,
        step-size, fixed). if onlyValue, it's only two items (start-value,
        fixed). The item fixed is a bool. If ``True`` the value is fixed (partial
        controller active), if ``False`` the widget will be created.

    """
    outputValues = defaultValueRangeStep if not onlyValue else [defaultValueRangeStep]
    if not onlyValue:
        if initValueRangeStep is not None and getattr(initValueRangeStep, "__getitem__", None) is None:
            error_msg = (f"initValueRangeStep value '{initValueRangeStep}' must be specified in the format [val,min,max,step].\n"
                         "Please, correct the value and retry.")
            raise exceptions.MuMoTValueError(error_msg)
    if inputValue is not None:
        if not isinstance(inputValue, numbers.Number):
            error_msg = (f"Input value '{inputValue}' is not a numeric vaule and must be a number.\n"
                         "Please, correct the value and retry.")
            raise exceptions.MuMoTValueError(error_msg)
        elif validRange and (inputValue < validRange[0] or inputValue > validRange[1]):
            error_msg = (f"Input value '{inputValue}' has raised out-of-range exception. Valid range is {validRange}\n"
                         "Please, correct the value and retry.")
            raise exceptions.MuMoTValueError(error_msg)
        else:
            if onlyValue:
                return [inputValue, True]
            else:
                outputValues[0] = inputValue
                outputValues.append(True)
                # it is not necessary to modify the values [min,max,step] because when last value is True, they should be ignored
                return outputValues

    if initValueRangeStep is not None:
        if onlyValue:
            if validRange and (initValueRangeStep < validRange[0] or initValueRangeStep > validRange[1]):
                error_msg = (f"Invalid init value={initValueRangeStep} has raised out-of-range exception. Valid range is {validRange}\n"
                             "Please, correct the value and retry.")
                raise exceptions.MuMoTValueError(error_msg)
            else:
                outputValues = [initValueRangeStep]
        else:
            if initValueRangeStep[1] > initValueRangeStep[2] or initValueRangeStep[0] < initValueRangeStep[1] or initValueRangeStep[0] > initValueRangeStep[2]:
                error_msg = (f"Invalid init range [val,min,max,step]={initValueRangeStep}. Value must be within min and max values.\n"
                             "Please, correct the value and retry.")
                raise exceptions.MuMoTValueError(error_msg)
            elif validRange and (initValueRangeStep[1] < validRange[0] or initValueRangeStep[2] > validRange[1]):
                error_msg = (f"Invalid init range [val,min,max,step]={initValueRangeStep} has raised out-of-range exception. Valid range is {validRange}\n"
                             "Please, correct the value and retry.")
                raise exceptions.MuMoTValueError(error_msg)
            else:
                outputValues = initValueRangeStep

    outputValues.append(False)
    return outputValues


def _parse_input_keyword_for_boolean_widgets(inputValue, defaultValue, initValue=None, paramNameForErrorMsg=None):
    """Parse an input keyword and set initial range and default values (when the input is a boolean checkbox)
    check if the fixed value is not None, otherwise it returns the default value.

    Parameters
    ----------
    inputValue : object
        If not None it indicates the fixed value to use
    defaultValue : bool
        dafault boolean value

    Returns
    -------
    value : object
        The keyword value; 'fixed' is a boolean.
    fixed : bool
        If True the value is fixed (partial controller active); if False the widget will be created.

    """
    if inputValue is not None:
        if not isinstance(inputValue, bool):  # terminating the process if the input argument is wrong
            paramNameForErrorMsg = f"for {paramNameForErrorMsg} = " if paramNameForErrorMsg else ""
            errorMsg = (f"The specified value {paramNameForErrorMsg}'{inputValue}' is not valid. \n"
                        "The value must be a boolean True/False.")
            raise exceptions.MuMoTValueError(errorMsg)
        return [inputValue, True]
    else:
        if isinstance(initValue, bool):
            return [initValue, False]
        else:
            return [defaultValue, False]


def _process_params(params):
    paramsRet = []
    paramNames, paramValues = zip(*params)
    for name in paramNames:
        # self._paramNames.append(name.replace('\\','')) ## @todo: have to rationalise how LaTeX characters are handled
        if name in ('plotLimits', 'systemSize'):
            paramsRet.append(name)
        else:
            expr = parse_latex(name.replace('\\\\', '\\'))
            atoms = expr.atoms()
            if len(atoms) > 1:
                raise exceptions.MuMoTSyntaxError(f"Non-singleton parameter name in parameter {name}")
            for atom in atoms:
                # parameter name should contain a single atom
                pass
            paramsRet.append(atom)

    return (paramsRet, paramValues)


def _decodeNetworkTypeFromString(netTypeStr: str) -> Optional[consts.NetworkType]:
    # init the network type
    admissibleNetTypes = {'full': consts.NetworkType.FULLY_CONNECTED,
                          'erdos-renyi': consts.NetworkType.ERSOS_RENYI,
                          'barabasi-albert': consts.NetworkType.BARABASI_ALBERT,
                          'dynamic': consts.NetworkType.DYNAMIC}

    if netTypeStr not in admissibleNetTypes:
        raise exceptions.MuMoTValueError(f"ERROR! Invalid network type argument! Valid strings are: {admissibleNetTypes}")
    return admissibleNetTypes.get(netTypeStr, None)


def _encodeNetworkTypeToString(netType: consts.NetworkType) -> Optional[str]:
    # init the network type
    netTypeEncoding = {consts.NetworkType.FULLY_CONNECTED: 'full',
                       consts.NetworkType.ERSOS_RENYI: 'erdos-renyi',
                       consts.NetworkType.BARABASI_ALBERT: 'barabasi-albert',
                       consts.NetworkType.DYNAMIC: 'dynamic'}

    if netType not in netTypeEncoding:
        raise exceptions.MuMoTValueError(f"ERROR! Invalid netTypeEncoding table! Tried to encode network type: {netType}")
    return netTypeEncoding.get(netType, 'none')


def _round_to_1(x):
    """Used for determining significant digits for axes formatting in plots MuMoTstreamView and MuMoTbifurcationView."""
    if x == 0:
        return 1
    return round(x, -int(math.floor(math.log10(abs(x)))))


def _make_autopct(values):
    def my_autopct(pct):
        total = sum(values)
        val = int(round(pct * total / 100.0))
        return '{p:.2f}%  ({v:d})'.format(p=pct, v=val)
    return my_autopct
