# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import secrets
import string
from abc import ABC, abstractmethod
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Optional, Union

import bmesh
from bpy.types import Armature, Context, Mesh, NodesModifier, Object
from mathutils import Matrix, Vector

from ..common import shader
from ..common.convert import Json
from ..common.deep import make_json
from ..common.logger import get_logger
from ..editor.extension import get_armature_extension, get_material_extension
from ..editor.property_group import BonePropertyGroup, BonePropertyGroupType
from ..editor.search import MESH_CONVERTIBLE_OBJECT_TYPES
from ..external import io_scene_gltf2_support

logger = get_logger(__name__)


class AbstractBaseVrmExporter(ABC):
    def __init__(
        self,
        context: Context,
        export_objects: Sequence[Object],
        armature: Object,
    ) -> None:
        self.context = context
        self.export_objects = export_objects
        self.armature = armature
        self.export_id = "BlenderVrmAddonExport" + (
            "".join(secrets.choice(string.digits) for _ in range(10))
        )
        self.gltf2_addon_export_settings = (
            io_scene_gltf2_support.create_export_settings()
        )

        armature_data = self.armature.data
        if not isinstance(armature_data, Armature):
            message = f"{type(armature_data)} is not an Armature"
            raise TypeError(message)

    @abstractmethod
    def export_vrm(self) -> Optional[bytes]:
        pass

    @staticmethod
    def enter_clear_blend_shape_proxy_previews(
        armature_data: Armature,
    ) -> tuple[Sequence[float], Mapping[str, float]]:
        ext = get_armature_extension(armature_data)

        saved_vrm0_previews: list[float] = []
        for blend_shape_group in ext.vrm0.blend_shape_master.blend_shape_groups:
            saved_vrm0_previews.append(blend_shape_group.preview)
            blend_shape_group.preview = 0

        saved_vrm1_previews: dict[str, float] = {}
        for (
            name,
            expression,
        ) in ext.vrm1.expressions.all_name_to_expression_dict().items():
            saved_vrm1_previews[name] = expression.preview
            expression.preview = 0

        return saved_vrm0_previews, saved_vrm1_previews

    @staticmethod
    def leave_clear_blend_shape_proxy_previews(
        armature_data: Armature,
        saved_vrm0_previews: Sequence[float],
        saved_vrm1_previews: Mapping[str, float],
    ) -> None:
        ext = get_armature_extension(armature_data)

        for blend_shape_group, blend_shape_preview in zip(
            ext.vrm0.blend_shape_master.blend_shape_groups, saved_vrm0_previews
        ):
            blend_shape_group.preview = blend_shape_preview

        for (
            name,
            expression,
        ) in ext.vrm1.expressions.all_name_to_expression_dict().items():
            expression_preview = saved_vrm1_previews.get(name)
            if expression_preview is not None:
                expression.preview = expression_preview

    @contextmanager
    def clear_blend_shape_proxy_previews(
        self, armature_data: Armature
    ) -> Iterator[None]:
        saved_vrm0_previews, saved_vrm1_previews = (
            self.enter_clear_blend_shape_proxy_previews(armature_data)
        )
        try:
            yield
            # After yield, native bpy objects may be deleted or frames may advance,
            # becoming invalid. Accessing them in this state causes crashes, so
            # be careful not to access potentially invalid native objects after yield
        finally:
            self.leave_clear_blend_shape_proxy_previews(
                armature_data, saved_vrm0_previews, saved_vrm1_previews
            )

    def enter_enable_deform_for_all_referenced_bones(
        self, armature_data: Armature
    ) -> list[str]:
        ext = get_armature_extension(armature_data)
        modified_non_deform_bone_names = list[str]()
        for (
            bone_property_group,
            bone_property_group_type,
        ) in BonePropertyGroup.get_all_bone_property_groups(armature_data):
            bone = armature_data.bones.get(bone_property_group.bone_name)
            if not bone or bone.use_deform:
                continue
            if (
                ext.is_vrm0()
                and BonePropertyGroupType.is_vrm0(bone_property_group_type)
            ) or (
                ext.is_vrm1()
                and BonePropertyGroupType.is_vrm1(bone_property_group_type)
            ):
                bone.use_deform = True
                modified_non_deform_bone_names.append(bone.name)
        return modified_non_deform_bone_names

    def leave_enable_deform_for_all_referenced_bones(
        self, armature_data: Armature, modified_non_deform_bone_names: list[str]
    ) -> None:
        for modified_non_deform_bone_name in modified_non_deform_bone_names:
            bone = armature_data.bones.get(modified_non_deform_bone_name)
            if bone and bone.use_deform:
                bone.use_deform = False

    @contextmanager
    def enable_deform_for_all_referenced_bones(
        self, armature_data: Armature
    ) -> Iterator[None]:
        modified_non_deform_bone_names = (
            self.enter_enable_deform_for_all_referenced_bones(armature_data)
        )
        try:
            yield
        finally:
            self.leave_enable_deform_for_all_referenced_bones(
                armature_data, modified_non_deform_bone_names
            )

    @staticmethod
    def enter_hide_mtoon1_outline_geometry_nodes(
        context: Context,
    ) -> dict[str, list[tuple[str, bool, bool]]]:
        object_name_to_modifiers: dict[str, list[tuple[str, bool, bool]]] = {}
        for obj in context.blend_data.objects:
            for modifier in obj.modifiers:
                if not modifier.show_viewport:
                    continue
                if modifier.type != "NODES":
                    continue
                if not isinstance(modifier, NodesModifier):
                    continue
                node_group = modifier.node_group
                if (
                    not node_group
                    or node_group.name != shader.OUTLINE_GEOMETRY_GROUP_NAME
                ):
                    continue
                modifiers = object_name_to_modifiers.get(obj.name)
                if modifiers is None:
                    modifiers = []
                    object_name_to_modifiers[obj.name] = modifiers
                modifiers = object_name_to_modifiers[obj.name]
                modifiers.append(
                    (
                        modifier.name,
                        modifier.show_render,
                        modifier.show_viewport,
                    )
                )
                if modifier.show_render:
                    modifier.show_render = False
                if modifier.show_viewport:
                    modifier.show_viewport = False
        return object_name_to_modifiers

    @staticmethod
    def exit_hide_mtoon1_outline_geometry_nodes(
        context: Context,
        object_name_to_modifiers: dict[str, list[tuple[str, bool, bool]]],
    ) -> None:
        for object_name, modifiers in object_name_to_modifiers.items():
            for modifier_name, render, viewport in modifiers:
                obj = context.blend_data.objects.get(object_name)
                if not obj:
                    continue
                modifier = obj.modifiers.get(modifier_name)
                if (
                    not modifier
                    or modifier.type != "NODES"
                    or not isinstance(modifier, NodesModifier)
                ):
                    continue
                node_group = modifier.node_group
                if (
                    not node_group
                    or node_group.name != shader.OUTLINE_GEOMETRY_GROUP_NAME
                ):
                    continue
                if modifier.show_render != render:
                    modifier.show_render = render
                if modifier.show_viewport != viewport:
                    modifier.show_viewport = viewport

    @staticmethod
    @contextmanager
    def hide_mtoon1_outline_geometry_nodes(context: Context) -> Iterator[None]:
        object_name_to_modifier_names = (
            AbstractBaseVrmExporter.enter_hide_mtoon1_outline_geometry_nodes(context)
        )
        try:
            yield
            # After yield, native bpy objects may be deleted or frames may advance,
            # becoming invalid. Accessing them in this state causes crashes, so
            # be careful not to access potentially invalid native objects after yield
        finally:
            AbstractBaseVrmExporter.exit_hide_mtoon1_outline_geometry_nodes(
                context, object_name_to_modifier_names
            )

    @staticmethod
    def setup_mtoon_gltf_fallback_nodes(context: Context, *, is_vrm0: bool) -> None:
        """Reflect MToon node values to nodes used for glTF fallback values.

        When MToon nodes are directly edited, glTF fallback values are not
        automatically set. Therefore, we explicitly set values during export.
        """
        for material in context.blend_data.materials:
            mtoon1 = get_material_extension(material).mtoon1
            if not mtoon1.enabled:
                continue
            mtoon1.pbr_metallic_roughness.base_color_factor = (
                mtoon1.pbr_metallic_roughness.base_color_factor
            )
            mtoon1.emissive_factor = mtoon1.emissive_factor
            mtoon1.normal_texture.scale = mtoon1.normal_texture.scale
            mtoon1.extensions.khr_materials_emissive_strength.emissive_strength = (
                mtoon1.extensions.khr_materials_emissive_strength.emissive_strength
            )
            for texture in mtoon1.all_textures(downgrade_to_mtoon0=is_vrm0):
                texture.source = texture.get_connected_node_image()


