# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2014-2020 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 a class wrapping OpenGL 2D and 3D texture."""

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "04/10/2016"


try:
    from collections import abc
except ImportError:  # Python2 support
    import collections as abc

from ctypes import c_void_p
import logging

import numpy

from . import gl, utils


_logger = logging.getLogger(__name__)


class Texture(object):
    """Base class to wrap OpenGL 2D and 3D texture

    :param internalFormat: OpenGL texture internal format
    :param data: The data to copy to the texture or None for an empty texture
    :type data: numpy.ndarray or None
    :param format_: Input data format if different from internalFormat
    :param shape: If data is None, shape of the texture
                  (height, width) or (depth, height, width)
    :type shape: List[int]
    :param int texUnit: The texture unit to use
    :param minFilter: OpenGL texture minimization filter (default: GL_NEAREST)
    :param magFilter: OpenGL texture magnification filter (default: GL_LINEAR)
    :param wrap: Texture wrap mode for dimensions: (t, s) or (r, t, s)
                 If a single value is provided, it used for all dimensions.
    :type wrap: OpenGL wrap mode or 2 or 3-tuple of wrap mode
    """

    def __init__(self, internalFormat, data=None, format_=None,
                 shape=None, texUnit=0,
                 minFilter=None, magFilter=None, wrap=None):

        self._internalFormat = internalFormat
        if format_ is None:
            format_ = self.internalFormat

        if data is None:
            assert shape is not None
        else:
            assert shape is None
            data = numpy.array(data, copy=False, order='C')
            if format_ != gl.GL_RED:
                shape = data.shape[:-1]  # Last dimension is channels
            else:
                shape = data.shape

        self._deferredUpdates = [(format_, data, None)]

        assert len(shape) in (2, 3)
        self._shape = tuple(shape)
        self._ndim = len(shape)

        self.texUnit = texUnit

        self._texParameterUpdates = {}  # Store texture params to update

        self._minFilter = minFilter if minFilter is not None else gl.GL_NEAREST
        self._texParameterUpdates[gl.GL_TEXTURE_MIN_FILTER] = self._minFilter

        self._magFilter = magFilter if magFilter is not None else gl.GL_LINEAR
        self._texParameterUpdates[gl.GL_TEXTURE_MAG_FILTER] = self._magFilter

        self._name = None  # Store texture ID

        if wrap is not None:
            if not isinstance(wrap, abc.Iterable):
                wrap = [wrap] * self.ndim

            assert len(wrap) == self.ndim

            self._texParameterUpdates[gl.GL_TEXTURE_WRAP_S] = wrap[-1]
            self._texParameterUpdates[gl.GL_TEXTURE_WRAP_T] = wrap[-2]
            if self.ndim == 3:
                self._texParameterUpdates[gl.GL_TEXTURE_WRAP_R] = wrap[0]

    @property
    def target(self):
        """OpenGL target type of this texture"""
        return gl.GL_TEXTURE_2D if self.ndim == 2 else gl.GL_TEXTURE_3D

    @property
    def ndim(self):
        """The number of dimensions: 2 or 3"""
        return self._ndim

    @property
    def internalFormat(self):
        """Texture internal format"""
        return self._internalFormat

    @property
    def shape(self):
        """Shape of the texture: (height, width) or (depth, height, width)"""
        return self._shape

    @property
    def name(self):
        """OpenGL texture name.

        It is None if not initialized or already discarded.
        """
        return self._name

    @property
    def minFilter(self):
        """Minifying function parameter (GL_TEXTURE_MIN_FILTER)"""
        return self._minFilter

    @minFilter.setter
    def minFilter(self, minFilter):
        if minFilter != self.minFilter:
            self._minFilter = minFilter
            self._texParameterUpdates[gl.GL_TEXTURE_MIN_FILTER] = minFilter

    @property
    def magFilter(self):
        """Magnification function parameter (GL_TEXTURE_MAG_FILTER)"""
        return self._magFilter

    @magFilter.setter
    def magFilter(self, magFilter):
        if magFilter != self.magFilter:
            self._magFilter = magFilter
            self._texParameterUpdates[gl.GL_TEXTURE_MAG_FILTER] = magFilter

    def _isPrepareRequired(self) -> bool:
        """Returns True if OpenGL texture needs to be updated.

        :rtype: bool
        """
        return (self._name is None or
                self._texParameterUpdates or
                self._deferredUpdates)

    def _prepareAndBind(self, texUnit=None):
        """Synchronizes the OpenGL texture"""
        if self._name is None:
            self._name = gl.glGenTextures(1)

        self._bind(texUnit)

        # Synchronizes texture parameters
        for pname, param in self._texParameterUpdates.items():
            gl.glTexParameter(self.target, pname, param)
        self._texParameterUpdates = {}

        # Copy data to texture
        for format_, data, offset in self._deferredUpdates:
            gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1)

            # This are the defaults, useless to set if not modified
            # gl.glPixelStorei(gl.GL_UNPACK_ROW_LENGTH, 0)
            # gl.glPixelStorei(gl.GL_UNPACK_SKIP_PIXELS, 0)
            # gl.glPixelStorei(gl.GL_UNPACK_SKIP_ROWS, 0)
            # gl.glPixelStorei(gl.GL_UNPACK_IMAGE_HEIGHT, 0)
            # gl.glPixelStorei(gl.GL_UNPACK_SKIP_IMAGES, 0)

            if data is None:
                data = c_void_p(0)
                type_ = gl.GL_UNSIGNED_BYTE
            else:
                type_ = utils.numpyToGLType(data.dtype)

            if offset is None:  # Initialize texture
                if self.ndim == 2:
                    _logger.debug(
                        'Creating 2D texture shape: (%d, %d),'
                        ' internal format: %s, format: %s, type: %s',
                        self.shape[0], self.shape[1],
                        str(self.internalFormat), str(format_), str(type_))

                    gl.glTexImage2D(
                        gl.GL_TEXTURE_2D,
                        0,
                        self.internalFormat,
                        self.shape[1],
                        self.shape[0],
                        0,
                        format_,
                        type_,
                        data)

                else:
                    _logger.debug(
                        'Creating 3D texture shape: (%d, %d, %d),'
                        ' internal format: %s, format: %s, type: %s',
                        self.shape[0], self.shape[1], self.shape[2],
                        str(self.internalFormat), str(format_), str(type_))

                    gl.glTexImage3D(
                        gl.GL_TEXTURE_3D,
                        0,
                        self.internalFormat,
                        self.shape[2],
                        self.shape[1],
                        self.shape[0],
                        0,
                        format_,
                        type_,
                        data)

            else:  # Update already existing texture
                if self.ndim == 2:
                    gl.glTexSubImage2D(gl.GL_TEXTURE_2D,
                                       0,
                                       offset[1],
                                       offset[0],
                                       data.shape[1],
                                       data.shape[0],
                                       format_,
                                       type_,
                                       data)

                else:
                    gl.glTexSubImage3D(gl.GL_TEXTURE_3D,
                                       0,
                                       offset[2],
                                       offset[1],
                                       offset[0],
                                       data.shape[2],
                                       data.shape[1],
                                       data.shape[0],
                                       format_,
                                       type_,
                                       data)

        self._deferredUpdates = []

    def _bind(self, texUnit=None):
        """Bind the texture to a texture unit.

        :param int texUnit: The texture unit to use
        """
        if texUnit is None:
            texUnit = self.texUnit
        gl.glActiveTexture(gl.GL_TEXTURE0 + texUnit)
        gl.glBindTexture(self.target, self.name)

    def _unbind(self, texUnit=None):
        """Reset texture binding to a texture unit.

        :param int texUnit: The texture unit to use
        """
        if texUnit is None:
            texUnit = self.texUnit
        gl.glActiveTexture(gl.GL_TEXTURE0 + texUnit)
        gl.glBindTexture(self.target, 0)

    def prepare(self):
        """Synchronizes the OpenGL texture.

        This method must be called with a current OpenGL context.
        """
        if self._isPrepareRequired():
            self._prepareAndBind()
            self._unbind()

    def bind(self, texUnit=None):
        """Bind the texture to a texture unit.

        The OpenGL texture is updated if needed.

        This method must be called with a current OpenGL context.

        :param int texUnit: The texture unit to use
        """
        if self._isPrepareRequired():
            self._prepareAndBind(texUnit)
        else:
            self._bind(texUnit)

    def discard(self):
        """Delete associated OpenGL texture.

        This method must be called with a current OpenGL context.
        """
        if self._name is not None:
            gl.glDeleteTextures(self._name)
            self._name = None
        else:
            _logger.warning("Texture not initialized or already discarded")

    # with statement

    def __enter__(self):
        self.bind()

    def __exit__(self, exc_type, exc_val, exc_tb):
        self._unbind()

    def update(self, format_, data, offset=(0, 0, 0), copy=True):
        """Update the content of the texture.

        Texture is not resized, so data must fit into texture with the
        given offset.

        This update is performed lazily during next call to
        :meth:`prepare` or :meth:`bind`.
        Data MUST not be changed until then.

        :param format_: The OpenGL format of the data
        :param data: The data to use to update the texture
        :param List[int] offset: Offset in the texture where to copy the data
        :param bool copy:
            True (default) to copy data, False to use as is (do not modify)
        """
        data = numpy.array(data, copy=copy, order='C')
        offset = tuple(offset)

        assert data.ndim == self.ndim
        assert len(offset) >= self.ndim
        for i in range(self.ndim):
            assert offset[i] + data.shape[i] <= self.shape[i]

        self._deferredUpdates.append((format_, data, offset))
