import numpy as np
import lmfit
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtGui


class FitModel(lmfit.Model):
    """ Simple extension of lmfit.Model that allows one-line fitting.
    
    Example uses:
        
    # single exponential fit::
    
        fit = expfitting.Exp1.fit(data, 
            x=time_vals,
            xoffset=(0, 'fixed'),
            yoffset=(yoff_guess, -120, 0),
            amp=(amp_guess, 0, 50),
            tau=(tau_guess, 0.1, 50))
        
    # plot the fit::
    
        fit_curve = fit.eval()
        plot(time_vals, fit_curve)
    
    
    # double exponential fit with tau ratio constraint
    # note that 'tau_ratio' does not appear in the exp2 model; 
    # we can define new parameters here.::

        fit = expfitting.Exp2.fit(data, 
                x=time_vals,
                xoffset=(0, 'fixed'),
                yoffset=(yoff_guess, -120, 0),
                amp1=(amp_guess, 0, 50),
                tau1=(tau_guess, 0.1, 50),
                amp2=(-0.5, -50, 0),
                tau_ratio=(10, 3, 50),
                tau2='tau1 * tau_ratio'
                )
        
    """

    def fit(self, data, interactive=False, **params):
        """ Return a fit of data to this model.
        
        Parameters
        ----------
        data : array
            dependent data to fit
        interactive : bool
            If True, show a GUI used for interactively exploring fit parameters
            
        Extra keyword arguments are passed to make_params() if they are model
        parameter names, or passed directly to Model.fit() for independent
        variable names.
        
        Returns
        -------
        fit of data to model as an lmfit object
        """

        fit_params = {}
        model_params = {}
        for k, v in params.items():
            if k in self.independent_vars or k in [
                "weights",
                "method",
                "scale_covar",
                "iter_cb",
            ]:
                fit_params[k] = v
            else:
                model_params[k] = v
        p = self.make_params(**model_params)
        # print ('params: ', p)
        # print ('fitparams: ', fit_params)
        # import matplotlib.pyplot as mpl
        # mpl.plot(data)
        # mpl.show()

        fit = lmfit.Model.fit(self, data, params=p, **fit_params)
        if interactive:
            self.show_interactive(fit)
        return fit

    def make_params(self, **params):
        """
        Make parameters used for fitting with this model.
        
        Keyword arguments are used to generate parameters for the fit. Each 
        parameter may be specified by the following formats:
        
        param=value :
            The initial value of the parameter
        param=(value, 'fixed') :
            Fixed value for the parameter
        param=(value, min, max) :
            Initial value and min, max values, which may be float or None
        param='expression' :
            Expression used to compute parameter value. See:
            http://lmfit.github.io/lmfit-py/constraints.html#constraints-chapter
        """
        p = lmfit.Parameters()
        for k in self.param_names:
            p.add(k)

        for param, val in params.items():
            if param not in p:
                p.add(param)

            if isinstance(val, str):
                p[param].expr = val
            elif np.isscalar(val):
                p[param].value = val
            elif isinstance(val, tuple):
                if len(val) == 2:
                    assert val[1] == "fixed"
                    p[param].value = val[0]
                    p[param].vary = False
                elif len(val) == 3:
                    p[param].value = val[0]
                    p[param].min = val[1]
                    p[param].max = val[2]
                else:
                    raise TypeError(
                        "Tuple parameter specifications must be (val, 'fixed')"
                        " or (val, min, max)."
                    )
            else:
                raise TypeError("Invalid parameter specification: %r" % val)

        # set initial values for parameters with mathematical constraints
        # this is to allow fit.eval(**fit.init_params)
        global_ns = np.__dict__
        for param, val in params.items():
            if isinstance(val, str):
                p[param].value = eval(val, global_ns, p.valuesdict())
        return p

    def show_interactive(self, fit=None):
        """ Show an interactive GUI for exploring fit parameters.
        """
        if not hasattr(self, "_interactive_win"):
            self._interactive_win = FitExplorer(model=self, fit=fit)
        self._interactive_win.show()


def exp1(x, xoffset, yoffset, tau, amp):
    return yoffset + amp * np.exp(-(x - xoffset) / tau)


class Exp1(FitModel):
    """ Single exponential fitting model.
    
    Parameters are xoffset, yoffset, amp, and tau.
    """

    def __init__(self):
        FitModel.__init__(self, exp1, independent_vars=["x"])


def exp2(x, xoffset, yoffset, tau1, amp1, tau2, amp2):
    xoff = x - xoffset
    return yoffset + amp1 * np.exp(-xoff / tau1) + amp2 * np.exp(-xoff / tau2)


class Exp2(FitModel):
    """ Double exponential fitting model.
    
    Parameters are xoffset, yoffset, amp1, tau1, amp2, and tau2.
    """

    def __init__(self):
        FitModel.__init__(self, exp2, independent_vars=["x"])


class FitExplorer(QtGui.QWidget):
    def __init__(self, model, fit):
        QtGui.QWidget.__init__(self)
        self.model = model
        self.fit = fit
        self.layout = QtGui.QGridLayout()
        self.setLayout(self.layout)
        self.splitter = QtGui.QSplitter(QtCore.Qt.Horizontal)
        self.layout.addWidget(self.splitter)
        self.ptree = pg.parametertree.ParameterTree()
        self.splitter.addWidget(self.ptree)
        self.plot = pg.PlotWidget()
        self.splitter.addWidget(self.plot)

        self.params = pg.parametertree.Parameter.create(
            name="param_root",
            type="group",
            children=[
                dict(name="fit", type="action"),
                dict(name="parameters", type="group"),
            ],
        )

        for k in fit.params:
            p = pg.parametertree.Parameter.create(
                name=k, type="float", value=fit.params[k].value
            )
            self.params.param("parameters").addChild(p)

        self.ptree.setParameters(self.params)

        self.update_plots()

        self.params.param("parameters").sigTreeStateChanged.connect(self.update_plots)

    def update_plots(self):
        for k in self.fit.params:
            self.fit.params[k].value = self.params["parameters", k]

        self.plot.clear()
        self.plot.plot(self.fit.data)
        self.plot.plot(self.fit.eval(), pen="y")
