# SPDX-FileCopyrightText: 2019-2022 Blender Foundation
#
# SPDX-License-Identifier: GPL-2.0-or-later

import bpy
import random
import re
import zlib

from collections import defaultdict
from typing import TYPE_CHECKING, Sequence, Optional, Mapping, Iterable, Any

from bpy.types import bpy_prop_collection  # noqa
from bpy.types import Bone, UILayout, Object, PoseBone, Armature, BoneCollection, EditBone
from idprop.types import IDPropertyGroup
from rna_prop_ui import rna_idprop_value_to_python
from bpy.app.translations import pgettext_rpt as rpt_

from .errors import MetarigError
from .misc import ArmatureObject
from .naming import mirror_name_fuzzy

if TYPE_CHECKING:
    from ..base_rig import BaseRig
    from .. import RigifyBoneCollectionReference


ROOT_COLLECTION = "Root"
DEF_COLLECTION = "DEF"
ORG_COLLECTION = "ORG"
MCH_COLLECTION = "MCH"

SPECIAL_COLLECTIONS = (ROOT_COLLECTION, DEF_COLLECTION, MCH_COLLECTION, ORG_COLLECTION)

REFS_TOGGLE_SUFFIX = '_layers_extra'
REFS_LIST_SUFFIX = "_coll_refs"


def set_bone_layers(bone: Bone | EditBone, layers: Sequence[BoneCollection], *, combine=False):
    if not layers:
        return

    if not combine:
        for coll in list(bone.collections):
            coll.unassign(bone)

    for coll in layers:
        coll.assign(bone)


def union_layer_lists(lists: Iterable[Iterable[BoneCollection | None] | None]) -> list[BoneCollection]:
    all_collections = dict()

    for lst in lists:
        if lst is not None:
            all_collections.update({coll.name: coll for coll in lst if coll is not None})

    return list(all_collections.values())


def find_used_collections(obj: ArmatureObject) -> dict[str, BoneCollection]:
    bcoll_map = {}

    for bone in obj.data.bones:
        bcoll_map.update({coll.name: coll for coll in bone.collections})

    return bcoll_map


def is_collection_ref_list_prop(param: Any) -> bool:
    from .. import RigifyBoneCollectionReference

    return (isinstance(param, bpy_prop_collection) and
            all(isinstance(item, RigifyBoneCollectionReference) for item in param))


def copy_ref_list(to_ref_list, from_ref_list, *, mirror=False):
    """Copy collection references between two RigifyBoneCollectionReference lists."""
    to_ref_list.clear()

    for ref in from_ref_list:
        to_ref = to_ref_list.add()
        to_ref['uid'] = ref['uid']
        to_ref['name'] = ref['name']

        if mirror:
            to_ref.name = mirror_name_fuzzy(ref.name)


def ensure_collection_uid(bcoll: BoneCollection):
    """Retrieve the uid of the given bone collection, assigning a new one if necessary."""
    uid = bcoll.rigify_uid
    if uid >= 0:
        return uid

    # Choose the initial uid value
    max_uid = 0x7fffffff

    if re.fullmatch(r"Bones(\.\d+)?", bcoll.name):
        # Use random numbers for collections with the default name
        uid = random.randint(0, max_uid)
    else:
        uid = zlib.adler32(bcoll.name.encode("utf-8")) & max_uid

    # Ensure the uid is unique within the armature
    used_ids = set(coll.rigify_uid for coll in bcoll.id_data.collections_all)

    while uid in used_ids:
        uid = random.randint(0, max_uid)

    assert uid >= 0
    bcoll.rigify_uid = uid
    return uid


