import numpy as _np

from matplotlib.widgets import AxesWidget as _AxesWidget
from matplotlib.colors import is_color_like as _is_color_like
from matplotlib.axes import Axes as _mplAxes
from matplotlib.figure import Figure as _mplFigure
from IPython.display import display as _ipydisplay

from pyemma.util.types import is_int as _is_int
from scipy.spatial import cKDTree as _cKDTree

from ._bmutils import get_ascending_coord_idx

from mdtraj import Trajectory as _mdTrajectory
from nglview import NGLWidget as _NGLwdg

from ipywidgets import HBox as _HBox, VBox as _VBox

def pts_per_axis_unit(mplax, pt_per_inch=72):
    r"""
    Return how many pt per axis unit of a given maptplotlib axis a figure has

    Parameters
    ----------

    mplax : :obj:`matplotlib.axes._subplots.AxesSubplot`

    pt_per_inch : how many points are in an inch (this number should not change)

    Returns
    --------

    pt_per_xunit, pt_per_yunit

    """

    # matplotlib voodoo
    # Get bounding box
    bbox = mplax.get_window_extent().transformed(mplax.get_figure().dpi_scale_trans.inverted())

    span_inch = _np.array([bbox.width, bbox.height], ndmin=2).T

    span_units = [mplax.get_xlim(), mplax.get_ylim()]
    span_units = _np.diff(span_units, axis=1)

    inch_per_unit = span_inch / span_units
    return inch_per_unit * pt_per_inch



def update2Dlines(iline, x, y):
    """
    provide a common interface to update objects on the plot to a new position (x,y) depending
    on whether they are hlines, vlines, dots etc

    Parameters
    ----------

    iline: :obj:`matplotlib.lines.Line2D` object

    x : float with new position

    y : float with new position

    """
    # TODO FIND OUT A CLEANER WAY TO DO THIS (dict or class)

    if not hasattr(iline,'whatisthis'):
        raise AttributeError("This method will only work if iline has the attribute 'whatsthis'")
    else:
        # TODO find cleaner way of distinguishing these 2Dlines
        if iline.whatisthis in ['dot']:
            iline.set_xdata((x))
            iline.set_ydata((y))
        elif iline.whatisthis in ['lineh']:
            iline.set_ydata((y,y))
        elif iline.whatisthis in ['linev']:
            iline.set_xdata((x,x))
        else:
            # TODO: FIND OUT WNY EXCEPTIONS ARE NOT BEING RAISED
            raise TypeError("what is this type of 2Dline?")


class ClickOnAxisListener(object):
    def __init__(self, ngl_wdg, crosshairs, showclick_objs, ax, pos,
                 list_mpl_objects_to_update):
        self.ngl_wdg = ngl_wdg
        self.crosshairs = crosshairs
        self.showclick_objs = showclick_objs
        self.ax = ax
        self.pos = pos
        self.list_mpl_objects_to_update = list_mpl_objects_to_update
        self.list_of_dots = [None]*self.pos.shape[0]
        self.fig_size = self.ax.figure.get_size_inches()
        self.kdtree = None

    def build_tree(self):
        # Use ax.transData to compute distance in pixels
        # regardelss of the axes units (http://matplotlib.org/users/transforms_tutorial.html)
        # Corresponds to the visual distance between clicked point and target point
        self.kdtree = _cKDTree(self.ax.transData.transform(self.pos))

    @property
    def figure_changed_size(self):
        return not _np.allclose(self.fig_size, self.ax.figure.get_size_inches())

    def __call__(self, event):
        # Wait for the first click or a a figsize change
        # to build the kdtree
        if self.figure_changed_size or self.kdtree is None:
            self.build_tree()
            self.fig_size = self.ax.figure.get_size_inches()

        # Was the click inside the bounding box?
        if self.ax.get_window_extent().contains(event.x, event.y):
            if self.crosshairs:
                for iline in self.showclick_objs:
                    update2Dlines(iline, event.xdata, event.ydata)

            _, index = self.kdtree.query(x=[event.x, event.y], k=1)
            for idot in self.list_mpl_objects_to_update:
                update2Dlines(idot, self.pos[index, 0], self.pos[index, 1])

            self.ngl_wdg.isClick = True
            if hasattr(self.ngl_wdg, '_GeomsInWid'):
                # We're in a sticky situation
                if event.button == 1:
                    # Pressed left
                    self.ngl_wdg._GeomsInWid[index].show()
                    if self.list_of_dots[index] is None:
                        # Plot and store the dot in case there wasn't
                        self.list_of_dots[index] = self.ax.plot(self.pos[index, 0], self.pos[index, 1], 'o',
                                                                c=self.ngl_wdg._GeomsInWid[index].color_dot, ms=7)[0]
                elif event.button in [2, 3]:
                    #  Pressed right or middle
                    self.ngl_wdg._GeomsInWid[index].hide()
                    # Delete dot if the geom is not visible anymore
                    if not self.ngl_wdg._GeomsInWid[index].is_visible() and self.list_of_dots[index] is not None:
                        self.list_of_dots[index].remove()
                        self.list_of_dots[index] = None
            else:
                # We're not sticky, just go to the frame
                self.ngl_wdg.frame = index

