# SPDX-License-Identifier: GPL-2.0-or-later

import bpy

from mathutils import (
    Vector,
    Matrix,
)
from mathutils.geometry import intersect_point_line
from .drawing_utilities import SnapDrawn
from .common_utilities import (
    convert_distance,
    get_units_info,
    location_3d_to_region_2d,
)


class SnapNavigation():
    __slots__ = (
        'use_ndof',
        '_rotate',
        '_move',
        '_zoom',
        '_ndof_all',
        '_ndof_orbit',
        '_ndof_orbit_zoom',
        '_ndof_pan')

    @staticmethod
    def debug_key(key):
        for member in dir(key):
            print(member, getattr(key, member))

    @staticmethod
    def convert_to_flag(shift, ctrl, alt):
        return (shift << 0) | (ctrl << 1) | (alt << 2)

    def __init__(self, context, use_ndof):
        # TO DO:
        # 'View Orbit', 'View Pan', 'NDOF Orbit View', 'NDOF Pan View'
        self.use_ndof = use_ndof and context.preferences.inputs.use_ndof

        self._rotate = set()
        self._move = set()
        self._zoom = set()

        if self.use_ndof:
            self._ndof_all = set()
            self._ndof_orbit = set()
            self._ndof_orbit_zoom = set()
            self._ndof_pan = set()

        for key in context.window_manager.keyconfigs.user.keymaps['3D View'].keymap_items:
            if key.idname == 'view3d.rotate':
                self._rotate.add((self.convert_to_flag(
                    key.shift, key.ctrl, key.alt), key.type, key.value))
            elif key.idname == 'view3d.move':
                self._move.add((self.convert_to_flag(
                    key.shift, key.ctrl, key.alt), key.type, key.value))
            elif key.idname == 'view3d.zoom':
                if key.type == 'WHEELINMOUSE':
                    self._zoom.add((self.convert_to_flag(
                        key.shift, key.ctrl, key.alt), 'WHEELUPMOUSE', key.value, key.properties.delta))
                elif key.type == 'WHEELOUTMOUSE':
                    self._zoom.add((self.convert_to_flag(
                        key.shift, key.ctrl, key.alt), 'WHEELDOWNMOUSE', key.value, key.properties.delta))
                else:
                    self._zoom.add((self.convert_to_flag(
                        key.shift, key.ctrl, key.alt), key.type, key.value, key.properties.delta))

            elif self.use_ndof:
                if key.idname == 'view3d.ndof_all':
                    self._ndof_all.add((self.convert_to_flag(
                        key.shift, key.ctrl, key.alt), key.type))
                elif key.idname == 'view3d.ndof_orbit':
                    self._ndof_orbit.add((self.convert_to_flag(
                        key.shift, key.ctrl, key.alt), key.type))
                elif key.idname == 'view3d.ndof_orbit_zoom':
                    self._ndof_orbit_zoom.add(
                        (self.convert_to_flag(key.shift, key.ctrl, key.alt), key.type))
                elif key.idname == 'view3d.ndof_pan':
                    self._ndof_pan.add((self.convert_to_flag(
                        key.shift, key.ctrl, key.alt), key.type))

    def run(self, context, event, snap_location):
        evkey = (self.convert_to_flag(event.shift, event.ctrl,
                 event.alt), event.type, event.value)

        if evkey in self._rotate:
            if snap_location:
                bpy.ops.view3d.rotate_custom_pivot(
                    'INVOKE_DEFAULT', pivot=snap_location)
            else:
                bpy.ops.view3d.rotate('INVOKE_DEFAULT', use_cursor_init=True)
            return True

        if evkey in self._move:
            bpy.ops.view3d.move('INVOKE_DEFAULT')
            return True

        for key in self._zoom:
            if evkey == key[0:3]:
                if key[3]:
                    if snap_location:
                        bpy.ops.view3d.zoom_custom_target(
                            'INVOKE_DEFAULT', delta=key[3], target=snap_location)
                    else:
                        bpy.ops.view3d.zoom('INVOKE_DEFAULT', delta=key[3])
                else:
                    bpy.ops.view3d.zoom('INVOKE_DEFAULT')
                return True

        if self.use_ndof:
            ndofkey = evkey[:2]
            if ndofkey in self._ndof_all:
                bpy.ops.view3d.ndof_all('INVOKE_DEFAULT')
                return True
            if ndofkey in self._ndof_orbit:
                bpy.ops.view3d.ndof_orbit('INVOKE_DEFAULT')
                return True
            if ndofkey in self._ndof_orbit_zoom:
                bpy.ops.view3d.ndof_orbit_zoom('INVOKE_DEFAULT')
                return True
            if ndofkey in self._ndof_pan:
                bpy.ops.view3d.ndof_pan('INVOKE_DEFAULT')
                return True

        return False


