"""
The nodes that modify inputs by using static parameters
and not 't' time moments, with CPU.
"""
from yta_programming.singleton import SingletonABCMeta
from yta_editor_utils.texture import TextureUtils
from abc import abstractmethod

import numpy as np


# TODO: Move this abstract classes and reorganize
# the modules because we have only one 'processor'
# class but the module is called like that...
class _NodeProcessorCPU(metaclass = SingletonABCMeta):
    """
    *Singleton class*

    *For internal use only*

    Class to represent a node processor that uses CPU
    to transform the input.

    This class must be implemented by any processor
    that uses CPU to modify an input.
    """
    
    # TODO: Just code and the same attributes that the
    # GPU version also has
    @abstractmethod
    def process(
        self,
        # TODO: What about the type (?)
        input: np.ndarray,
        **kwargs
    # TODO: What about the output type (?)
    ) -> np.ndarray:
        """
        Process the provided 'input' and transform it by
        using the code that is defined here.
        """
        # TODO: Specific attributes can be received as
        # **kwargs to modify the specific process
        pass

"""
TODO: I didn't implement any of these 2 types
but here they are as I have them implemented in
the GPU version.
"""
class _NodeComplexCPU:
    """
    A node that is made by applying different nodes. It
    is different than the other nodes because it needs
    to import different nodes to process the input(s).

    This is the equivalent to `_NodeComplexGPU` of 
    `yta-editor-nodes-gpu`, but in CPU.

    TODO: This is, by now, just a class to identify these
    new type of nodes.
    """

    @abstractmethod
    def process(
        self,
        input: np.ndarray,
        **kwargs
    ) -> np.ndarray:
        pass

class _NodeCompositorCPU(metaclass = SingletonABCMeta):
    """
    *Singleton class*

    *For internal use only*
    
    Class to represent a node compositor that uses CPU
    to position the input in a specific place.

    This is the equivalent to `_NodeCompositorGPU` of 
    `yta-editor-nodes-gpu`, but in CPU.

    Nodes can be chained and the result from one node
    can be applied on another node.

    This _NodeCompositor is to be able to make a composition
    with different inputs. For example, placing a specific
    input in a position, with a rotation, over another input
    that acts as the base.
    """

    # TODO: Just code and the same attributes that the
    # GPU version also has
    @abstractmethod
    def process(
        self,
        # TODO: What about the type (?)
        base_input: np.ndarray,
        overlay_input: np.ndarray,
        **kwargs
    # TODO: What about the output type (?)
    ) -> np.ndarray:
        """
        Place the provided `overlay_input` over the also
        given `base_input`. 
        """
        # TODO: Specific attributes can be received as
        # **kwargs to modify the specific process
        pass

# Specific implementations below
class SelectionMaskProcessorCPU(_NodeProcessorCPU):
    """
    Class to use a mask selection (from which we will
    determine if the pixel must be applied or not) to
    apply the `processed_input` on the `original_input`.
    """

    def process(
        self,
        # TODO: What about the type (?)
        original_input: np.ndarray,
        processed_input: np.ndarray,
        selection_mask_input: np.ndarray
    ):
        """
        Apply the `selection_mask` provided to the also
        given `original` and `processed` nuumpy arrays to
        obtain the processed one but affected only as the
        selection mask says.

        The input are processed as float32, with float 
        precission, to be able to calculate properly,
        and then returned to uint8 [0, 255] values (the
        ones our OpenGL is able to handle with the 'f1'
        dtype and the sampler2d uniforms).
        """
        # We force to have float precission for the calculations
        original_input = TextureUtils.numpy_to_float32(original_input)
        processed_input = TextureUtils.numpy_to_float32(processed_input)
        selection_mask_input = TextureUtils.numpy_to_float32(selection_mask_input)

        # We need a 3D or 4D mask
        selection_mask_input = (
            np.expand_dims(selection_mask_input, axis = -1)
            if selection_mask_input.ndim == 2 else
            selection_mask_input
        )

        selection_mask_input = (
            np.repeat(
                a = selection_mask_input,
                repeats = original_input.shape[-1],
                axis = -1
            )
            if (
                selection_mask_input.shape[-1] == 1 and
                original_input.shape[-1] in (3, 4)
            ) else
            selection_mask_input
        )

        # Mix with the selection mask
        final = original_input * (1.0 - selection_mask_input) + processed_input * selection_mask_input

        return TextureUtils.numpy_to_uint8(final)
    
