'''
    Copyright (C) 2021 - 2025 Akaneyu

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.
'''

import math
import bpy
import numpy as np
from . import app
from . import utils

class IMAGE_EDITOR_PLUS_OT_make_selection(bpy.types.Operator):
    """Make a selection on the image"""
    bl_idname = "image_editor_plus.make_selection"
    bl_label = "Make Selection"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.lmb = False

    def modal(self, context, event):
        area_session = app.get_area_session(context)

        context.area.tag_redraw()

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        if event.type == 'MOUSEMOVE':
            if self.lmb:
                region_pos = [event.mouse_region_x, event.mouse_region_y]

                if area_session.selection_region:
                    area_session.selection_region[1] = region_pos

        elif event.type == 'LEFTMOUSE':
            if event.value == 'PRESS':
                self.lmb = True

                region_pos = [event.mouse_region_x, event.mouse_region_y]
                area_session.selection_region = [region_pos, region_pos]

            elif event.value == 'RELEASE':
                self.lmb = False

                if area_session.selection_region:
                    area_session.selecting = False

                    app.convert_selection(context)

                    # deselect layer
                    img_props = img.imageeditorplus_properties
                    img_props.selected_layer_index = -1

                    return {'FINISHED'}

        elif event.type in {'RIGHTMOUSE', 'ESC'}:
            area_session.selection = None
            area_session.selection_region = None
            area_session.selecting = False

            return {'CANCELLED'}

        return {'RUNNING_MODAL'}

    def invoke(self, context, event):
        area_session = app.get_area_session(context)

        if context.area.type != 'IMAGE_EDITOR':
            return {'CANCELLED'}

        area_session.selection = None
        area_session.selection_region = None
        area_session.selecting = True

        context.window_manager.modal_handler_add(self)

        return {'RUNNING_MODAL'}

