# /*##########################################################################
#
# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides the base scene structure.

This module provides the classes for describing a tree structure with
rendering and picking API.
All nodes inherit from :class:`Base`.
Nodes with children are provided with :class:`PrivateGroup` and
:class:`Group` classes.
Leaf rendering nodes should inherit from :class:`Elem`.
"""

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "25/07/2016"


import itertools
import weakref

import numpy

from . import event
from . import transform

from .viewport import Viewport


# Nodes #######################################################################


class Base(event.Notifier):
    """A scene node with common features."""

    def __init__(self):
        super(Base, self).__init__()
        self._visible = True
        self._pickable = False

        self._parentRef = None

        self._transforms = transform.TransformList()
        self._transforms.addListener(self._transformChanged)

    # notifying properties

    visible = event.notifyProperty("_visible", doc="Visibility flag of the node")
    pickable = event.notifyProperty("_pickable", doc="True to make node pickable")

    # Access to tree path

    @property
    def parent(self):
        """Parent or None if no parent"""
        return None if self._parentRef is None else self._parentRef()

    def _setParent(self, parent):
        """Set the parent of this node.

        For internal use.

        :param Base parent: The parent.
        """
        if parent is not None and self._parentRef is not None:
            raise RuntimeError("Trying to add a node at two places.")
            # Alternative: remove it from previous children list
        self._parentRef = None if parent is None else weakref.ref(parent)

    @property
    def path(self):
        """Tuple of scene nodes, from the tip of the tree down to this node.

        If this tree is attached to a :class:`Viewport`,
        then the :class:`Viewport` is the first element of path.
        """
        if self.parent is None:
            return (self,)
        elif isinstance(self.parent, Viewport):
            return self.parent, self
        else:
            return self.parent.path + (self,)

    @property
    def viewport(self):
        """The viewport this node is attached to or None."""
        root = self.path[0]
        return root if isinstance(root, Viewport) else None

    @property
    def root(self):
        """The root node of the scene.

        If attached to a :class:`Viewport`, this is the item right under it
        """
        path = self.path
        return path[1] if isinstance(path[0], Viewport) else path[0]

    @property
    def objectToNDCTransform(self):
        """Transform from object to normalized device coordinates.

        Do not forget perspective divide.
        """
        # Using the Viewport's transforms property to proxy the camera
        path = self.path
        assert isinstance(path[0], Viewport)
        return transform.StaticTransformList(elem.transforms for elem in path)

    @property
    def objectToSceneTransform(self):
        """Transform from object to scene.

        Combine transforms up to the Viewport (not including it).
        """
        path = self.path
        if isinstance(path[0], Viewport):
            path = path[1:]  # Remove viewport to remove camera transforms
        return transform.StaticTransformList(elem.transforms for elem in path)

    # transform

    @property
    def transforms(self):
        """List of transforms defining the frame of this node relative
        to its parent."""
        return self._transforms

    @transforms.setter
    def transforms(self, iterable):
        self._transforms.removeListener(self._transformChanged)
        if isinstance(iterable, transform.TransformList):
            # If it is a TransformList, do not create one to enable sharing.
            self._transforms = iterable
        else:
            assert hasattr(iterable, "__iter__")
            self._transforms = transform.TransformList(iterable)
        self._transforms.addListener(self._transformChanged)

    def _transformChanged(self, source):
        self.notify()  # Broadcast transform notification

    # Bounds

    _CUBE_CORNERS = numpy.array(
        list(itertools.product((0.0, 1.0), repeat=3)), dtype=numpy.float32
    )
    """Unit cube corners used to transform bounds"""

    def _bounds(self, dataBounds=False):
        """Override in subclass to provide bounds in object coordinates"""
        return None

    def bounds(self, transformed=False, dataBounds=False):
        """Returns the bounds of this node aligned with the axis,
        with or without transform applied.

        :param bool transformed: False to give bounds in object coordinates
                                 (the default), True to apply this object's
                                 transforms.
        :param bool dataBounds: False to give bounds of vertices (the default),
                                True to give bounds of the represented data.
        :return: The bounds: ((xMin, yMin, zMin), (xMax, yMax, zMax)) or None
                 if no bounds.
        :rtype: numpy.ndarray of float
        """
        bounds = self._bounds(dataBounds)

        if transformed and bounds is not None:
            bounds = self.transforms.transformBounds(bounds)

        return bounds

    # Rendering

    def prepareGL2(self, ctx):
        """Called before the rendering to prepare OpenGL resources.

        Override in subclass.
        """
        pass

    def renderGL2(self, ctx):
        """Called to perform the OpenGL rendering.

        Override in subclass.
        """
        pass

    def render(self, ctx):
        """Called internally to perform rendering."""
        if self.visible:
            ctx.pushTransform(self.transforms)
            self.prepareGL2(ctx)
            self.renderGL2(ctx)
            ctx.popTransform()

    def postRender(self, ctx):
        """Hook called when parent's node render is finished.

        Called in the reverse of rendering order (i.e., last child first).

        Meant for nodes that modify the :class:`RenderContext` ctx to
        reset their modifications.
        """
        pass

    def pick(self, ctx, x, y, depth=None):
        """True/False picking, should be fast"""
        if self.pickable:
            pass

    def pickRay(self, ctx, ray):
        """Picking returning list of ray intersections."""
        if self.pickable:
            pass


class Elem(Base):
    """A scene node that does some rendering."""

    def __init__(self):
        super(Elem, self).__init__()
        # self.showBBox = False  # Here or outside scene graph?
        # self.clipPlane = None  # This needs to be handled in the shader


class PrivateGroup(Base):
    """A scene node that renders its (private) childern.

    :param iterable children: :class:`Base` nodes to add as children
    """

    class ChildrenList(event.NotifierList):
        """List of children with notification and children's parent update."""

        def _listWillChangeHook(self, methodName, *args, **kwargs):
            super(PrivateGroup.ChildrenList, self)._listWillChangeHook(
                methodName, *args, **kwargs
            )
            for item in self:
                item._setParent(None)

        def _listWasChangedHook(self, methodName, *args, **kwargs):
            for item in self:
                item._setParent(self._parentRef())
            super(PrivateGroup.ChildrenList, self)._listWasChangedHook(
                methodName, *args, **kwargs
            )

        def __init__(self, parent, children):
            self._parentRef = weakref.ref(parent)
            super(PrivateGroup.ChildrenList, self).__init__(children)

    def __init__(self, children=()):
        super(PrivateGroup, self).__init__()
        self.__children = PrivateGroup.ChildrenList(self, children)
        self.__children.addListener(self._updated)

    @property
    def _children(self):
        """List of children to be rendered.

        This private attribute is meant to be used by subclass.
        """
        return self.__children

    @_children.setter
    def _children(self, iterable):
        self.__children.removeListener(self._updated)
        for item in self.__children:
            item._setParent(None)
        del self.__children  # This is needed
        self.__children = PrivateGroup.ChildrenList(self, iterable)
        self.__children.addListener(self._updated)
        self.notify()

    def _updated(self, source, *args, **kwargs):
        """Listen for updates"""
        if source is not self:  # Avoid infinite recursion
            self.notify(*args, **kwargs)

    def _bounds(self, dataBounds=False):
        """Compute the bounds from transformed children bounds"""
        bounds = []
        for child in self._children:
            if child.visible:
                childBounds = child.bounds(transformed=True, dataBounds=dataBounds)
                if childBounds is not None:
                    bounds.append(childBounds)

        if len(bounds) == 0:
            return None
        else:
            bounds = numpy.array(bounds, dtype=numpy.float32)
            return numpy.array(
                (bounds[:, 0, :].min(axis=0), bounds[:, 1, :].max(axis=0)),
                dtype=numpy.float32,
            )

    def prepareGL2(self, ctx):
        pass

    def renderGL2(self, ctx):
        """Render all children"""
        for child in self._children:
            child.render(ctx)
        for child in reversed(self._children):
            child.postRender(ctx)


class Group(PrivateGroup):
    """A scene node that renders its (public) children."""

    @property
    def children(self):
        """List of children to be rendered."""
        return self._children

    @children.setter
    def children(self, iterable):
        self._children = iterable