def assign_dict(
    target: dict[str, Json],
    key: str,
    value: Union[Json, tuple[float, float, float], tuple[float, float, float, float]],
    default_value: Json = None,
) -> bool:
    if value is None or value == default_value:
        return False
    target[key] = make_json(value)
    return True


def generate_evaluated_mesh(context: Context, obj: Object) -> Optional[Mesh]:
    obj_data = obj.data
    if obj_data is None:
        return None

    # https://docs.blender.org/api/2.80/Depsgraph.html
    # TODO: Shape keys may sometimes break
    depsgraph = context.evaluated_depsgraph_get()
    evaluated_obj = obj.evaluated_get(depsgraph)
    evaluated_temporary_mesh = evaluated_obj.to_mesh(
        preserve_all_data_layers=True, depsgraph=depsgraph
    )
    if not evaluated_temporary_mesh:
        return None

    # The documentation says to use BlendDataMeshes.new_from_object(), but
    # that doesn't preserve shape keys.
    if isinstance(obj_data, Mesh):
        evaluated_mesh = obj_data.copy()
    else:
        logger.error(
            "Unexpected object type: %s name=%s",
            type(obj_data),
            obj_data.name,
        )
        evaluated_mesh = context.blend_data.meshes.new(name=obj_data.name)

    bm = bmesh.new()
    bm.from_mesh(evaluated_temporary_mesh)
    bm.to_mesh(evaluated_mesh)
    bm.free()

    evaluated_obj.to_mesh_clear()
    return evaluated_mesh