class CharMap:
    __slots__ = (
        'unit_system',
        'uinfo',
        'length_entered',
        'length_entered_value',
        'line_pos')

    ascii = {
        ".", ",", "-", "+", "1", "2", "3",
        "4", "5", "6", "7", "8", "9", "0",
        "c", "m", "d", "k", "h", "a",
        " ", "/", "*", "'", "\""
        # "="
    }
    type = {
        'BACK_SPACE', 'DEL',
        'LEFT_ARROW', 'RIGHT_ARROW'
    }

    def __init__(self, context):
        scale = context.scene.unit_settings.scale_length
        separate_units = context.scene.unit_settings.use_separate
        self.unit_system = context.scene.unit_settings.system
        self.uinfo = get_units_info(scale, self.unit_system, separate_units)

        self.clear()

    def modal_(self, context, event):
        if event.value == 'PRESS':
            type = event.type
            ascii = event.ascii
            if (type in self.type) or (ascii in self.ascii):
                if ascii:
                    pos = self.line_pos
                    if ascii == ",":
                        ascii = "."
                    self.length_entered = self.length_entered[:pos] + \
                        ascii + self.length_entered[pos:]
                    self.line_pos += 1

                if self.length_entered:
                    pos = self.line_pos
                    if type == 'BACK_SPACE':
                        self.length_entered = self.length_entered[:pos -
                                                                  1] + self.length_entered[pos:]
                        self.line_pos -= 1

                    elif type == 'DEL':
                        self.length_entered = self.length_entered[:pos] + \
                            self.length_entered[pos + 1:]

                    elif type == 'LEFT_ARROW':
                        self.line_pos = (
                            pos - 1) % (len(self.length_entered) + 1)

                    elif type == 'RIGHT_ARROW':
                        self.line_pos = (
                            pos + 1) % (len(self.length_entered) + 1)

                    try:
                        self.length_entered_value = bpy.utils.units.to_value(
                            self.unit_system, 'LENGTH', self.length_entered)
                    except:  # ValueError:
                        self.length_entered_value = 0.0  # invalid
                        #self.report({'INFO'}, "Operation not supported yet")
                else:
                    self.length_entered_value = 0.0

                return True

        return False

    def get_converted_length_str(self, length):
        if self.length_entered:
            pos = self.line_pos
            ret = self.length_entered[:pos] + '|' + self.length_entered[pos:]
        else:
            ret = convert_distance(length, self.uinfo)

        return ret

    def clear(self):
        self.length_entered = ''
        self.length_entered_value = 0.0
        self.line_pos = 0


