#!/usr/bin/env python3
""" Custom Initializers for faceswap.py """

import logging
import sys
import inspect

import numpy as np
import tensorflow as tf

from lib.utils import get_backend

if get_backend() == "amd":
    from keras.utils import get_custom_objects  # pylint:disable=no-name-in-module
    from keras import backend as K
    from keras import initializers
else:
    # Ignore linting errors from Tensorflow's thoroughly broken import system
    from tensorflow.keras.utils import get_custom_objects  # noqa pylint:disable=no-name-in-module,import-error
    from tensorflow.keras import initializers, backend as K  # noqa pylint:disable=no-name-in-module,import-error

logger = logging.getLogger(__name__)  # pylint: disable=invalid-name


def compute_fans(shape, data_format='channels_last'):
    """Computes the number of input and output units for a weight shape.

    Ported directly from Keras as the location moves between keras and tensorflow-keras

    Parameters
    ----------
    shape: tuple
        shape tuple of integers
    data_format: str
        Image data format to use for convolution kernels. Note that all kernels in Keras are
        standardized on the `"channels_last"` ordering (even when inputs are set to
        `"channels_first"`).

    Returns
    -------
    tuple
            A tuple of scalars, `(fan_in, fan_out)`.

    Raises
    ------
    ValueError
        In case of invalid `data_format` argument.
    """
    if len(shape) == 2:
        fan_in = shape[0]
        fan_out = shape[1]
    elif len(shape) in {3, 4, 5}:
        # Assuming convolution kernels (1D, 2D or 3D).
        # Theano kernel shape: (depth, input_depth, ...)
        # Tensorflow kernel shape: (..., input_depth, depth)
        if data_format == 'channels_first':
            receptive_field_size = np.prod(shape[2:])
            fan_in = shape[1] * receptive_field_size
            fan_out = shape[0] * receptive_field_size
        elif data_format == 'channels_last':
            receptive_field_size = np.prod(shape[:-2])
            fan_in = shape[-2] * receptive_field_size
            fan_out = shape[-1] * receptive_field_size
        else:
            raise ValueError('Invalid data_format: ' + data_format)
    else:
        # No specific assumptions.
        fan_in = np.sqrt(np.prod(shape))
        fan_out = np.sqrt(np.prod(shape))
    return fan_in, fan_out