def force_apply_modifiers(
    context: Context,
    obj: Object,
    *,
    preserve_shape_keys: bool,
    transform: Optional[Matrix] = None,
) -> Optional[Mesh]:
    if obj.type not in MESH_CONVERTIBLE_OBJECT_TYPES:
        return None

    evaluated_mesh = generate_evaluated_mesh(context, obj)
    if evaluated_mesh is None:
        return None

    if transform:
        evaluated_mesh.transform(transform)

    if not preserve_shape_keys:
        return evaluated_mesh

    obj_data = obj.data
    if obj_data is None:
        return evaluated_mesh

    if not isinstance(obj_data, Mesh):
        return evaluated_mesh

    shape_keys = obj_data.shape_keys
    if not shape_keys:
        return evaluated_mesh

    evaluated_mesh_shape_keys = evaluated_mesh.shape_keys
    if not evaluated_mesh_shape_keys:
        return evaluated_mesh

    # If the mesh has shape keys, reproduce them as much as possible
    for shape_key in shape_keys.key_blocks:
        evaluated_mesh_shape_key = evaluated_mesh_shape_keys.key_blocks.get(
            shape_key.name
        )
        if not evaluated_mesh_shape_key:
            continue

        if shape_key.name == shape_keys.reference_key.name:
            continue

        shape_key.value = 1.0
        context.view_layer.update()

        depsgraph = context.evaluated_depsgraph_get()
        baked_shape_key_obj = obj.evaluated_get(depsgraph)
        baked_shape_key_mesh = baked_shape_key_obj.to_mesh(
            preserve_all_data_layers=True, depsgraph=depsgraph
        )
        if baked_shape_key_mesh:
            if transform:
                baked_shape_key_mesh.transform(transform)

            evaluated_mesh_shape_key_data = evaluated_mesh_shape_key.data
            baked_shape_key_mesh_vertices = baked_shape_key_mesh.vertices

            # TODO: If the number of vertices is different, we should use advanced
            # graph matching algorithm.
            for i in range(
                min(
                    len(evaluated_mesh_shape_key_data),
                    len(baked_shape_key_mesh_vertices),
                )
            ):
                evaluated_mesh_shape_key_data[i].co = Vector(
                    baked_shape_key_mesh_vertices[i].co
                )

            baked_shape_key_obj.to_mesh_clear()
        shape_key.value = 0.0

    context.view_layer.update()
    return evaluated_mesh