class Constrain:
    def __init__(self, prefs, scene, obj):
        self.last_type = None
        self.last_vec = None
        self.rotMat = None
        self.preferences = prefs
        trans_orient = scene.transform_orientation_slots[0]
        self.orientation = [None, None]
        if trans_orient.type == 'LOCAL':
            self.orientation[0] = obj.matrix_world.to_3x3().transposed()
            self.orientation[1] = Matrix.Identity(3)
        else:
            self.orientation[0] = Matrix.Identity(3)
            self.orientation[1] = obj.matrix_world.to_3x3().transposed()

        self.orientation_id = 0
        self.center = Vector((0.0, 0.0, 0.0))
        self.center_2d = Vector((0.0, 0.0))
        self.projected_vecs = Matrix(([0.0, 0.0], [0.0, 0.0], [0.0, 0.0]))

    def _constrain_set(self, mcursor):
        vec = (mcursor - self.center_2d)
        vec.normalize()

        dot_x = abs(vec.dot(self.projected_vecs[0]))
        dot_y = abs(vec.dot(self.projected_vecs[1]))
        dot_z = abs(vec.dot(self.projected_vecs[2]))

        if dot_x > dot_y and dot_x > dot_z:
            vec = self.orientation[self.orientation_id][0]
            type = 'X'

        elif dot_y > dot_x and dot_y > dot_z:
            vec = self.orientation[self.orientation_id][1]
            type = 'Y'

        else:  # dot_z > dot_y and dot_z > dot_x:
            vec = self.orientation[self.orientation_id][2]
            type = 'Z'

        return vec, type

    def modal(self, event, shift_callback):
        type = event.type
        if self.last_type == type:
            self.orientation_id += 1

        if type == 'X':
            if self.orientation_id < 2:
                self.last_vec = self.orientation[self.orientation_id][0]
            else:
                self.orientation_id = 0
                self.last_vec = type = None
        elif type == 'Y':
            if self.orientation_id < 2:
                self.last_vec = self.orientation[self.orientation_id][1]
            else:
                self.orientation_id = 0
                self.last_vec = type = None
        elif type == 'Z':
            if self.orientation_id < 2:
                self.last_vec = self.orientation[self.orientation_id][2]
            else:
                self.orientation_id = 0
                self.last_vec = type = None
        elif shift_callback and type in {'RIGHT_SHIFT', 'LEFT_SHIFT'}:
            if self.orientation_id < 1:
                type = 'shift'
                self.last_vec = shift_callback()
            else:
                self.orientation_id = 0
                self.last_vec = type = None
        else:
            return False

        self.preferences.auto_constrain = False
        self.last_type = type
        return True

    def toggle(self):
        self.rotMat = None  # update
        if self.preferences.auto_constrain:
            self.orientation_id = (self.orientation_id + 1) % 2
            self.preferences.auto_constrain = self.orientation_id != 0
        else:
            self.preferences.auto_constrain = True

    def update(self, region, rv3d, mcursor, center):
        if rv3d.view_matrix != self.rotMat or self.center != center:
            self.rotMat = rv3d.view_matrix.copy()

            self.center = center.copy()
            self.center_2d = location_3d_to_region_2d(
                region, rv3d, self.center)

            vec = self.center + self.orientation[self.orientation_id][0]
            self.projected_vecs[0] = location_3d_to_region_2d(
                region, rv3d, vec) - self.center_2d
            vec = self.center + self.orientation[self.orientation_id][1]
            self.projected_vecs[1] = location_3d_to_region_2d(
                region, rv3d, vec) - self.center_2d
            vec = self.center + self.orientation[self.orientation_id][2]
            self.projected_vecs[2] = location_3d_to_region_2d(
                region, rv3d, vec) - self.center_2d

            self.projected_vecs[0].normalize()
            self.projected_vecs[1].normalize()
            self.projected_vecs[2].normalize()

        return self._constrain_set(mcursor)


