"""
********************************************************************************
plotter
********************************************************************************

.. currentmodule:: compas_plotters.plotter

Classes
=======

.. autosummary::
    :toctree: generated/
    :nosignatures:

    Plotter

"""

import os
from typing import Callable, Optional, Tuple, List, Union
from typing_extensions import Literal
import matplotlib
import matplotlib.pyplot as plt
import tempfile
from PIL import Image

import compas
from .artists import PlotterArtist


class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]


class Plotter:
    """Plotter for the visualization of COMPAS geometry.

    Parameters
    ----------
    view : tuple[tuple[float, float], tuple[float, float]], optional
        The area of the axes that should be zoomed into view.
    figsize : tuple[float, float], optional
        Size of the figure in inches.
    dpi : float, optional
        Resolution of the figure in "dots per inch".
    bgcolor : tuple[float, float, float], optional
        Background color for the figure canvas.
    show_axes : bool, optional
        If True, show the axes of the figure.
    zstack : {'natural', 'zorder'}, optional
        If ``'natural'``, the drawing elements appear in the order they were added.
        If ``'natural'``, the drawing elements are added based on their `zorder`.

    Attributes
    ----------
    viewbox : tuple[tuple[float, float], tuple[float, float]]
        X min-max and Y min-max of the area of the axes that is zoomed into view.
    axes : matplotlib.axes.Axes, read-only
        `matplotlib` axes object used by the figure.
        For more info, see the documentation of the Axes class ([1]_) and the axis and tick API ([2]_).
    figure : matplotlib.figure.Figure, read-only
        `matplotlib` figure instance.
        For more info, see the figure API ([3]_).
    bgcolor : tuple[float, float, float]
        Background color of the figure.
    title : str
        Title of the plot.
    artists : list[:class:`compas_plotters.artists.PlotterArtist`]
        Artists that should be included in the plot.

    Class Attributes
    ----------------
    fontsize : int
        Default fontsize used by the plotter.

    References
    ----------
    .. [1] https://matplotlib.org/api/axes_api.html
    .. [2] https://matplotlib.org/api/axis_api.html
    .. [3] https://matplotlib.org/api/figure_api.html

    Examples
    --------
    >>> from compas.geometry import Point, Plane, Circle
    >>> from compas_plotters.plotter import Plotter

    Create a plotter instance.

    >>> plotter = Plotter()

    Add COMPAS objects.

    >>> plotter.add(Point(0, 0, 0))                                                 # doctest: +SKIP
    <compas_plotters.artists.pointartist.PointArtist object at 0x17880eb80>         # doctest: +SKIP
    >>> plotter.add(Circle(Plane.worldXY(), 1.0))                                   # doctest: +SKIP
    <compas_plotters.artists.circleartist.CircleArtist object at 0x10d136e80>       # doctest: +SKIP

    """

    fontsize = 12

    def __init__(self,
                 view: Tuple[Tuple[float, float], Tuple[float, float]] = ((-8.0, 16.0), (-5.0, 10.0)),
                 figsize: Tuple[float, float] = (8.0, 5.0),
                 dpi: float = 100,
                 bgcolor: Tuple[float, float, float] = (1.0, 1.0, 1.0),
                 show_axes: bool = False,
                 zstack: Literal['natural', 'zorder'] = 'zorder'):
        self._show_axes = show_axes
        self._bgcolor = None
        self._viewbox = None
        self._axes = None
        self._artists = []
        self.viewbox = view
        self.figsize = figsize
        self.dpi = dpi
        self.bgcolor = bgcolor
        self.zstack = zstack

    @property
    def viewbox(self) -> Tuple[Tuple[float, float], Tuple[float, float]]:
        return self._viewbox

    @viewbox.setter
    def viewbox(self, view: Tuple[Tuple[float, float], Tuple[float, float]]):
        xlim, ylim = view
        xmin, xmax = xlim
        ymin, ymax = ylim
        self._viewbox = (xmin, xmax), (ymin, ymax)

    @property
    def axes(self) -> matplotlib.axes.Axes:
        if not self._axes:
            figure = plt.figure(facecolor=self.bgcolor,
                                figsize=self.figsize,
                                dpi=self.dpi)
            axes = figure.add_subplot(111, aspect='equal')
            if self.viewbox:
                xmin, xmax = self.viewbox[0]
                ymin, ymax = self.viewbox[1]
                axes.set_xlim(xmin, xmax)
                axes.set_ylim(ymin, ymax)
            axes.set_xscale('linear')
            axes.set_yscale('linear')
            if self._show_axes:
                axes.set_frame_on(True)
                axes.grid(False)
                axes.set_xticks([])
                axes.set_yticks([])
                axes.spines['top'].set_color('none')
                axes.spines['right'].set_color('none')
                axes.spines['left'].set_position('zero')
                axes.spines['bottom'].set_position('zero')
                axes.spines['left'].set_linestyle('-')
                axes.spines['bottom'].set_linestyle('-')
            else:
                axes.grid(False)
                axes.set_frame_on(False)
                axes.set_xticks([])
                axes.set_yticks([])
            axes.autoscale_view()
            plt.tight_layout()
            self._axes = axes
        return self._axes

    @property
    def figure(self) -> matplotlib.figure.Figure:
        return self.axes.get_figure()

    @property
    def bgcolor(self) -> str:
        return self._bgcolor

    @bgcolor.setter
    def bgcolor(self, value: Union[str, Tuple[float, float, float]]):
        self._bgcolor = value
        self.figure.set_facecolor(value)

    @property
    def title(self) -> str:
        return self.figure.canvas.get_window_title()

    @title.setter
    def title(self, value: str):
        self.figure.canvas.set_window_title(value)

    @property
    def artists(self) -> List[PlotterArtist]:
        return self._artists

    @artists.setter
    def artists(self, artists: List[PlotterArtist]):
        self._artists = artists

    # =========================================================================
    # Methods
    # =========================================================================

    def pause(self, pause: float) -> None:
        """Pause plotting during the specified interval.

        Parameters
        ----------
        pause: float
            The duration of the pause in seconds.
        """
        if pause:
            plt.pause(pause)

    def zoom_extents(self, padding: Optional[int] = None) -> None:
        """Zoom the view to the bounding box of all objects."""
        padding = padding or 0
        width, height = self.figsize
        fig_aspect = width / height
        data = []
        for artist in self.artists:
            data += artist.data
        x, y = zip(* data)
        xmin = min(x)
        xmax = max(x)
        ymin = min(y)
        ymax = max(y)
        xspan = xmax - xmin + padding
        yspan = ymax - ymin + padding
        data_aspect = xspan / yspan
        xlim = [xmin - 0.1 * xspan, xmax + 0.1 * xspan]
        ylim = [ymin - 0.1 * yspan, ymax + 0.1 * yspan]
        if data_aspect < fig_aspect:
            scale = fig_aspect / data_aspect
            xlim[0] *= scale
            xlim[1] *= scale
        else:
            scale = data_aspect / fig_aspect
            ylim[0] *= scale
            ylim[1] *= scale
        self.viewbox = (xlim, ylim)
        self.axes.set_xlim(*xlim)
        self.axes.set_ylim(*ylim)
        self.axes.autoscale_view()

    def add(self,
            item: Union[compas.geometry.Primitive,
                        compas.datastructures.Network,
                        compas.datastructures.Mesh],
            artist: Optional[PlotterArtist] = None,
            **kwargs) -> PlotterArtist:
        """Add a COMPAS geometry object or data structure to the plot.

        Parameters
        ----------
        item
            A COMPAS geometric primitive, network, or mesh.
        artist
            Type of artist to use for drawing.

        Returns
        -------
        :class:`PlotterArtist`

        """
        if not artist:
            if self.zstack == 'natural':
                zorder = 1000 + len(self._artists) * 100
                artist = PlotterArtist(item, plotter=self, zorder=zorder, context='Plotter', **kwargs)
            else:
                artist = PlotterArtist(item, plotter=self, context='Plotter', **kwargs)
        artist.draw()
        self._artists.append(artist)
        return artist

    # def add_as(self,
    #            item: Union[compas.geometry.Primitive,
    #                        compas.datastructures.Network,
    #                        compas.datastructures.Mesh],
    #            artist_type: PlotterArtist,
    #            **kwargs) -> PlotterArtist:
    #     """Add a COMPAS geometry object or data structure using a specific artist type."""
    #     artist = PlotterArtist(item, artist_type=artist_type, context='Plotter', **kwargs)
    #     artist.draw()
    #     self._artists.append(artist)
    #     return artist

    def add_from_list(self, items, **kwargs) -> List[PlotterArtist]:
        """Add multiple COMPAS geometry objects and/or data structures from a list."""
        artists = []
        for item in items:
            artist = self.add(item, plotter=self, **kwargs)
            artists.append(artist)
        return artists

    def find(self,
             item: Union[compas.geometry.Primitive,
                         compas.datastructures.Network,
                         compas.datastructures.Mesh]) -> PlotterArtist:
        """Find a geometry object or data structure in the plot."""
        for artist in self._artists:
            if item is artist.item:
                return artist

    def register_listener(self, listener: Callable) -> None:
        """Register a listener for pick events.

        Parameters
        ----------
        listener : callable
            The handler for pick events.

        Returns
        -------
        None

        Notes
        -----
        For more information, see the docs of ``mpl_connect`` ([1]_), and on event
        handling and picking ([2]_).

        References
        ----------
        .. [1] https://matplotlib.org/api/backend_bases_api.html#matplotlib.backend_bases.FigureCanvasBase.mpl_connect
        .. [2] https://matplotlib.org/users/event_handling.html

        """
        self.figure.canvas.mpl_connect('pick_event', listener)

    def draw(self, pause: Optional[float] = None) -> None:
        """Draw all objects included in the plot."""
        self.figure.canvas.draw()
        self.figure.canvas.flush_events()
        if pause:
            plt.pause(pause)

    def redraw(self, pause: Optional[float] = None) -> None:
        """Updates and pauses the plot.

        Parameters
        ----------
        pause : float
            Amount of time to pause the plot in seconds.

        """
        for artist in self._artists:
            artist.redraw()
        self.figure.canvas.draw()
        self.figure.canvas.flush_events()
        if pause:
            plt.pause(pause)

    def show(self) -> None:
        """Displays the plot.
        """
        self.draw()
        plt.show()

    def save(self, filepath: str, **kwargs) -> None:
        """Saves the plot to a file.

        Parameters
        ----------
        filepath : str
            Full path of the file.

        Notes
        -----
        For an overview of all configuration options, see [1]_.

        References
        ----------
        .. [1] https://matplotlib.org/2.0.2/api/pyplot_api.html#matplotlib.pyplot.savefig

        """
        plt.savefig(filepath, **kwargs)

    def on(self,
           interval: int = None,
           frames: int = None,
           record: bool = False,
           recording: str = None,
           dpi: int = 150) -> Callable:
        """Method for decorating callback functions in dynamic plots."""
        if record:
            if not recording:
                raise Exception('Please provide a path for the recording.')

        def outer(func: Callable):
            if record:
                with tempfile.TemporaryDirectory() as dirpath:
                    paths = []
                    for f in range(frames):
                        func(f)
                        self.redraw(pause=interval)
                        if record:
                            filepath = os.path.join(dirpath, f'frame-{f}.png')
                            paths.append(filepath)
                            self.save(filepath, dpi=dpi)
                    images = []
                    for path in paths:
                        images.append(Image.open(path))
                    images[0].save(recording, save_all=True, append_images=images[1:], optimize=False, duration=interval * 1000, loop=0)
            else:
                for f in range(frames):
                    func(f)
                    self.redraw(pause=interval)

        return outer