class MolPXBox(object):
    r"""
    Class created to be the parent class of MolPXHBox and MolPXVBox, which inherit from
    MolPXBox and the ipywidget classes HBox and VBox (*args and **kwargs are for these)

    The sole purpose of this class is to  avoid monkey-patching elsewhere in the code,
     this class creates them as empty lists on instantiation.

    It also implements two methods:

    * self.display (=IPython.display(self)

    * append_if_existing
    """
    def __init__(self, *args, **kwargs):
        self.linked_axes = []
        self.linked_mdgeoms = []
        self.linked_ngl_wdgs = []
        self.linked_data_arrays = []
        self.linked_ax_wdgs = []
        self.linked_figs = []

    def display(self):
        _ipydisplay(self)

    def append_if_existing(self, args0, startswith_arg="linked_"):
        r"""
        args0  is the tuple containing all widgets to be included in the MolPXBox
        this tuple can contain itself other MolPXWidget
        so we iterate through them and appending linked stuff
        """
        for iarg in args0:
            for attrname in dir(iarg):
                if attrname.startswith(startswith_arg) and len(iarg.__dict__[attrname]) != 0:
                    self.__dict__[attrname] += iarg.__dict__[attrname]

def auto_append_these_mpx_attrs(iobj, *attrs):
    r""" The attribute s name is automatically derived
    from the attribute s type via a type:name dictionary

    *attrs : any number of unnamed objects of the types in type2attrname.
             If the object type is a list, it will be flattened prior to attempting
    """

    attrs_flat_list = []
    for sublist in attrs:
        if isinstance(sublist, list):
            for item in sublist:
                attrs_flat_list.append(item)
        else:
            attrs_flat_list.append(sublist)

    # Go through the arguments and assign them an attrname according to their types
    for iattr in attrs_flat_list:
        for attrname, itype in type2attrname.items():
            if isinstance(iattr, itype):
                iobj.__dict__[attrname].append(iattr)
                break

class MolPXHBox(_HBox, MolPXBox):
    def __init__(self, *args, **kwargs):
        super(MolPXHBox, self).__init__(*args, **kwargs)
        self.append_if_existing(args[0])

class MolPXVBox(_VBox, MolPXBox):
    def __init__(self, *args, **kwargs):
        super(MolPXVBox, self).__init__(*args, **kwargs)
        self.append_if_existing(args[0])

type2attrname = {"linked_axes": _mplAxes,
                 "linked_mdgeoms": _mdTrajectory,
                 "linked_ngl_wdgs": _NGLwdg,
                 "linked_data_arrays": _np.ndarray,
                 "linked_ax_wdgs": _AxesWidget,
                 "linked_figs": _mplFigure,
                 }

class ChangeInNGLWidgetListener(object):

    def __init__(self, ngl_wdg, list_mpl_objects_to_update, pos):
        self.ngl_wdg = ngl_wdg
        self.list_mpl_objects_to_update = list_mpl_objects_to_update
        self.pos = pos
    def __call__(self, change):
        self.ngl_wdg.isClick = False
        _idx = change["new"]
        try:
            for idot in self.list_mpl_objects_to_update:
                update2Dlines(idot, self.pos[_idx, 0], self.pos[_idx, 1])
            #print("caught index error with index %s (new=%s, old=%s)" % (_idx, change["new"], change["old"]))
        except IndexError as e:
            for idot in self.list_mpl_objects_to_update:
                update2Dlines(idot, self.pos[0, 0], self.pos[0, 1])
            print("caught index error with index %s (new=%s, old=%s)" % (_idx, change["new"], change["old"]))
        #print("set xy = (%s, %s)" % (x[_idx], y[_idx]))