def resolve_collection_reference(obj: ArmatureObject, ref: Any, *,
                                 update=False, raise_error=False) -> bpy.types.BoneCollection | None:
    """
    Find the bone collection referenced by the given reference.
    The reference should be RigifyBoneCollectionReference, either typed or as a raw idproperty.
    """

    uid = ref["uid"]
    if uid < 0:
        return None

    arm = obj.data

    name = ref.get("name", "")
    name_coll = arm.collections_all.get(name) if name else None

    # First try an exact match of both name and uid
    if name_coll and name_coll.rigify_uid == uid:
        return name_coll

    # Then try searching by the uid
    for coll in arm.collections_all:
        if coll.rigify_uid == uid:
            if update:
                ref["name"] = coll.name
            return coll

    # Fall back to lookup by name only if possible
    if name_coll:
        if update:
            ref["uid"] = ensure_collection_uid(name_coll)
        return name_coll

    if raise_error:
        raise MetarigError(f"Broken bone collection reference: {name} #{uid}")

    return None


def validate_collection_references(obj: ArmatureObject):
    # Scan and update all references. This uses raw idprop access
    # to avoid depending on valid rig component definitions.
    refs = defaultdict(list)
    warnings = []

    for pose_bone in obj.pose.bones:
        params = pose_bone.get("rigify_parameters")
        if not params:
            continue

        for prop_name, prop_value in params.items():
            prop_name: str

            # Filter for reference list properties
            if not prop_name.endswith(REFS_LIST_SUFFIX):
                continue

            value = rna_idprop_value_to_python(prop_value)
            if not isinstance(value, list):
                continue

            for item in value:
                # Scan valid reference items
                if not isinstance(item, IDPropertyGroup):
                    continue

                name = item.get("name")
                if not name or item.get("uid", -1) < 0:
                    continue

                ref_coll = resolve_collection_reference(obj, item, update=True)

                if ref_coll:
                    refs[ref_coll.name].append(item)
                else:
                    stem = prop_name[:-len(REFS_LIST_SUFFIX)].replace("_", " ").title()
                    warnings.append(
                        rpt_("Bone {:s} has a broken reference to {:s} collection '{:s}'").format(
                            pose_bone.name, stem, name))
                    print(f"RIGIFY: {warnings[-1]}")

    # Ensure uids are unique
    known_uids = dict()

    for bcoll in obj.data.collections_all:
        uid = bcoll.rigify_uid
        if uid < 0:
            continue

        prev_use = known_uids.get(uid)

        if prev_use is not None:
            warnings.append(rpt_("Collection {:s} has the same uid {:d} as {:s}").format(bcoll.name, uid, prev_use))
            print(f"RIGIFY: {warnings[-1]}")

            # Replace the uid
            bcoll.rigify_uid = -1
            uid = ensure_collection_uid(bcoll)

            for ref in refs[bcoll.name]:
                ref["uid"] = uid

        known_uids[uid] = bcoll.name

    return warnings


##############################################
# UI utilities
##############################################