class IMAGE_EDITOR_PLUS_OT_cancel_selection(bpy.types.Operator):
    """Cancel the selection"""
    bl_idname = "image_editor_plus.cancel_selection"
    bl_label = "Cancel Selection"

    def execute(self, context):
        area_session = app.get_area_session(context)

        if not area_session.selection:
            return {'CANCELLED'}

        app.cancel_selection(context)

        context.area.tag_redraw()

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_swap_colors(bpy.types.Operator):
    """Swap foreground and background color"""
    bl_idname = "image_editor_plus.swap_colors"
    bl_label = "Swap Colors"

    def execute(self, context):
        wm = context.window_manager

        props = wm.imageeditorplus_properties

        props.foreground_color, props.background_color = \
                props.background_color[:], props.foreground_color[:]

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_fill_with_fg_color(bpy.types.Operator):
    """Fill the image with foreground color"""
    bl_idname = "image_editor_plus.fill_with_fg_color"
    bl_label = "Fill with FG Color"

    def execute(self, context):
        wm = context.window_manager

        props = wm.imageeditorplus_properties
        color = props.foreground_color[:] + (1.0,)

        img = app.get_target_image(context)
        if not img:
            return {'CANCELLED'}

        pixels = utils.read_pixels_from_image(img)

        selection = app.get_target_selection(context)
        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = color
        elif selection == []:
            return {'CANCELLED'}
        else:
            pixels[:] = color

        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_fill_with_bg_color(bpy.types.Operator):
    """Fill the image with background color"""
    bl_idname = "image_editor_plus.fill_with_bg_color"
    bl_label = "Fill with BG Color"

    def execute(self, context):
        wm = context.window_manager

        props = wm.imageeditorplus_properties
        color = props.background_color[:] + (1.0,)

        img = app.get_target_image(context)
        if not img:
            return {'CANCELLED'}

        pixels = utils.read_pixels_from_image(img)

        selection = app.get_target_selection(context)
        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = color
        elif selection == []:
            return {'CANCELLED'}
        else:
            pixels[:] = color

        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_clear(bpy.types.Operator):
    """Clear the image"""
    bl_idname = "image_editor_plus.clear"
    bl_label = "Clear"

    def execute(self, context):
        img = app.get_target_image(context)
        if not img:
            return {'CANCELLED'}

        pixels = utils.read_pixels_from_image(img)

        selection = app.get_target_selection(context)
        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = (0, 0, 0, 0)
        elif selection == []:
            return {'CANCELLED'}
        else:
            pixels[:] = (0, 0, 0, 0)

        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_cut(bpy.types.Operator):
    """Cut the image"""
    bl_idname = "image_editor_plus.cut"
    bl_label = "Cut"

    def execute(self, context):
        session = app.get_session()

        img = app.get_target_image(context)
        if not img:
            return {'CANCELLED'}

        width, height = img.size
        pixels = utils.read_pixels_from_image(img)

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        session.copied_image_pixels = target_pixels.copy()

        session.copied_image_settings = {
            'is_float': img.is_float,
            'colorspace_name': img.colorspace_settings.name
        }

        layer = app.get_active_layer(context)
        if layer:
            session.copied_layer_settings = {
                'rotation': layer.rotation,
                'scale': layer.scale,
                'custom_data': layer.custom_data
            }
        else:
            session.copied_layer_settings = None

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = (0, 0, 0, 0)
        else:
            pixels[:] = (0, 0, 0, 0)

        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        self.report({'INFO'}, 'Cut selected image.')

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_copy(bpy.types.Operator):
    """Copy the image"""
    bl_idname = "image_editor_plus.copy"
    bl_label = "Copy"

    def execute(self, context):
        session = app.get_session()

        img = app.get_target_image(context)
        if not img:
            return {'CANCELLED'}

        width, height = img.size
        pixels = utils.read_pixels_from_image(img)

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        session.copied_image_pixels = target_pixels

        session.copied_image_settings = {
            'is_float': img.is_float,
            'colorspace_name': img.colorspace_settings.name
        }

        layer = app.get_active_layer(context)
        if layer:
            session.copied_layer_settings = {
                'rotation': layer.rotation,
                'scale': layer.scale,
                'custom_data': layer.custom_data
            }
        else:
            session.copied_layer_settings = None

        self.report({'INFO'}, 'Copied selected image.')

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_paste(bpy.types.Operator):
    """Paste the image"""
    bl_idname = "image_editor_plus.paste"
    bl_label = "Paste"

    def execute(self, context):
        session = app.get_session()

        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        target_pixels = session.copied_image_pixels
        if target_pixels is None:
            return {'CANCELLED'}

        app.create_layer(img, target_pixels, session.copied_image_settings,
                session.copied_layer_settings)

        app.cancel_selection(context)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_crop(bpy.types.Operator):
    """Crop the image to the boundary of the selection"""
    bl_idname = "image_editor_plus.crop"
    bl_label = "Crop"

    def execute(self, context):
        wm = context.window_manager

        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        pixels = utils.read_pixels_from_image(img)

        selection = app.get_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        else:
            target_pixels = pixels

        target_width, target_height = target_pixels.shape[1], target_pixels.shape[0]

        img.scale(target_width, target_height)

        utils.write_pixels_to_image(img, target_pixels)

        if selection:
            img_props = img.imageeditorplus_properties
            layers = img_props.layers

            for layer in reversed(layers):
                layer_pos = layer.location
                layer_pos[0] -= selection[0][0]
                layer_pos[1] -= selection[0][1]

        app.cancel_selection(context)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_deselect_layer(bpy.types.Operator):
    bl_idname = "image_editor_plus.deselect_layer"
    bl_label = "Deselect Layer"

    def execute(self, context):
        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        img_props = img.imageeditorplus_properties
        img_props.selected_layer_index = -1

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_move_layer(bpy.types.Operator):
    """Move the layer"""
    bl_idname = "image_editor_plus.move_layer"
    bl_label = "Move Layer"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.start_input_position = [0, 0]
        self.start_layer_location = [0, 0]

    def modal(self, context, event):
        area_session = app.get_area_session(context)

        context.area.tag_redraw()

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        layer_width, layer_height = 1, 1
        layer_img = bpy.data.images.get(layer.name, None)
        if layer_img:
            layer_width, layer_height = layer_img.size[0], layer_img.size[1]

        if event.type == 'MOUSEMOVE':
            region_pos = [event.mouse_region_x, event.mouse_region_y]
            view_x, view_y = context.region.view2d.region_to_view(*region_pos)

            target_x = width * view_x
            target_y = height * view_y

            layer.location[0] = int(self.start_layer_location[0] \
                    + target_x - self.start_input_position[0])
            layer.location[1] = int(self.start_layer_location[1] \
                    - (target_y - self.start_input_position[1]))
        elif event.type == 'LEFTMOUSE':
            app.rebuild_image_layers_nodes(img)

            area_session.layer_moving = False
            area_session.prevent_layer_update_event = False

            return {'FINISHED'}
        elif event.type in {'RIGHTMOUSE', 'ESC'}:
            layer.location = self.start_layer_location

            area_session.layer_moving = False
            area_session.prevent_layer_update_event = False

            return {'CANCELLED'}

        return {'RUNNING_MODAL'}

    def invoke(self, context, event):
        area_session = app.get_area_session(context)

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        region_pos = [event.mouse_region_x, event.mouse_region_y]
        view_x, view_y = context.region.view2d.region_to_view(*region_pos)

        self.start_input_position = [width * view_x, height * view_y]
        self.start_layer_location = layer.location[:]

        area_session.layer_moving = True
        area_session.prevent_layer_update_event = True

        context.window_manager.modal_handler_add(self)

        return {'RUNNING_MODAL'}

