# -*- coding: utf-8 -*-
"""
The view module hosts the View class, the central object of cv2d.
"""
import logging
import os
import sys
import six
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import FormatStrFormatter
from matplotlib.widgets import Slider, Button
    

import yaml

from colorview2d import Data
import colorview2d.utils as utils

# setup logging

LOGGER = logging.getLogger('colorview2d')
LOGGER.setLevel(logging.DEBUG)
# create file handler which logs even debug messages
FHAND = logging.FileHandler('colorview2d.log')
FHAND.setLevel(logging.DEBUG)
# create console handler with a higher log level
CHAND = logging.StreamHandler()
CHAND.setLevel(logging.ERROR)
# create formatter and add it to the handlers
FORMATTER = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
FHAND.setFormatter(FORMATTER)
CHAND.setFormatter(FORMATTER)
# add the handlers to the logger
LOGGER.addHandler(FHAND)
LOGGER.addHandler(CHAND)


class View(object):
    """
    A class to handle a 2d :class:`numpy.ndarray` with (linearly scaled) _axes, apply a (extendable)
    range of filters (mods) to the data while keeping track of the
    modifications.

    Hosts a :class:`matplotlib.pyplot.Figure` of the data. Customization of this figure
    is simplified with respect to the matplotlib library.
    Provides interactive colorbar controls.

    *Note*: The class provides methods ``add_<Modname>(arg1, ...)`` and ``rm_<Modname>()``
    that are not documented because they are generated on-init. There is one such method
    for each mod in ``modlist``.

    :Example:

    ::

        data = colroview2d.Data(np.random.random((100, 100)))
        fig = colorview2d.View(data)
        fig.add_Smooth(2, 2)
        fig.plot_pdf('Test.pdf')


    """
    def __init__(self, data=None,
                 cfgfile=None,
                 config=None,
                 pipeline=None):

        self._modlist = {}
        self._create_modlist()

        self._data = None
        
        if isinstance(data, np.ndarray):
            self._data = Data(data)
        elif isinstance(data, Data):
            self._data = data
        else:
            raise ValueError("Provide a 2d numpy.ndarray or a colorview2d.Data"
                             "instance to create a View object.")
        self._original_data = self._data.deep_copy()

        self._config = utils.Config()
        # overwrite the on_change hook of the Config class.
        # this way we can react to changes in the config appropriately.
        self._config.on_change = self._on_config_change
            
        # The pipeline contains a dict of numbers and tuples with
        # strings that are unique to IMod objects
        # and their arguments
        self._pipeline = []

        if cfgfile:
            # If a config file is provided, we load that one.
            # All other parameters are ignored.
            # Note: The filename must be removed from the config file
            self.load_config(os.path.join(os.getcwd(), cfgfile))


        # if the config argument is not empty we replace the values
        if config:
            self._config.update_raw(config)

        # Matplotlib figure object, contains the actual plot
        # Generated upon retrieval by property accessor
        # Readonly, Initialized with one pixel
        plt.ioff()
        self._fig = plt.figure(1, dpi=self._config['Dpi'])
        self._colorcontrolfigure = plt.figure(figsize=(9, 1))

        # We use the property setter to add the given pipeline.
        if pipeline is not None:
            self.pipeline = pipeline

        self._apply_pipeline()


    @property
    def modlist(self):
        """A (autogenerated) list of all mods that can be found in the mods/ subfolder."""
        return self._modlist

    @property
    def data(self):
        """A :class:`colorview2d.Data`. It encapsulates the 2d data."""
        return self._data

    @data.setter
    def data(self, data):
        """Sets the :class:`colorview2d.Data` of the View."""
        self._data = data
        self._data_changed()


    def _data_changed(self):
        """Called when the data is modified.
        
        Takes care to update any exiting plotting facilities.
        Is called internally after mod application.
        """

        if self.plotting:
            self._plot.set_data(self._data.zdata)
            self._plot.set_extent([self._data.xleft, self._data.xright,
                                   self._data.ybottom, self._data.ytop])
            self._axes.set_xlim(self._data.xleft, self._data.xright)
            self._axes.set_ylim(self._data.ybottom, self._data.ytop)

            # we redraw the colorbar sliders to set the slider range correctly
            if self._colorcontrolfigure.axes:
                self._show_cbsliders()
            # re-setting the value triggers update of the plot
            self._config['Cbmin'] = 'auto'
            self._config['Cbmax'] = 'auto'

        return

    @property
    def config(self):
        """Holds information on the plot layout, ticks, fonts etc.
        Can be accessed via ``__setitem__``, i.e., ``myview.config['parameter']``.
        Also an ``config.update(dict)`` function is available.
        The attribute is initialized with a fixed set of parameters read from
        ``default.cv2d`` config file in the package directory.

        *Important:* Does not fully implement a dictionary interface.

        Methods:
            update (dict): update the configuration with a dictionary containing
                valid parameters. Note that the plot, if there is any,
                is updated when the config is changed via ``update``
            update_raw (dict): update the configuration without updating any
                existing plot.
        """
        return self._config

    @config.setter
    def config(self, config_dict):
        """Change the config. Note that it is a custom :class:`colorview2d.utils.ConfigDict` class.
        We use the update routine to prevent overwriting the private attribute with
        an ordinary :class:`dict`

        Anyways: Be careful when overwriting the config because there is no error checking
        on the values given!

        Args:
            config_dict (dict): dictionary with configuration items.
        """
        self._config.update(config_dict)

    @property
    def fig(self):
        """The :class:`matplotlib.pyplot.figure`."""
        if not hasattr(self, '_plot'):
            self.draw_plot()
        return self._fig

    @property
    def pipeline(self):
        """A dictionary with mod identifiers (strings) and their arguments (tuples)."""
        return self._pipeline

    @pipeline.setter
    def pipeline(self, pipeline):
        """Overwrite the pipeline string. Note that this is used for initialization
        and does not trigger any modifications to the data.

        Args:
            pipeline (list): A list of strings that are valid mod identifiers.
        """
        self._pipeline = []

        for modstring in pipeline:
            self.add_mod(modstring[0], modstring[1])

    @property
    def plotting(self):
        """Boolean. Are we showing any plot at the moment?"""
        # We use the existence of the private _plot attribute
        # as a primer.
        return hasattr(self, '_plot')

    # def show(self):
    #     """Show the figure in the GUI.
    #     Can be used only if wxpython is installed.

    #     The GUI is not yet functional.
    #     """

    #     try:
    #         import colorview2d.mainapp as mainapp
    #     except ImportError:
    #         logging.error('Cannot start the GUI. Is wxpython installed?')
    #         return

    #     logging.info("Initializing the GUI.")
    #     self.mainapp = mainapp.MainApp(self)
    #     self.mainapp.MainLoop()

    def show_plt_fig(self):
        """Show two interactive :class:`matplotlib.pyplot.Figure` plots.
        The first displays the data with config and pipeline applied.
        The second provides two matplotlib slider widgets to control
        the limits of the colorbar interactively and a *Reset* button
        to apply the default (full-range) colorbar limits.
        """
        # in order to successively open and close the
        # interactive figure, we have to
        # create a dummy figure and use its
        # manager to display "fig"

        if not self._plt_fig_is_active():
            dummy_fig = plt.figure()
            self._fig_manager = dummy_fig.canvas.manager
            self._fig_manager.canvas.figure = self._fig
            self._fig.set_canvas(self._fig_manager.canvas)
            self._fig.canvas.set_window_title('colorview2d plot')

            dummy_fig_colorctrls = plt.figure(figsize=(9, 1))
            self._fig_manager_colorctrls = dummy_fig_colorctrls.canvas.manager
            self._fig_manager_colorctrls.canvas.figure = self._colorcontrolfigure
            self._colorcontrolfigure.set_canvas(self._fig_manager_colorctrls.canvas)
            self._colorcontrolfigure.canvas.set_window_title('colorview2d colorbar control')
            

        self.draw_plot()
        self._fig.show()
        self._show_cbsliders()
        plt.ion()



    def _plt_fig_is_active(self):
        """Check if there is an active canvas manager.
        If there is, we are (hopefully) running an active matplotlib.pyplot window
        with an interactive plot.

        Returns:
            boolean
        """
        return hasattr(self, '_fig_manager')


    def hide_plt_fig(self):
        """Hide the interactive :class:`matplotlib.pyplot.Figure`."""
        # To this end we have to destroy the figure manager.
        # See maptlotlib.pyplot.close().
        if self._plt_fig_is_active():
            plt._pylab_helpers.Gcf.destroy(self._fig_manager.num)
            delattr(self, '_fig_manager')
            plt._pylab_helpers.Gcf.destroy(self._fig_manager_colorctrls.num)
            delattr(self, '_fig_manager_colorctrls')
        # we delete _plot which indicates that we are not plotting
        if hasattr(self, '_plot'):
            delattr(self, '_plot')

    def _create_modlist(self):
        """
        Creates the list of mods from the mods/ folder and adds them
        to the private modlist attribute.

        We check if the module (with arbitrary name) contains a class
        which inherits from colorview2d.IMod
        """
        import pkgutil
        import inspect

        import colorview2d.mods

        package = colorview2d.mods
        for importer, modname, ispckg in pkgutil.iter_modules(package.__path__):
            try:
                mod = importer.find_module(modname).load_module(modname)
                for name, obj in inspect.getmembers(mod):
                    if inspect.isclass(obj):
                        if issubclass(obj, colorview2d.IMod):
                            self._modlist[name] = obj()
            except:
                error = sys.exc_info()[0]
                logging.error('Can not import mod %s.', modname)
                logging.error('Error: %s.', error)
        # Now let us export functions of the form add_Modname and rm_Modname
        # to the namespace of the View class

        def add_func_signatures(modtitle):
            def addme(*args):
                self.add_mod(modtitle, args)
            addme.__name__ = "add_%s" % modtitle
            addme.__doc__ = self._modlist[modtitle].do_apply.__doc__
            setattr(self, addme.__name__, addme)

            def removeme():
                self.remove_mod(modtitle)
            removeme.__name__ = "rm_%s" % modtitle
            removeme.__doc__ = "Remove mod %s from pipeline." % modtitle
            setattr(self, removeme.__name__, removeme)
            
        
        for modtitle in self._modlist:
            add_func_signatures(modtitle)

    def add_mod(self, modname, modargs=(), pos=-1, do_apply=True):
        """Add a mod to the pipeline by its title string and its arguments either
        to the end of the pipeline or at a specified postion.

        Args:
            modname (string): The type of the mod.
            modargs (tuple): A tuple containing the arguments of the mod.
            pos (int): Where to add the mod in the pipeline. Default is last.
            do_apply (boolean): Trigger modification of the data (True) or just add
                mod to the pipeline.
        """

        logging.info('Add mod %s to pipeline with arguments %s', modname, modargs)
        modstring = (modname, modargs)

        if self._modlist[modname]:
            if pos == -1:
                self._pipeline.append(modstring)
            elif pos < len(self._pipeline) and pos >= 1:
                self._pipeline.insert(pos - 1, modstring)
            else:
                logging.warn('Position %d not available in pipeline.', pos)
        else:
            logging.warn('Mod %s not available in mod plugin list.', modname)

        if do_apply:
            self._apply_pipeline()

    def remove_mod(self, modtype=None, pos=-1, do_apply=True):
        """Removes the last mod from the pipeline, or the mod at position pos
        or the last mod in the pipeline with the type modtype.

        Args:
            modtype (string): The identifier of the mod type.
            pos (int): The position of the mod in the pipeline.
            do_apply (bool): Is the pipeline applied after the element is removed?
        """

        if pos == -1 and not modtype:
            self._pipeline.pop()
        elif pos >= 1 and pos <= len(self._pipeline):
            del self._pipeline[pos - 1]
        elif modtype:
            found = False
            for modtuple in reversed(self._pipeline):
                if modtuple[0] == modtype:
                    self._pipeline.remove(modtuple)
                    found = True
                    break
            if not found:
                logging.warn('Mod %s not in current pipeline.', modtype)
        else:
                logging.warn('Pos = %d is not a valid position.', pos)

        if do_apply:
            self._apply_pipeline()

    def clear_pipeline(self):
        """Clear the pipeline and update the plot."""
        self._pipeline = []
        self._apply_pipeline()

    def _apply_pipeline(self):
        """Applies the pipeline to the data.

        It is normally not necessary to manually call this function unless
        the pipeline string is not overwritten directly.

        The data is first reverted to its original state,
        then mods are applied in the order they were added.
        The plot panel is notified of the update in the data.
        The main panel is signalled to update the color controls.
        """

        self._data = self._original_data.deep_copy()

        for pos, modtuple in enumerate(self._pipeline):
            mod = self._modlist[modtuple[0]]
            if mod:
                # if apply returns false, the application failed and the
                # mod is removed from the pipeline
                if not mod.apply(self._data, modtuple[1]):
                    logging.warning(
                        'Application of mod %s at position %d failed.'
                        'Removing mod from pipeline.',
                        mod.title,
                        pos)
                    self.remove_mod(pos)
            else:
                logging.warning('No mod candidate found for %s.', modtuple[0])

        self._data_changed()


    def get_arraydata(self):
        """Shortcut for the 2d data contained int the data.

        Returns:
            2d numpy.ndarray
        """
        return self.data.zdata

    def replace_data(self, newdata):
        """Replace the data.

        In contrast to calling ``myview.data = newdata``, the method replace_data
        also replaces the copy of the *raw* data.

        *Warning*: Some modifications may not be applicable to the new data.

        Args:
            newdata (:class:`colorview2d.Data`): the new data.
        """
        self._data = newdata
        self._original_data = newdata.deep_copy()
        self._apply_pipeline()

    def load_config(self, cfgpath):
        """Load the configuration and the pipeline from a config file
        specified in the YAML format.

        Args:
            cfgpath (string): The path to a cv2d configuration file.
        """
        from ast import literal_eval

        with open(cfgpath) as cfgfile:
            doclist = yaml.load_all(cfgfile)
            # The config dict is the first yaml document

            # Note that the advance_iterator does doclist.next()
            # and is a 2to3 compatibility issue
            self._config.update_raw(six.advance_iterator(doclist))
            if self.plotting:
                self.draw_plot()
            # The pipeline string is the second. It is optional.
            try:
                logging.info('Pipeline string found: %s', self.pipeline)
                pipeline = literal_eval(six.advance_iterator(doclist))
                # Note that the property setter is called
                # applying the mods one by one
                self.pipeline = pipeline

            except StopIteration:
                logging.info('No pipeline string found.')


    def save_config(self, cfgpath):
        """Save the configuration and the pipeline to a config file.

        Args:
            cfgpath (string): the path to the config file
        """
        with open(cfgpath, 'w') as stream:
            # We write first the config dict
            yaml.dump(self._config.dict, stream, explicit_start=True)
            # ... and second the pipeline string
            yaml.dump(repr(self._pipeline), stream, explicit_start=True)

    def _on_config_change(self, key, value):
        """Called when paramters in the utils.Config class are changed.
        
        We use different levels of severeness. When only colorbar settings
        are changed, this can be done easily, but changes to the font
        require redrawing the whole plot.
        """
        # When there is no plot we do not care at the moment.
        if not self.plotting:
            return

        # For all changes to the colorbar we just have to call _plot.changed()
        # to redraw
        if key == 'Colormap':
            self._plot.set_cmap(self._config['Colormap'])
        elif key in ['Cbmin', 'Cbmax']:
            (cbmin, cbmax) = self._get_cblims()
            self._plot.set_clim(vmin=cbmin, vmax=cbmax)
            # update the slider
            if self._colorcontrolfigure.axes:
                self._min_slider.set_val(cbmin)
                self._max_slider.set_val(cbmax)

        if key in ['Colormap', 'Cbmin', 'Cbmax']:
            self._plot.changed()
            return

        # If config_dict only contains changes that do not need a redrawing
        # of the plot we apply them and return
        if key in ['Xlabel', 'Ylabel', 'Xtickformat', 'Ytickformat', 'Cblabel']:
            self._apply_config_post_plot()
            return

        # If the font parameters, the ticksize or the format of the colorbar ticks
        # is changed, we have to redraw the plot
        self.draw_plot()

    def plot_pdf(self, filename):
        """Redraw the figure and plot it to a pdf file."""
        self.draw_plot()
        # Note that the Width and Height parameters are *only* applied
        # when plotting to pdf.
        self._fig.set_size_inches(self._config['Width'], self._config['Height'])
        self._fig.tight_layout()
        self._fig.savefig(filename, dpi=self._config['Dpi'])

    def draw_plot(self):
        """(Re-)draw the :class:`matplotlib.pyplot.figure`.

        This method is intended to be used directly when you are only interested
        in the figure object itself which can be obtained by ``myview.fig`` attribute.

        It includes an axes object containing the (imshow generated)
        2d color plot with labels, ticks and colorbar as specified in the
        config dictionary.
        """
        self._fig.clear()
        self._axes = self._fig.add_subplot(111)
        self._apply_config_pre_plot()

        self._plot = self._axes.imshow(self.get_arraydata(),
            extent=[self.data.xleft,
                    self.data.xright,
                    self.data.ybottom,
                    self.data.ytop],
            aspect='auto',
            origin='lower',
            interpolation="nearest")

        if not self._config['Cbtickformat'] == 'auto':
            self.colorbar = self._fig.colorbar(
                self._plot,
                format=FormatStrFormatter(self._config['Cbtickformat']))
        else:
            self.colorbar = self._fig.colorbar(self._plot)

        # we set the correct colorbar settings
        # this call seems redundant but invokes the update
        # of the colorbar
        self.config = {'Cbmin':self.config['Cbmin'], 'Cbmax':self.config['Cbmax']}

        self._apply_config_post_plot()

        self._plot.changed()
        self._fig.tight_layout()

    def _show_cbsliders(self):
        """Add sliders for the width and the center of the colorbar."""
        self._colorcontrolfigure.clear()

        axcolor = 'lightgoldenrodyellow'
        axmax = self._colorcontrolfigure.add_axes([0.2, 0.4, 0.65, 0.1],
                                                  axisbg=axcolor,
                                                  axisbelow=True)
        axmin = self._colorcontrolfigure.add_axes([0.2, 0.7, 0.65, 0.1],
                                                  axisbg=axcolor,
                                                  axisbelow=True)
        (cbmin, cbmax) = self._get_cblims()

        self._max_slider = Slider(axmax,
                                  label='Colorbar max',
                                  valmin=self.data.zmin,
                                  valmax=self.data.zmax,
                                  valinit=cbmax,
                                  valfmt='%.3e')
        self._min_slider = Slider(axmin,
                                  label='Colorbar min',
                                  valmin=self.data.zmin,
                                  valmax=self.data.zmax,
                                  valfmt='%.3e',
                                  valinit=cbmin)
        self._max_slider.slidermin = self._min_slider
        self._min_slider.slidermax = self._max_slider

        def update(val):
            # in order to avoid an infinite recursion we have to
            # do the setup of the colorbar limits manually
            # (set_val is called by _on_config_change)
            self._plot.set_clim(self._min_slider.val, self._max_slider.val)
            self.config.update_raw({'Cbmax': self._max_slider.val, 'Cbmin': self._min_slider.val})
            self._fig.show()

        self._max_slider.on_changed(update)
        self._min_slider.on_changed(update)

        resetax = self._colorcontrolfigure.add_axes([0.2, 0.1, 0.1, 0.18])
        self._colorcontrolfigure._button = Button(resetax, 'Reset', color=axcolor, hovercolor='0.975')

        def reset(event):
            self.config.update({'Cbmax': 'auto', 'Cbmin': 'auto'})
        self._colorcontrolfigure._button.on_clicked(reset)

        self._colorcontrolfigure.show()

    def _get_cblims(self):
        """Obtain the colorbar limits from the config and resolves the 'auto'
        case to zmin/zmax value.

        This is intended to be used in the actual plotting routines and the colorbar controls 
        that do not accept 'auto'.
        """
        # If the colorbar is set to auto
        # we use zmin/zmax
        if self.config['Cbmax'] == 'auto':
            cbmax = self.data.zmax
        else:
            cbmax = self.config['Cbmax']
        if self.config['Cbmin'] == 'auto':
            cbmin = self.data.zmin
        else:
            cbmin = self.config['Cbmin']

        return (cbmin, cbmax)

        
    def _apply_config_post_plot(self):
        """
        The function applies the rest of the configuration to the plot.
        Note that the colorbar is created in this function because
        colorbar.ax.yaxis.set_major_formatter(FormatStrFormatter(string)) does not work properly.
        """
        self._axes.set_ylabel(self._config['Ylabel'])
        self._axes.set_xlabel(self._config['Xlabel'])

        self.colorbar.set_label(self._config['Cblabel'])
        if not self._config['Xtickformat'] == 'auto':
            self._axes.xaxis.set_major_formatter(FormatStrFormatter(self._config['Xtickformat']))
        if not self._config['Ytickformat'] == 'auto':
            self._axes.yaxis.set_major_formatter(FormatStrFormatter(self._config['Ytickformat']))
        self._plot.set_cmap(self._config['Colormap'])



    def _apply_config_pre_plot(self):
        """
        Applies the ticks and labels stored in the MainFrame.
        This function is called before the actual plot is drawn.
        This pre_plot hook is necessary because the rcParams['font.family']
        attribute can not be changed after the plot is drawn.
        """

        logging.info("Font now {}".format(self._config['Font']))

        if self._config['Font'] is 'default':
            plt.rcParams.update(plt.rcParamsDefault)
        else:
            plt.rcParams['font.family'] = self._config['Font']
        plt.rcParams['font.size'] = self._config['Fontsize']
        plt.rcParams['xtick.major.size'] = self._config['Xticklength']
        plt.rcParams['ytick.major.size'] = self._config['Yticklength']

    