class ICNR(initializers.Initializer):  # pylint: disable=invalid-name,no-member
    """ ICNR initializer for checkerboard artifact free sub pixel convolution

    Parameters
    ----------
    initializer: :class:`keras.initializers.Initializer`
        The initializer used for sub kernels (orthogonal, glorot uniform, etc.)
    scale: int, optional
        scaling factor of sub pixel convolution (up sampling from 8x8 to 16x16 is scale 2).
        Default: `2`

    Returns
    -------
    tensor
        The modified kernel weights

    Example
    -------
    >>> x = conv2d(... weights_initializer=ICNR(initializer=he_uniform(), scale=2))

    References
    ----------
    Andrew Aitken et al. Checkerboard artifact free sub-pixel convolution
    https://arxiv.org/pdf/1707.02937.pdf,  https://distill.pub/2016/deconv-checkerboard/
    """

    def __init__(self, initializer, scale=2):
        self.scale = scale
        self.initializer = initializer

    def __call__(self, shape, dtype="float32"):
        """ Call function for the ICNR initializer.

        Parameters
        ----------
        shape: tuple or list
            The required resized shape for the output tensor
        dtype: str
            The data type for the tensor

        Returns
        -------
        tensor
            The modified kernel weights
        """
        shape = list(shape)
        if self.scale == 1:
            return self.initializer(shape)
        new_shape = shape[:3] + [shape[3] // (self.scale ** 2)]
        if isinstance(self.initializer, dict):
            self.initializer = initializers.deserialize(self.initializer)
        var_x = self.initializer(new_shape, dtype)
        var_x = K.permute_dimensions(var_x, [2, 0, 1, 3])
        var_x = K.resize_images(var_x,
                                self.scale,
                                self.scale,
                                "channels_last",
                                interpolation="nearest")
        var_x = self._space_to_depth(var_x)
        var_x = K.permute_dimensions(var_x, [1, 2, 0, 3])
        logger.debug("Output shape: %s", var_x.shape)
        return var_x

    def _space_to_depth(self, input_tensor):
        """ Space to depth implementation.

        PlaidML does not have a space to depth operation, so calculate if backend is amd
        otherwise returns the :func:`tensorflow.space_to_depth` operation.

        Parameters
        ----------
        input_tensor: tensor
            The tensor to be manipulated

        Returns
        -------
        tensor
            The manipulated input tensor
        """
        if get_backend() == "amd":
            batch, height, width, depth = input_tensor.shape.dims
            new_height = height // self.scale
            new_width = width // self.scale
            reshaped = K.reshape(input_tensor,
                                 (batch, new_height, self.scale, new_width, self.scale, depth))
            retval = K.reshape(K.permute_dimensions(reshaped, [0, 1, 3, 2, 4, 5]),
                               (batch, new_height, new_width, -1))
        else:
            retval = tf.nn.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC")
        logger.debug("Input shape: %s, Output shape: %s", input_tensor.shape, retval.shape)
        return retval

    def get_config(self):
        """ Return the ICNR Initializer configuration.

        Returns
        -------
        dict
            The configuration for ICNR Initialization
        """
        config = {"scale": self.scale,
                  "initializer": self.initializer
                  }
        base_config = super().get_config()
        return dict(list(base_config.items()) + list(config.items()))


class ConvolutionAware(initializers.Initializer):  # pylint: disable=no-member
    """
    Initializer that generates orthogonal convolution filters in the Fourier space. If this
    initializer is passed a shape that is not 3D or 4D, orthogonal initialization will be used.

    Adapted, fixed and optimized from:
    https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/initializers/convaware.py

    Parameters
    ----------
    eps_std: float, optional
        The Standard deviation for the random normal noise used to break symmetry in the inverse
        Fourier transform. Default: 0.05
    seed: int, optional
        Used to seed the random generator. Default: ``None``
    initialized: bool, optional
        This should always be set to ``False``. To avoid Keras re-calculating the values every time
        the model is loaded, this parameter is internally set on first time initialization.
        Default:``False``

    Returns
    -------
    tensor
        The modified kernel weights

    References
    ----------
    Armen Aghajanyan, https://arxiv.org/abs/1702.06295
    """

    def __init__(self, eps_std=0.05, seed=None, initialized=False):
        self.eps_std = eps_std
        self.seed = seed
        self.orthogonal = initializers.Orthogonal()  # pylint:disable=no-member
        self.he_uniform = initializers.he_uniform()  # pylint:disable=no-member
        self.initialized = initialized

    def __call__(self, shape, dtype=None):
        """ Call function for the ICNR initializer.

        Parameters
        ----------
        shape: tuple or list
            The required shape for the output tensor
        dtype: str
            The data type for the tensor

        Returns
        -------
        tensor
            The modified kernel weights
        """
        # TODO Tensorflow appears to pass in a :class:`tensorflow.python.framework.dtypes.DType`
        # object which causes this to error, so currently just reverts to default dtype if a string
        # is not passed in.
        if self.initialized:   # Avoid re-calculating initializer when loading a saved model
            return self.he_uniform(shape, dtype=dtype)
        dtype = K.floatx() if not isinstance(dtype, str) else dtype
        logger.info("Calculating Convolution Aware Initializer for shape: %s", shape)
        rank = len(shape)
        if self.seed is not None:
            np.random.seed(self.seed)

        fan_in, _ = compute_fans(shape)  # pylint:disable=protected-access
        variance = 2 / fan_in

        if rank == 3:
            row, stack_size, filters_size = shape

            transpose_dimensions = (2, 1, 0)
            kernel_shape = (row,)
            correct_ifft = lambda shape, s=[None]: np.fft.irfft(shape, s[0])  # noqa
            correct_fft = np.fft.rfft

        elif rank == 4:
            row, column, stack_size, filters_size = shape

            transpose_dimensions = (2, 3, 1, 0)
            kernel_shape = (row, column)
            correct_ifft = np.fft.irfft2
            correct_fft = np.fft.rfft2

        elif rank == 5:
            var_x, var_y, var_z, stack_size, filters_size = shape

            transpose_dimensions = (3, 4, 0, 1, 2)
            kernel_shape = (var_x, var_y, var_z)
            correct_fft = np.fft.rfftn
            correct_ifft = np.fft.irfftn

        else:
            self.initialized = True
            return K.variable(self.orthogonal(shape), dtype=dtype)

        kernel_fourier_shape = correct_fft(np.zeros(kernel_shape)).shape

        basis = self._create_basis(filters_size, stack_size, np.prod(kernel_fourier_shape), dtype)
        basis = basis.reshape((filters_size, stack_size,) + kernel_fourier_shape)
        randoms = np.random.normal(0, self.eps_std, basis.shape[:-2] + kernel_shape)
        init = correct_ifft(basis, kernel_shape) + randoms
        init = self._scale_filters(init, variance)
        self.initialized = True
        return K.variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware")

    def _create_basis(self, filters_size, filters, size, dtype):
        """ Create the basis for convolutional aware initialization """
        logger.debug("filters_size: %s, filters: %s, size: %s, dtype: %s",
                     filters_size, filters, size, dtype)
        if size == 1:
            return np.random.normal(0.0, self.eps_std, (filters_size, filters, size))
        nbb = filters // size + 1
        var_a = np.random.normal(0.0, 1.0, (filters_size, nbb, size, size))
        var_a = self._symmetrize(var_a)
        var_u = np.linalg.svd(var_a)[0].transpose(0, 1, 3, 2)
        var_p = np.reshape(var_u, (filters_size, nbb * size, size))[:, :filters, :].astype(dtype)
        return var_p

    @staticmethod
    def _symmetrize(var_a):
        """ Make the given tensor symmetrical. """
        var_b = np.transpose(var_a, axes=(0, 1, 3, 2))
        diag = var_a.diagonal(axis1=2, axis2=3)
        var_c = np.array([[np.diag(arr) for arr in batch] for batch in diag])
        return var_a + var_b - var_c

    @staticmethod
    def _scale_filters(filters, variance):
        """ Scale the given filters. """
        c_var = np.var(filters)
        var_p = np.sqrt(variance / c_var)
        return filters * var_p

    def get_config(self):
        """ Return the Convolutional Aware Initializer configuration.

        Returns
        -------
        dict
            The configuration for ICNR Initialization
        """
        return dict(eps_std=self.eps_std,
                    seed=self.seed,
                    initialized=self.initialized)


# Update initializers into Keras custom objects
for name, obj in inspect.getmembers(sys.modules[__name__]):
    if inspect.isclass(obj) and obj.__module__ == __name__:
        get_custom_objects().update({name: obj})