class GeometryInNGLWidget(object):
    r"""
    returns an object that is aware of where its geometries are located in the NGLWidget their representation status


    The object exposes two methods, show and hide, to automagically know what to do
    """

    def __init__(self, geom, ngl_wdg, list_of_repr_dicts=None,
                 color_molecule_hex='Element', n_small=10):
        self.lives_at_components = []
        self.geom = geom
        self.ngl_wdg = ngl_wdg
        self.have_repr = []

        sticky_rep = 'cartoon'
        if self.geom[0].top.n_residues < n_small:
            sticky_rep = 'ball+stick'
        if list_of_repr_dicts is None:
            list_of_repr_dicts = [{'repr_type': sticky_rep, 'selection': 'all'}]

        self.list_of_repr_dicts = list_of_repr_dicts
        self.color_molecule_hex = color_molecule_hex
        self.color_dot = color_molecule_hex
        if isinstance(self.color_molecule_hex, str) and color_molecule_hex == 'Element':
            self.color_dot = 'red'

    def show(self):
        # Show can mean either
        #    - add a whole new component (case 1)
        #    - add the representation again to a representation-less component (case 2)

        # CASE 1
        if self.is_empty() or self.all_reps_are_on():
            if len(self.have_repr) == self.geom.n_frames:
                print("arrived at the end")
                component = None
            else:
                idx = len(self.have_repr)

                self.ngl_wdg.add_trajectory(self.geom[idx])
                self.lives_at_components.append(len(self.ngl_wdg._ngl_component_ids) - 1)
                self.ngl_wdg.clear_representations(component=self.lives_at_components[-1])
                self.have_repr.append(True)
                component = self.lives_at_components[-1]

        # CASE 2
        elif self.any_rep_is_off():  # Some are living in the widget already but have no rep
            idx = _np.argwhere(~_np.array(self.have_repr))[0].squeeze()
            component = self.lives_at_components[idx]
            self.have_repr[idx] = True
        else:
            raise Exception("This situation should not arise. This is a bug")

        if component is not None:
            for irepr in self.list_of_repr_dicts:
                self.ngl_wdg.add_representation(irepr['repr_type'],
                                                selection=irepr['selection'],
                                                component=component,
                                                color=self.color_molecule_hex)

    def hide(self):
        if self.is_empty() or self.all_reps_are_off():
            print("nothing to hide")
            pass
        elif self.any_rep_is_on():  # There's represented components already in the widget
            idx = _np.argwhere(self.have_repr)[-1].squeeze()
            self.ngl_wdg.clear_representations(component=self.lives_at_components[idx])
            self.have_repr[idx] = False
        else:
            raise Exception("This situation should not arise. This is a bug")

    # Quickhand methods for knowing what's up
    def is_empty(self):
        if len(self.have_repr) == 0:
            return True
        else:
            return False

    def all_reps_are_off(self):
        if len(self.have_repr) == 0:
            return True
        else:
            return _np.all(~_np.array(self.have_repr))

    def all_reps_are_on(self):
        if len(self.have_repr) == 0:
            return False
        else:
            return _np.all(self.have_repr)

    def any_rep_is_off(self):
        return _np.any(~_np.array(self.have_repr))

    def any_rep_is_on(self):
        return _np.any(self.have_repr)

    def is_visible(self):
        if self.is_empty() or self.all_reps_are_off():
            return False
        else:
            return True