class SnapUtilities:
    """
    __slots__ = (
        "sctx",
        "draw_cache",
        "outer_verts",
        "unit_system",
        "rd",
        "obj",
        "bm",
        "geom",
        "type",
        "location",
        "preferences",
        "normal",
        "snap_vert",
        "snap_edge",
        "snap_face",
        "incremental",
    )
    """

    constrain_keys = {
        'X': Vector((1, 0, 0)),
        'Y': Vector((0, 1, 0)),
        'Z': Vector((0, 0, 1)),
        'RIGHT_SHIFT': 'shift',
        'LEFT_SHIFT': 'shift',
    }

    snapwidgets = []
    constrain = None

    @staticmethod
    def set_contrain(context, key):
        widget = SnapUtilities.snapwidgets[-1] if SnapUtilities.snapwidgets else None
        if SnapUtilities.constrain == key:
            SnapUtilities.constrain = None
            if hasattr(widget, "get_normal"):
                widget.get_normal(context)
            return

        if hasattr(widget, "normal"):
            if key == 'shift':
                import bmesh
                if isinstance(widget.geom, bmesh.types.BMEdge):
                    verts = widget.geom.verts
                    widget.normal = verts[1].co - verts[0].co
                    widget.normal.normalise()
                else:
                    return
            else:
                widget.normal = SnapUtilities.constrain_keys[key]

        SnapUtilities.constrain = key

    def snap_context_update_and_return_moving_objects(self, context):
        moving_objects = set()
        moving_snp_objects = set()
        children = set()
        for obj in context.view_layer.objects.selected:
            moving_objects.add(obj)

        temp_children = set()
        for obj in context.visible_objects:
            temp_children.clear()
            while obj.parent is not None:
                temp_children.add(obj)
                parent = obj.parent
                if parent in moving_objects:
                    children.update(temp_children)
                    temp_children.clear()
                obj = parent

        del temp_children

        moving_objects.difference_update(children)

        self.sctx.clear_snap_objects(True)

        for obj in context.visible_objects:
            is_moving = obj in moving_objects or obj in children
            snap_obj = self.sctx.add_obj(obj, obj.matrix_world)
            if is_moving:
                moving_snp_objects.add(snap_obj)

            if obj.instance_type == 'COLLECTION':
                mat = obj.matrix_world.copy()
                for ob in obj.instance_collection.objects:
                    snap_obj = self.sctx.add_obj(ob, mat @ ob.matrix_world)
                    if is_moving:
                        moving_snp_objects.add(snap_obj)

        del children
        return moving_objects, moving_snp_objects

    def snap_context_update(self, context):
        def visible_objects_and_duplis():
            if self.preferences.outer_verts:
                for obj in context.visible_objects:
                    yield (obj, obj.matrix_world)

                    if obj.instance_type == 'COLLECTION':
                        mat = obj.matrix_world.copy()
                        for ob in obj.instance_collection.objects:
                            yield (ob, mat @ ob.matrix_world)
            else:
                for obj in context.objects_in_mode_unique_data:
                    yield (obj, obj.matrix_world)

        self.sctx.clear_snap_objects(True)

        for obj, matrix in visible_objects_and_duplis():
            self.sctx.add_obj(obj, matrix)

    def snap_context_init(self, context, snap_edge_and_vert=True):
        from .snap_context_l import global_snap_context_get

        # Create Snap Context
        self.sctx = global_snap_context_get(
            context.evaluated_depsgraph_get(), context.region, context.space_data)
        ui_scale = context.preferences.system.ui_scale
        self.sctx.set_pixel_dist(12 * ui_scale)

        if SnapUtilities.snapwidgets:
            widget = SnapUtilities.snapwidgets[-1]

            self.obj = widget.snap_obj.data[0] if widget.snap_obj else context.active_object
            self.bm = widget.bm
            self.geom = widget.geom
            self.type = widget.type
            self.location = widget.location
            self.preferences = widget.preferences
            self.draw_cache = widget.draw_cache
            if hasattr(widget, "normal"):
                self.normal = widget.normal

        else:
            # init these variables to avoid errors
            self.obj = context.active_object
            self.bm = None
            self.geom = None
            self.type = 'OUT'
            self.location = Vector()

            preferences = context.preferences.addons[__package__].preferences
            self.preferences = preferences

            # Init DrawCache
            self.draw_cache = SnapDrawn(
                preferences.out_color,
                preferences.face_color,
                preferences.edge_color,
                preferences.vert_color,
                preferences.center_color,
                preferences.perpendicular_color,
                preferences.constrain_shift_color,
                tuple(
                    context.preferences.themes[0].user_interface.axis_x) + (1.0,),
                tuple(
                    context.preferences.themes[0].user_interface.axis_y) + (1.0,),
                tuple(
                    context.preferences.themes[0].user_interface.axis_z) + (1.0,),
                self.sctx.rv3d,
                ui_scale)

        self.snap_vert = self.snap_edge = snap_edge_and_vert

        shading = context.space_data.shading
        self.snap_face = not (snap_edge_and_vert and (
            shading.show_xray or shading.type == 'WIREFRAME'))

        self.sctx.set_snap_mode(self.snap_vert, self.snap_edge, self.snap_face)

        # Configure the unit of measure
        unit_system = context.scene.unit_settings.system
        scale = context.scene.unit_settings.scale_length
        scale /= context.space_data.overlay.grid_scale
        self.rd = bpy.utils.units.to_value(
            unit_system, 'LENGTH', str(1 / scale))

        self.incremental = bpy.utils.units.to_value(
            unit_system, 'LENGTH', str(self.preferences.incremental))

    def snap_to_grid(self):
        if self.type == 'OUT' and self.preferences.increments_grid:
            loc = self.location / self.rd
            self.location = Vector((round(loc.x),
                                    round(loc.y),
                                    round(loc.z))) * self.rd

    def snap_context_free(self):
        self.sctx = None
        del self.sctx

        del self.bm
        del self.draw_cache
        del self.geom
        del self.location
        del self.rd
        del self.snap_face
        del self.snap_obj
        del self.type

        del self.preferences

        SnapUtilities.constrain = None