class ControlLayersOption:
    def __init__(self, name: str,
                 toggle_name: Optional[str] = None,
                 toggle_default=True, description="Set of bone collections"):
        self.name = name
        self.toggle_default = toggle_default
        self.description = description

        self.toggle_option = self.name + REFS_TOGGLE_SUFFIX
        self.refs_option = self.name + REFS_LIST_SUFFIX

        if toggle_name:
            self.toggle_name = toggle_name
        else:
            self.toggle_name = "Assign " + self.name.title() + " Collections"

    def get(self, params) -> Optional[list[BoneCollection]]:
        if getattr(params, self.toggle_option):
            result = []

            for ref in getattr(params, self.refs_option):
                coll = ref.find_collection(update=True, raise_error=True)

                if coll:
                    result.append(coll)

            if not result:
                bones = [pbone for pbone in params.id_data.pose.bones if pbone.rigify_parameters == params]
                print(f"RIGIFY: empty {self.name} layer list on bone {bones[0].name if bones else '?'}")

            return result
        else:
            return None

    def set(self, params, layers):
        if self.refs_option in params:
            del params[self.refs_option]

        setattr(params, self.toggle_option, layers is not None)

        if layers:
            items = getattr(params, self.refs_option)

            for coll in layers:
                item: RigifyBoneCollectionReference = items.add()
                item.set_collection(coll)

    def assign(self, params,
               bone_set: Object | Mapping[str, Bone | PoseBone],
               bone_list: Sequence[str], *,
               combine=False):
        layers = self.get(params)

        if isinstance(bone_set, Object):
            assert isinstance(bone_set.data, Armature)
            bone_set = bone_set.data.bones

        if layers:
            for name in bone_list:
                bone = bone_set[name]
                if isinstance(bone, PoseBone):
                    bone = bone.bone

                set_bone_layers(bone, layers, combine=combine)

    def assign_rig(self, rig: 'BaseRig', bone_list: Sequence[str], *, combine=False, priority=None):
        layers = self.get(rig.params)
        bone_set = rig.obj.data.bones

        if layers:
            for name in bone_list:
                set_bone_layers(bone_set[name], layers, combine=combine)

                if priority is not None:
                    rig.generator.set_layer_group_priority(name, layers, priority)

    def add_parameters(self, params):
        from .. import RigifyBoneCollectionReference

        prop_toggle = bpy.props.BoolProperty(
            name=self.toggle_name,
            default=self.toggle_default,
            description=""
        )

        setattr(params, self.toggle_option, prop_toggle)

        prop_coll_refs = bpy.props.CollectionProperty(
            type=RigifyBoneCollectionReference,
            description=self.description,
        )

        setattr(params, self.refs_option, prop_coll_refs)

    def parameters_ui(self, layout: UILayout, params):
        box = layout.box()

        row = box.row()
        row.prop(params, self.toggle_option)

        active = getattr(params, self.toggle_option)

        from ..operators.copy_mirror_parameters import make_copy_parameter_button
        from ..base_rig import BaseRig

        make_copy_parameter_button(row, self.refs_option, base_class=BaseRig, mirror_bone=True)

        if not active:
            return

        props = row.operator(operator="pose.rigify_collection_ref_add", text="", icon="ADD")
        props.prop_name = self.refs_option

        refs = getattr(params, self.refs_option)

        if len(refs):
            col = box.column(align=True)

            for i, ref in enumerate(refs):
                row = col.row(align=True)
                row.alert = ref.uid >= 0 and not ref.find_collection()
                row.prop(ref, "name", text="")
                row.alert = False

                props = row.operator(operator="pose.rigify_collection_ref_remove", text="", icon="REMOVE")
                props.prop_name = self.refs_option
                props.index = i
        else:
            box.label(text="Use the plus button to add list entries", icon="INFO")

    # Declarations for auto-completion
    FK: 'ControlLayersOption'
    TWEAK: 'ControlLayersOption'
    EXTRA_IK: 'ControlLayersOption'
    FACE_PRIMARY: 'ControlLayersOption'
    FACE_SECONDARY: 'ControlLayersOption'
    SKIN_PRIMARY: 'ControlLayersOption'
    SKIN_SECONDARY: 'ControlLayersOption'


ControlLayersOption.FK = ControlLayersOption(
    'fk', description="Layers for the FK controls to be on")
ControlLayersOption.TWEAK = ControlLayersOption(
    'tweak', description="Layers for the tweak controls to be on")

ControlLayersOption.EXTRA_IK = ControlLayersOption(
    'extra_ik', toggle_default=False,
    toggle_name="Extra IK Layers",
    description="Layers for the optional IK controls to be on",
)

# Layer parameters used by the super_face rig.
ControlLayersOption.FACE_PRIMARY = ControlLayersOption(
    'primary', description="Layers for the primary controls to be on")
ControlLayersOption.FACE_SECONDARY = ControlLayersOption(
    'secondary', description="Layers for the secondary controls to be on")

# Layer parameters used by the skin rigs
ControlLayersOption.SKIN_PRIMARY = ControlLayersOption(
    'skin_primary', toggle_default=False,
    toggle_name="Primary Control Layers",
    description="Layers for the primary controls to be on",
)

ControlLayersOption.SKIN_SECONDARY = ControlLayersOption(
    'skin_secondary', toggle_default=False,
    toggle_name="Secondary Control Layers",
    description="Layers for the secondary controls to be on",
)