class IMAGE_EDITOR_PLUS_OT_delete_layer(bpy.types.Operator):
    bl_idname = "image_editor_plus.delete_layer"
    bl_label = "Delete Layer"

    def execute(self, context):
        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        img_props = img.imageeditorplus_properties
        layers = img_props.layers
        selected_layer_index = img_props.selected_layer_index

        if selected_layer_index == -1 or selected_layer_index >= len(layers):
            return {'CANCELLED'}

        layer = layers[selected_layer_index]

        layer_img = bpy.data.images.get(layer.name, None)
        if layer_img:
            bpy.data.images.remove(layer_img)

        layers.remove(selected_layer_index)

        selected_layer_index = min(max(selected_layer_index, 0), len(layers) - 1)
        img_props.selected_layer_index = selected_layer_index

        app.rebuild_image_layers_nodes(img)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_change_image_layer_order(bpy.types.Operator):
    bl_idname = "image_editor_plus.change_image_layer_order"
    bl_label = "Change Image Layer Order"
    up: bpy.props.BoolProperty()

    def execute(self, context):
        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        img_props = img.imageeditorplus_properties
        layers = img_props.layers
        selected_layer_index = img_props.selected_layer_index

        if selected_layer_index == -1 or selected_layer_index >= len(layers):
            return {'CANCELLED'}

        if (self.up and selected_layer_index == 0) \
                or (not self.up and selected_layer_index >= len(layers) - 1):
            return {'CANCELLED'}

        layer = layers[selected_layer_index]

        new_layer_index = selected_layer_index + (-1 if self.up else 1)
        layers.move(selected_layer_index, new_layer_index)
        img_props.selected_layer_index = new_layer_index

        app.rebuild_image_layers_nodes(img)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_merge_layers(bpy.types.Operator):
    """Merge all layers"""
    bl_idname = "image_editor_plus.merge_layers"
    bl_label = "Merge Layers"

    def execute(self, context):
        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        width, height = img.size
        pixels = utils.read_pixels_from_image(img)

        img_props = img.imageeditorplus_properties
        layers = img_props.layers

        for layer in reversed(layers):
            layer_img = bpy.data.images.get(layer.name, None)
            if not layer_img:
                continue

            layer_width, layer_height = layer_img.size[0], layer_img.size[1]

            layer_pos = layer.location
            layer_x1, layer_y1 = layer_pos[0], height - layer_height - layer_pos[1]

            if layer.rotation == 0 and layer.scale[0] == 1.0 and layer.scale[1] == 1.0:
                layer_pixels = utils.read_pixels_from_image(layer_img)
            else:
                layer_pixels, new_layer_width, new_layer_height = \
                        app.apply_layer_transform(layer_img, layer.rotation, layer.scale)

                layer_x1 = int(layer_x1 - (new_layer_width - layer_width) / 2.0)
                layer_y1 = int(layer_y1 - (new_layer_height - layer_height) / 2.0)
                layer_width = new_layer_width
                layer_height = new_layer_height

            layer_x2 = layer_x1 + layer_width
            layer_y2 = layer_y1 + layer_height

            # clamp
            target_x1 = max(min(layer_x1, width), 0)
            target_y1 = max(min(layer_y1, height), 0)
            target_x2 = max(min(layer_x2, width), 0)
            target_y2 = max(min(layer_y2, height), 0)

            # completely out of the base image
            if layer_x1 == layer_x2 or layer_y1 == layer_y2:
                continue

            src_x1 = target_x1 - layer_x1
            src_y1 = target_y1 - layer_y1
            src_x2 = layer_width - (layer_x2 - target_x2)
            src_y2 = layer_height - (layer_y2 - target_y2)

            target_range = pixels[target_y1:target_y2, target_x1:target_x2]
            target_color_chan = target_range[:, :, :3]
            target_alpha_chan = target_range[:, :, 3:4]

            layer_range = layer_pixels[src_y1:src_y2, src_x1:src_x2]
            layer_color_chan = layer_range[:, :, :3]
            layer_alpha_chan = layer_range[:, :, 3:4]

            # composite
            temp_alpha_chan = target_alpha_chan * (1.0 - layer_alpha_chan) \
                    + layer_alpha_chan
            temp_alpha_chan_safe = \
                    np.where(temp_alpha_chan == 0, 1.0, temp_alpha_chan)    # for div by 0
            pixels[target_y1:target_y2, target_x1:target_x2, :3] \
                    = (target_color_chan * target_alpha_chan * (1.0 - layer_alpha_chan) \
                    + layer_color_chan * layer_alpha_chan) \
                    / temp_alpha_chan_safe
            pixels[target_y1:target_y2, target_x1:target_x2, 3:4] = temp_alpha_chan

            bpy.data.images.remove(layer_img)

        utils.write_pixels_to_image(img, pixels)

        layers.clear()

        app.rebuild_image_layers_nodes(img)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_flip(bpy.types.Operator):
    """Flip the image"""
    bl_idname = "image_editor_plus.flip"
    bl_label = "Flip"
    is_vertically: bpy.props.BoolProperty(name="Vertically", default=False)

    def execute(self, context):
        wm = context.window_manager

        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        width, height = img.size
        pixels = utils.read_pixels_from_image(img)

        if self.is_vertically:
            new_pixels = np.flipud(pixels)
        else:
            new_pixels = np.fliplr(pixels)

        utils.write_pixels_to_image(img, new_pixels)

        img_props = img.imageeditorplus_properties
        layers = img_props.layers

        for layer in reversed(layers):
            layer_img = bpy.data.images.get(layer.name, None)
            if not layer_img:
                continue

            layer_width, layer_height = layer_img.size[0], layer_img.size[1]

            layer_pos = layer.location

            if self.is_vertically:
                layer_pos[1] = height - layer_pos[1] - layer_height
                layer.scale[1] *= -1.0
            else:
                layer_pos[0] = width - layer_pos[0] - layer_width
                layer.scale[0] *= -1.0

            layer.rotation = -layer.rotation

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_rotate(bpy.types.Operator):
    """Rotate the image"""
    bl_idname = "image_editor_plus.rotate"
    bl_label = "Rotate"
    is_left: bpy.props.BoolProperty(name="Left", default=False)

    def execute(self, context):
        wm = context.window_manager

        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        width, height = img.size
        pixels = utils.read_pixels_from_image(img)

        if self.is_left:
            new_pixels = np.rot90(pixels, 3)
        else:
            new_pixels = np.rot90(pixels)

        img.scale(height, width)

        utils.write_pixels_to_image(img, new_pixels)

        img_props = img.imageeditorplus_properties
        layers = img_props.layers

        for layer in reversed(layers):
            layer_img = bpy.data.images.get(layer.name, None)
            if not layer_img:
                continue

            layer_width, layer_height = layer_img.size[0], layer_img.size[1]

            layer_pos = layer.location
            prev_layer_x, prev_layer_y = layer_pos[0], layer_pos[1]

            if self.is_left:
                layer_pos[0] = height - prev_layer_y - layer_height \
                        - (layer_width - layer_height) / 2.0
                layer_pos[1] = prev_layer_x + (layer_width - layer_height) / 2.0
                layer.rotation += math.pi / 2.0
            else:
                layer_pos[0] = prev_layer_y - (layer_width - layer_height) / 2.0
                layer_pos[1] = width - prev_layer_x - layer_width \
                        + (layer_width - layer_height) / 2.0
                layer.rotation -= math.pi / 2.0

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_scale(bpy.types.Operator):
    """Apply settings"""
    bl_idname = "image_editor_plus.scale"
    bl_label = "Scale"
    width: bpy.props.IntProperty()
    height: bpy.props.IntProperty()
    scale_layers: bpy.props.BoolProperty()

    def execute(self, context):
        wm = context.window_manager

        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        width, height = img.size

        img.scale(self.width, self.height)

        # scaling layers does not work unless the aspect rate is 1:1
        if self.scale_layers:
            scale_x = self.width / width 
            scale_y = self.height / height

            img_props = img.imageeditorplus_properties
            layers = img_props.layers

            for layer in reversed(layers):
                layer_img = bpy.data.images.get(layer.name, None)
                if not layer_img:
                    continue

                layer_width, layer_height = layer_img.size[0], layer_img.size[1]

                layer_scale = layer.scale
                layer_scale[0] *= scale_x
                layer_scale[1] *= scale_y

                layer_pos = layer.location
                layer_pos[0] = layer_pos[0] * scale_x - layer_width * (1.0 - scale_x) / 2.0
                layer_pos[1] = layer_pos[1] * scale_y - layer_height * (1.0 - scale_y) / 2.0

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_change_canvas_size(bpy.types.Operator):
    """Apply settings"""
    bl_idname = "image_editor_plus.change_canvas_size"
    bl_label = "Change Canvas Size"
    width: bpy.props.IntProperty()
    height: bpy.props.IntProperty()
    expand_from_center: bpy.props.BoolProperty()
    use_background_color: bpy.props.BoolProperty()

    def execute(self, context):
        wm = context.window_manager

        props = wm.imageeditorplus_properties
        if self.use_background_color:
            color = props.background_color[:] + (1.0,)
        else:
            color = (0, 0, 0, 0)

        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        width, height = img.size
        pixels = utils.read_pixels_from_image(img)

        if self.width > width or self.height > height:
            expand_width = self.width - width if self.width > width else 0
            expand_height = self.height - height if self.height > height else 0

            if self.expand_from_center:
                expand_left = int(expand_width / 2)
                expand_top = int(expand_height / 2)
                expand_right = expand_width - expand_left
                expand_bottom = expand_height - expand_top
            else:
                expand_left, expand_top = 0, 0
                expand_right = expand_width
                expand_bottom = expand_height

            new_pixels = np.pad(pixels, ((expand_bottom, expand_top),
                    (expand_left, expand_right), (0, 0)),
                    constant_values=np.array(((color, color), (color, color), (0, 0)),
                    dtype=object))
        else:
            new_pixels = pixels

        if self.width < width or self.height < height:
            shrink_width = width - self.width if self.width < width else 0
            shrink_height = height - self.height if self.height < height else 0

            if self.expand_from_center:
                shrink_left = int(shrink_width / 2)
                shrink_top = int(shrink_height / 2)
            else:
                shrink_left, shrink_top = 0, 0

            if self.height < height:
                new_pixels = new_pixels[height - self.height - shrink_top:height - shrink_top,
                        shrink_left:self.width + shrink_left]
            else:
                new_pixels = new_pixels[0:self.height,
                        shrink_left:self.width + shrink_left]

        img.scale(self.width, self.height)

        utils.write_pixels_to_image(img, new_pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_flip_layer(bpy.types.Operator):
    """Flip the layer"""
    bl_idname = "image_editor_plus.flip_layer"
    bl_label = "Flip Layer"
    is_vertically: bpy.props.BoolProperty(name="Vertically", default=False)

    def execute(self, context):
        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        if self.is_vertically:
            layer.scale[1] *= -1.0
        else:
            layer.scale[0] *= -1.0

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_rotate_layer(bpy.types.Operator):
    """Rotate the layer"""
    bl_idname = "image_editor_plus.rotate_layer"
    bl_label = "Rotate Layer"
    is_left: bpy.props.BoolProperty(name="Left", default=False)

    def execute(self, context):
        img = context.area.spaces.active.image
        if not img:
            return {'CANCELLED'}

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        layer.rotation += math.pi / 2.0 if self.is_left else -math.pi / 2.0

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_rotate_layer_arbitrary(bpy.types.Operator):
    """Rotate the image by a specified angle"""
    bl_idname = "image_editor_plus.rotate_layer_arbitrary"
    bl_label = "Rotate Layer Arbitrary"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.start_input_position = [0, 0]
        self.start_layer_angle = 0

    def modal(self, context, event):
        area_session = app.get_area_session(context)

        context.area.tag_redraw()

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        layer_width, layer_height = 1, 1
        layer_img = bpy.data.images.get(layer.name, None)
        if layer_img:
            layer_width, layer_height = layer_img.size[0], layer_img.size[1]

        if event.type == 'MOUSEMOVE':
            center_x = layer.location[0] + layer_width / 2.0
            center_y = height - layer.location[1] - layer_height / 2.0

            region_pos = [event.mouse_region_x, event.mouse_region_y]
            view_x, view_y = context.region.view2d.region_to_view(*region_pos)

            target_x = width * view_x
            target_y = height * view_y

            angle1 = math.atan2(self.start_input_position[1] - center_y,
                self.start_input_position[0] - center_x)
            angle2 = math.atan2(target_y - center_y, target_x - center_x)

            layer.rotation = self.start_layer_angle + angle2 - angle1

        elif event.type == 'LEFTMOUSE':
            app.rebuild_image_layers_nodes(img)

            area_session.layer_rotating = False
            area_session.prevent_layer_update_event = False

            return {'FINISHED'}
        elif event.type in {'RIGHTMOUSE', 'ESC'}:
            layer.rotation = self.start_layer_angle

            area_session.layer_rotating = False
            area_session.prevent_layer_update_event = False

            return {'CANCELLED'}

        return {'RUNNING_MODAL'}

    def invoke(self, context, event):
        area_session = app.get_area_session(context)

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        region_pos = [event.mouse_region_x, event.mouse_region_y]
        view_x, view_y = context.region.view2d.region_to_view(*region_pos)

        self.start_input_position = [width * view_x, height * view_y]
        self.start_layer_angle = layer.rotation

        area_session.layer_rotating = True
        area_session.prevent_layer_update_event = True

        context.window_manager.modal_handler_add(self)

        return {'RUNNING_MODAL'}

class IMAGE_EDITOR_PLUS_OT_scale_layer(bpy.types.Operator):
    """Scale the layer"""
    bl_idname = "image_editor_plus.scale_layer"
    bl_label = "Scale Layer"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.start_input_position = [0, 0]
        self.start_layer_scale_x = 1.0
        self.start_layer_scale_y = 1.0

    def modal(self, context, event):
        area_session = app.get_area_session(context)

        context.area.tag_redraw()

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        layer_width, layer_height = 1, 1
        layer_img = bpy.data.images.get(layer.name, None)
        if layer_img:
            layer_width, layer_height = layer_img.size[0], layer_img.size[1]

        if event.type == 'MOUSEMOVE':
            center_x = layer.location[0] + layer_width / 2.0
            center_y = height - layer.location[1] - layer_height / 2.0

            region_pos = [event.mouse_region_x, event.mouse_region_y]
            view_x, view_y = context.region.view2d.region_to_view(*region_pos)

            target_x = width * view_x
            target_y = height * view_y

            dist1 = math.hypot(self.start_input_position[0] - center_x,
                    self.start_input_position[1] - center_y)
            dist2 = math.hypot(target_x - center_x, target_y - center_y)

            layer.scale[0] = self.start_layer_scale_x * dist2 / dist1
            layer.scale[1] = self.start_layer_scale_y * dist2 / dist1
        elif event.type == 'LEFTMOUSE':
            app.rebuild_image_layers_nodes(img)

            area_session.layer_scaling = False
            area_session.prevent_layer_update_event = False

            return {'FINISHED'}
        elif event.type in {'RIGHTMOUSE', 'ESC'}:
            layer.scale[0] = self.start_layer_scale_x
            layer.scale[1] = self.start_layer_scale_y

            area_session.layer_scaling = False
            area_session.prevent_layer_update_event = False

            return {'CANCELLED'}

        return {'RUNNING_MODAL'}

    def invoke(self, context, event):
        area_session = app.get_area_session(context)

        img = context.area.spaces.active.image
        width, height = img.size[0], img.size[1]

        layer = app.get_active_layer(context)
        if not layer:
            return {'CANCELLED'}

        region_pos = [event.mouse_region_x, event.mouse_region_y]
        view_x, view_y = context.region.view2d.region_to_view(*region_pos)

        self.start_input_position = [width * view_x, height * view_y]
        self.start_layer_scale_x = layer.scale[0]
        self.start_layer_scale_y = layer.scale[1]

        area_session.layer_scaling = True
        area_session.prevent_layer_update_event = True

        context.window_manager.modal_handler_add(self)

        return {'RUNNING_MODAL'}

class IMAGE_EDITOR_PLUS_OT_offset(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.offset'
    bl_label = "Offset"
    offset_x: bpy.props.IntProperty()
    offset_y: bpy.props.IntProperty()
    offset_edge_behavior: bpy.props.EnumProperty(items=(
            ('wrap', 'Wrap', 'Wrap image around'),
            ('edge', 'Edge', 'Repeat edge pixels')))

    def execute(self, context):
        wm = context.window_manager

        offset_x = self.offset_x
        offset_y = self.offset_y
        edge_behavior = self.offset_edge_behavior

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        new_pixels = np.roll(target_pixels, offset_y, axis=0)

        if edge_behavior == 'edge':
            if offset_y > 0:
                new_pixels[:offset_y] = new_pixels[offset_y]
            elif offset_y < 0:
                new_pixels[offset_y:] = new_pixels[offset_y - 1]

        new_pixels = np.roll(new_pixels, offset_x, axis=1)

        if edge_behavior == 'edge':
            if offset_x > 0:
                new_pixels[:, :offset_x] = new_pixels[:, offset_x, np.newaxis]
            elif offset_x < 0:
                new_pixels[:, offset_x:] = new_pixels[:, offset_x - 1, np.newaxis]

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = new_pixels
        else:
            pixels[:,:] = new_pixels

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_adjust_color(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.adjust_color'
    bl_label = "Adjust Color"
    adjust_hue: bpy.props.FloatProperty()
    adjust_lightness: bpy.props.FloatProperty()
    adjust_saturation: bpy.props.FloatProperty()

    def execute(self, context):
        hue = self.adjust_hue
        light = self.adjust_lightness
        sat = self.adjust_saturation

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
            target_hsl = hsl[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels
            target_hsl = hsl

        new_hsl = np.dstack([
            (target_hsl[:, :, 0] + hue / 360.0) % 1.0,            # hue
            np.clip(target_hsl[:, :, 1] * sat / 100.0, 0, 1.0),   # saturation
            np.clip(target_hsl[:, :, 2] * light / 100.0, 0, 1.0)  # lightness
            ])

        rgb = np.clip(utils.hsl_to_rgb(new_hsl), 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0], 0:3] = rgb
        else:
            pixels[:,:,0:3] = rgb

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_adjust_brightness(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.adjust_brightness'
    bl_label = "Adjust Brightness"
    adjust_brightness: bpy.props.FloatProperty()
    adjust_contrast: bpy.props.FloatProperty()

    def execute(self, context):
        bright = self.adjust_brightness
        cont = self.adjust_contrast

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        rgb = target_pixels[:, :, :3]

        cont_fact = cont / 100.0 - 1.0
        cont_fact2 = (1.02 * (cont_fact + 1.0)) / (1.0 * (1.02 - cont_fact))
        bright_fact = bright / 200.0 - 0.5

        new_rgb = (rgb - 0.5) * cont_fact2 + 0.5 + bright_fact

        new_rgb = np.clip(new_rgb, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0], 0:3] = new_rgb
        else:
            pixels[:,:,0:3] = new_rgb

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_adjust_gamma(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.adjust_gamma'
    bl_label = "Adjust Gamma"
    adjust_gamma: bpy.props.FloatProperty()

    def execute(self, context):
        gamma = self.adjust_gamma

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        rgb = target_pixels[:, :, :3]

        gamma_inv = 1.0 / gamma
        new_rgb = rgb ** gamma_inv

        new_rgb = np.clip(new_rgb, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0], 0:3] = new_rgb
        else:
            pixels[:,:,0:3] = new_rgb

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_adjust_color_curve(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.adjust_color_curve'
    bl_label = "Adjust Color Curve"

    def execute(self, context):
        wm = context.window_manager

        curve_mapping = app.get_curve_mapping()
        if not curve_mapping:
            return {'CANCELLED'}

        curve_mapping.initialize()
        *curve_rgb, curve_val = curve_mapping.curves

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        rgb = target_pixels[:, :, :3]

        curve_eval = np.vectorize(curve_mapping.evaluate)

        new_rgb = curve_eval(curve_val, rgb)

        new_rgb = np.dstack([
            curve_eval(curve_rgb[i], new_rgb[:, :, i]) for i in range(3)
            ])

        new_rgb = np.clip(new_rgb, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0], 0:3] = new_rgb
        else:
            pixels[:,:,0:3] = new_rgb

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_replace_color(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.replace_color'
    bl_label = "Replace Color"
    source_color: bpy.props.FloatVectorProperty(size=3)
    replace_color: bpy.props.FloatVectorProperty(size=4)
    color_threshold: bpy.props.FloatProperty()

    def execute(self, context):
        src_color = self.source_color
        replace_color = self.replace_color
        threshold = self.color_threshold

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        if threshold <= 0:
            new_pixels = np.where(
                    np.all(target_pixels[:, :, :3] == src_color, axis=2)[..., np.newaxis],
                    replace_color, target_pixels)
        else:
            max_color_dist = np.sqrt(3) * threshold
            new_pixels = np.where(
                    (np.linalg.norm(target_pixels[:, :, :3] - src_color, axis=2)
                    < max_color_dist)[..., np.newaxis],
                    replace_color, target_pixels)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = new_pixels
        else:
            pixels[:] = new_pixels

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_blur(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.blur'
    bl_label = "Blur"
    blur_size: bpy.props.FloatProperty()
    expand_layer: bpy.props.BoolProperty()

    def execute(self, context):
        session = app.get_session()

        blur_size = self.blur_size
        need_expand_layer = self.expand_layer

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        expand_size = int(blur_size)
        layer = app.get_active_layer(context)

        # expand pixels
        if layer and need_expand_layer:
            pixels = np.pad(pixels,
                    ((expand_size, expand_size), (expand_size, expand_size), (0, 0)),
                    mode='constant', constant_values=0)

            target_pixels = np.pad(target_pixels,
                    ((expand_size, expand_size), (expand_size, expand_size), (0, 0)),
                    mode='constant', constant_values=0)

        new_pixels = utils.gaussian_blur_core(target_pixels, blur_size)

        new_pixels = np.clip(new_pixels, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = new_pixels
        else:
            pixels[:] = new_pixels

        img = app.get_target_image(context)
        width, height = img.size[0], img.size[1]

        # resize image
        layer_resized = False
        if layer:
            new_height, new_width, _ = pixels.shape
            if width != new_width or height != new_height:
                img.scale(new_width, new_height)

                cached_layer_location = session.cached_layer_location

                if need_expand_layer:
                    layer.location[0] = cached_layer_location[0] - expand_size
                    layer.location[1] = cached_layer_location[1] - expand_size
                else:
                    layer.location[0] = cached_layer_location[0]
                    layer.location[1] = cached_layer_location[1]

                layer_resized = True

        utils.write_pixels_to_image(img, pixels)

        if layer_resized:
            base_img = context.area.spaces.active.image
            app.rebuild_image_layers_nodes(base_img)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_sharpen(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.sharpen'
    bl_label = "Sharpen"
    sharpen_radius: bpy.props.FloatProperty()
    sharpen_amount: bpy.props.FloatProperty()
    sharpen_threshold: bpy.props.FloatProperty()

    def execute(self, context):
        radius = self.sharpen_radius
        amount = self.sharpen_amount
        threshold = self.sharpen_threshold

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        blurred = utils.gaussian_blur_core(target_pixels, radius)
        new_pixels = (amount + 1.0) * target_pixels - amount * blurred

        if threshold > 0:
            low_contrast = np.absolute(target_pixels - blurred) < threshold
            np.copyto(new_pixels, target_pixels, where=low_contrast)

        new_pixels = np.clip(new_pixels, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = new_pixels
        else:
            pixels[:] = new_pixels

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_add_noise(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.add_noise'
    bl_label = "Noise"
    add_noise_intensity: bpy.props.FloatProperty()

    def execute(self, context):
        intensity = self.add_noise_intensity

        sigma = (intensity * 0.02) ** 0.5

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        rgb = target_pixels[:, :, :3]

        noise = np.random.normal(0, sigma, (target_pixels.shape[0], target_pixels.shape[1], 3))
        new_rgb = rgb + noise

        new_rgb = np.clip(new_rgb, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0], 0:3] = new_rgb
        else:
            pixels[:,:,0:3] = new_rgb

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_pixelize(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.pixelize'
    bl_label = "Pixelize"
    pixelize_pixel_size: bpy.props.IntProperty()

    def execute(self, context):
        wm = context.window_manager

        pixel_size = self.pixelize_pixel_size

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        height, width = target_pixels.shape[0], target_pixels.shape[1]
        pad_x = math.ceil(width / pixel_size) * pixel_size - width
        pad_y = math.ceil(height / pixel_size) * pixel_size - height

        target_pixels = np.pad(target_pixels, ((0, pad_y), (0, pad_x), (0, 0)), 'edge')

        new_pixels = target_pixels[pixel_size // 2::pixel_size, pixel_size // 2::pixel_size]
        new_pixels = np.repeat(np.repeat(new_pixels, pixel_size, axis=0), pixel_size, axis=1)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = new_pixels[:height, :width]
        else:
            pixels[:] = new_pixels[:height, :width]

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_make_seamless(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.make_seamless'
    bl_label = "Make Seamless"

    def execute(self, context):
        img = app.get_target_image(context)
        if not img:
            return {'CANCELLED'}

        pixels = utils.read_pixels_from_image(img)

        selection = app.get_target_selection(context)
        if selection:
            target_pixels = pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_pixels = pixels

        target_width, target_height = target_pixels.shape[1], target_pixels.shape[0]
        target_half_width = target_width // 2
        target_half_height = target_height // 2
        odd_horz = target_width % 2
        odd_vert = target_height % 2

        for pat in range(2):
            if pat == 0:
                region1_x1, region1_x2 = 0, target_half_width
                region2_x1, region2_x2 = target_half_width + odd_horz, target_width
            else:
                region1_x1, region1_x2 = target_half_width + odd_horz, target_width
                region2_x1, region2_x2 = 0, target_half_width

            region1 = target_pixels[0:target_half_height, region1_x1:region1_x2]
            region2 = target_pixels[target_half_height + odd_vert:target_height,
                    region2_x1:region2_x2]

            y, x = np.meshgrid(range(target_half_height), range(region1_x1, region1_x2),
                    indexing='ij')
            a = (np.abs(x - target_half_width) - 1.0) / (target_half_width - 1.0)
            b = (np.abs(y - target_half_height) - 1.0) / (target_half_height - 1.0)
            w_denom = a * b + (1.0 - a) * (1.0 - b)
            w_denom = np.where(w_denom == 0, 1.0, w_denom)    # for div by 0
            w = 1.0 - a * b / w_denom

            new_region_chans = []
            for i in range(4):
                new_region_chans.append(w * region1[:,:,i] + (1.0 - w) * region2[:,:,i])

            new_region = np.dstack(new_region_chans)

            region1[:] = new_region
            region2[:] = new_region

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]] = target_pixels
        else:
            pixels[:] = target_pixels

        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}

class IMAGE_EDITOR_PLUS_OT_normal_map(bpy.types.Operator):
    """Apply settings"""
    bl_idname = 'image_editor_plus.normal_map'
    bl_label = "Normal Map"
    scale: bpy.props.FloatProperty()
    flip_x: bpy.props.BoolProperty()
    flip_y: bpy.props.BoolProperty()
    full_z: bpy.props.BoolProperty()

    def execute(self, context):
        scale = self.scale
        flip_x = self.flip_x
        flip_y = self.flip_y
        full_z = self.full_z

        pixels, hsl = app.get_image_cache()
        if pixels is None:
            return {'CANCELLED'}

        selection = app.get_target_selection(context)
        if selection:
            target_hsl = hsl[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0]]
        elif selection == []:
            return {'CANCELLED'}
        else:
            target_hsl = hsl

        target_width, target_height = target_hsl.shape[1], target_hsl.shape[0]

        target_hsl = np.pad(target_hsl, ((1, 1), (1, 1), (0, 0)), 'edge')

        new_pixels = np.zeros((target_height, target_width, 4))
        nx = (target_hsl[1:target_height + 1, 0:target_width, 2]
                - target_hsl[1:target_height + 1, 2:target_width + 2, 2]) * scale
        ny = (target_hsl[0:target_height, 1:target_width + 1, 2]
                - target_hsl[2:target_height + 2, 1:target_width + 1, 2]) * scale
        nz = 1.0 / np.sqrt(nx * nx + ny * ny + 1.0)

        nx *= nz
        ny *= nz

        new_pixels = np.dstack([
            0.5 + (-0.5 if flip_x else 0.5) * nx,
            0.5 + (-0.5 if flip_y else 0.5) * ny,
            (0 if full_z else 0.5) + (1.0 if full_z else 0.5) * nz,
            ])

        new_pixels = np.clip(new_pixels, 0, None)

        if selection:
            pixels[selection[0][1]:selection[1][1],
                    selection[0][0]:selection[1][0], 0:3] = new_pixels
        else:
            pixels[:,:,0:3] = new_pixels

        img = app.get_target_image(context)
        utils.write_pixels_to_image(img, pixels)

        app.refresh_image(context)

        return {'FINISHED'}