def link_ax_w_pos_2_nglwidget(ax, pos, ngl_wdg,
                              crosshairs=True,
                              dot_color='red',
                              band_width=None,
                              radius=False,
                              directionality=None,
                              exclude_coord=None,
                              ):
    r"""
    Initial idea for this function comes from @arose, the rest is @gph82

    Parameters
    ----------
    ax : matplotlib axis object to be linked

    pos : ndarray of shape (N,2) with the positions of the geoms in the ngl_wdg

    crosshairs : Boolean or str
        If True, a crosshair will show where the mouse-click ocurred. If 'h' or 'v', only the horizontal or
        vertical line of the crosshair will be shown, respectively. If False, no crosshair will appear

    dot_color : Anything that yields matplotlib.colors.is_color_like(dot_color)==True
        Default is 'red'. dot_color='None' yields no dot

    band_width : None or iterable of len = 2
        If band_width is not None, the method tries to figure out on its own if
        there is an ascending coordinate and will include a moving band on :obj:ax
        of this width (in units of the axis along which the band is plotted)

        If the method cannot find an ascending coordinate, an exception is thrown

    directionality : str or None, default is None
        If not None, directionality can be either 'a2w' or 'w2a', meaning that connectivity
         between axis and widget will be only established as
         * 'a2w' : action in axis   triggers action in widget, but not the other way around
         * 'w2a' : action in widget triggers action in axis, but not the other way around

    exclude_coord : None or int , default is None
        The excluded coordinate will not be considered when computing the nearest-point-to-click.
        Typical use case is for visualize.traj to only compute distances horizontally along the time axis

    Returns
    -------

    axes_widget : :obj:`matplotlib.Axes.Axeswidget` that has been linked to the NGLWidget
    """

    assert directionality in [None, 'a2w', 'w2a'], "The directionality parameter has to be in [None, 'a2w', 'w2a'] " \
                                                   "not %s"%directionality

    assert crosshairs in [True, False, 'h', 'v'], "The crosshairs parameter has to be in [True, False, 'h','v'], " \
                                                   "not %s" % crosshairs
    ipos = _np.copy(pos)
    if _is_int(exclude_coord):
        ipos[:,exclude_coord] = 0

    # Are we in a sticky situation?
    if hasattr(ngl_wdg, '_GeomsInWid'):
        sticky = True
    else:
        assert ngl_wdg.trajectory_0.n_frames == pos.shape[0], \
            ("Mismatching frame numbers %u vs %u" % (ngl_wdg.trajectory_0.n_frames, pos.shape[0]))
        sticky = False

    # Basic interactive objects
    showclick_objs = []
    if crosshairs in [True, 'h']:
        lineh = ax.axhline(ax.get_ybound()[0], c="black", ls='--')
        setattr(lineh, 'whatisthis', 'lineh')
        showclick_objs.append(lineh)
    if crosshairs in [True, 'v']:
        linev = ax.axvline(ax.get_xbound()[0], c="black", ls='--')
        setattr(linev, 'whatisthis', 'linev')
        showclick_objs.append(linev)

    if _is_color_like(dot_color):
        pass
    else:
        raise TypeError('dot_color should be a matplotlib color')

    dot = ax.plot(pos[0,0],pos[0,1], 'o', c=dot_color, ms=7, zorder=100)[0]
    setattr(dot,'whatisthis','dot')
    list_mpl_objects_to_update = [dot]

    # Other objects, related to smoothing options
    if band_width is not None:
        if radius:
            band_width_in_pts = int(_np.round(pts_per_axis_unit(ax).mean() * _np.mean(band_width)))
            rad = ax.plot(pos[0, 0], pos[0, 1], 'o',
                          ms=_np.round(band_width_in_pts),
                          c='green', alpha=.25, markeredgecolor='None')[0]
            setattr(rad, 'whatisthis', 'dot')
            if not sticky:
                list_mpl_objects_to_update.append(rad)
        else:
            # print("Band_width(x,y) is %s" % (band_width))
            coord_idx = get_ascending_coord_idx(pos)
            if _np.ndim(coord_idx)>0 and len(coord_idx)==0:
                raise ValueError("Must have an ascending coordinate for band_width usage")
            band_width_in_pts = int(_np.round(pts_per_axis_unit(ax)[coord_idx] * band_width[coord_idx]))
            # print("Band_width in %s is %s pts"%('xy'[coord_idx], band_width_in_pts))

            band_call = [ax.axvline, ax.axhline][coord_idx]
            band_init = [ax.get_xbound, ax.get_ybound][coord_idx]
            band_type = ['linev',  'lineh'][coord_idx]
            band = band_call(band_init()[0],
                             lw=band_width_in_pts,
                             c="green", ls='-',
                             alpha=.25)
            setattr(band, 'whatisthis', band_type)
            list_mpl_objects_to_update.append(band)

    ngl_wdg.isClick = False

    CLA_listener = ClickOnAxisListener(ngl_wdg, crosshairs, showclick_objs, ax, pos,
                                       list_mpl_objects_to_update)

    NGL_listener = ChangeInNGLWidgetListener(ngl_wdg, list_mpl_objects_to_update, pos)
    # Connect axes to widget
    axes_widget = _AxesWidget(ax)
    if directionality in [None, 'a2w']:
        axes_widget.connect_event('button_release_event', CLA_listener)

    # Connect widget to axes
    if directionality in [None, 'w2a']:
        ngl_wdg.observe(NGL_listener, "frame", "change")

    ngl_wdg.center()
    return axes_widget