# TODO: Just for testing, at least by now
class ColorContrastNodeProcessorCPU(_NodeProcessorCPU):
    """
    Node processor to test the color contrast
    change.

    TODO: Improve this, its just temporary
    """

    def __init__(
        self,
        factor: float = 1
    ):
        self.factor: float = factor
        """
        The factor to apply to change the color contrast.
        """

    def process(
        self,
        input: np.ndarray,
        # TODO: Maybe add 'factor' also here that can be None
        # to use the original of the instance
        **kwargs
    ):
        from yta_editor_utils.texture import TextureUtils

        input = TextureUtils.numpy_to_float32(input)

        # We do this to be the same as in GPU but it
        # should be calculated... and also for the GPU
        mean = np.array([0.5, 0.5, 0.5, 0.5], dtype = np.float32)

        # mean = input.mean(
        #     axis = (0, 1),
        #     keepdims = True
        # )
        """
        factor > 1 -> More contrast
        factor < 1 -> Less contrast
        """
        out = (input - mean) * self.factor + mean
        out = np.clip(out, 0, 1)

        return out
    
class BlackAndWhiteNodeProcessorCPU(_NodeProcessorCPU):
    """
    Node processor to test the color contrast
    change.

    TODO: Improve this, its just temporary
    """

    def process(
        self,
        input: np.ndarray,
        **kwargs
    ):
        from yta_editor_utils.texture import TextureUtils

        input = TextureUtils.numpy_to_float32(input)

        # Standard luminance conversion Rec.709
        gray = 0.2126 * input[..., 0] + 0.7152 * input[..., 1] + 0.0722 * input[..., 2]
        gray = np.clip(gray, 0, 1)

        return np.dstack(
            [gray, gray, gray, input[..., 3]]
            if input.shape[-1] == 4 else
            [gray, gray, gray]
        )
    
"""
TODO: Using Pillow is very slow, so we will not
implement this by now. But here is some code that
should work once it's modified
"""
    
# TODO: This one is a bit special because you need
# more than 1 texture, and it is also the one we
# use to move the frames, so move it from here
# class DisplacementWithRotationNodeCompositorCPU(_NodeCompositorCPU):
#     """
#     The frame, but moving and rotating over other frame.
#     """

#     def process(
#         self,
#         # TODO: What about the type (?)
#         base_input: np.ndarray,
#         overlay_input: np.ndarray,
#         position: tuple[int, int],
#         size: tuple[int, int],
#         rotation: int,
#         **kwargs
#     ):
#         from yta_numpy.utils import numpy_to_pillow

#         # TODO: Maybe 'cv2' is faster (?)
#         base_im = numpy_to_pillow(
#             frame = base_input,
#             do_read_as_rgba = True
#         )
#         ovr_im = numpy_to_pillow(
#             frame = overlay_input,
#             do_read_as_rgba = True
#         )

#         base_w, base_h = base_im.size

#         pos_x, pos_y = position
#         tgt_w = int(round(size[0]))
#         tgt_h = int(round(size[1]))

#         # Avoid 0 size
#         tgt_w = max(1, tgt_w)
#         tgt_h = max(1, tgt_h)

#         from PIL import Image

#         # Resize overlay to expected size
#         overlay_resized = ovr_im.resize((tgt_w, tgt_h), resample = Image.BICUBIC)

#         # --- Rotar alrededor del centro (expand=False mantiene bbox tamaño tgt_w x tgt_h) ---
#         # Pillow rota CCW; expand=False mantiene el canvas, recortando los bordes que sobresalgan.
#         overlay_rot = overlay_resized.rotate(rotation_degrees, resample=resample, expand=False)

#         # --- Crear capa vacía del tamaño de la base y pegar overlay en su posición centrada ---
#         overlay_layer = Image.new('RGBA', (base_w, base_h), (0, 0, 0, 0))

#         top_left_x = int(round(pos_x - tgt_w / 2.0))
#         top_left_y = int(round(pos_y - tgt_h / 2.0))

#         # Pegar usando la propia alpha de overlay_rot como máscara; paste maneja recortes fuera de bordes correctamente.
#         overlay_layer.paste(overlay_rot, (top_left_x, top_left_y), overlay_rot)

#         # --- Componer: base sobre la que overlay_layer se coloca ---
#         # Usamos alpha_composite pero requiere imágenes del mismo tamaño (ya lo son)
#         result = Image.alpha_composite(base_im, overlay_layer)

#         return np.array(result, dtype=np.uint8)