import bpy
from bpy.props import (FloatProperty,
                        BoolProperty,
                        EnumProperty,
                        StringProperty,
                        IntProperty)
from . import fn
import math
import re
import json
from pathlib import Path

## TODO : export json info to re-setup layers in AE automagically

def check_outname(ob, l):
    vl_name = l.viewlayer_render
    if vl_name in {'exclude', 'View Layer'}:
        return

    ng_name = f'NG_{ob.name}'
    ## check in wich node tree this exists
    for scn in bpy.data.scenes:
        # if scn.name == 'Scene':
        #     continue
        ng = scn.node_tree.nodes.get(ng_name)
        if ng:
            break
    print(scn.name)
    if not ng:
        print(f'Skip {vl_name}: Not found nodegroup {ng_name}' )
        return
    ng_socket = ng.outputs.get(vl_name)
    if not ng_socket:
        print(f'Skip {vl_name}: Not found in nodegroup {ng_name} sockets')
        return
    if not len(ng_socket.links):
        print(f' socket is disconnected in {ng_name} nodegroup')
        return
    fo_node = ng_socket.links[0].to_node
    fo_socket = ng_socket.links[0].to_socket
    if fo_node.type != 'OUTPUT_FILE':
        print(f'Skip {vl_name}: node is not an output_file {fo_node.name}')
        return

    # fo_socket.name isn't right, have to iterate in paths
    idx = [i for i in fo_node.inputs].index(fo_socket)
    subpath = fo_node.file_slots[idx].path
    # fp = Path(fo_node.base_path.rstrip('/')) / subpath
    # fp = Path(bpy.path.abspath(str(fp)).rstrip("/")) # abspath on disk
    outname = subpath.split('/')[0] # folder name on disk

    return outname

class GPEXP_OT_export_infos_for_compo(bpy.types.Operator):
    bl_idname = "gp.export_infos_for_compo"
    bl_label = "Export Infos For Compo"
    bl_description = "Export informations for compositing, including layers with masks, fusion mode, opacity"
    bl_options = {"REGISTER"}

    # @classmethod
    # def poll(cls, context):
    #     return context.object and context.object.type == 'GPENCIL'

    skip_check : bpy.props.BoolProperty(default=False, options={'SKIP_SAVE'})

    def invoke(self, context, event):
        self.l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json'
        if self.skip_check:
            if self.l_infos.exists():
                # Preferably skip if already user defined ?
                return {'FINISHED'}
            return self.execute(context)

        if self.l_infos.exists():
            return context.window_manager.invoke_props_dialog(self)
        return self.execute(context)

    def draw(self, context):
        layout = self.layout
        layout.label(text='An infos Json already exists', icon = 'ERROR')
        layout.label(text='Do you want to overwrite ?')
        layout.label(text='Note: Must export before "Check Layers" step', icon='INFO')

    def execute(self, context):
        ## Repeat because might not be registered if called with invoke_default
        self.l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json'
        dic = {}
        pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]
        for o in pool:
            # if not o.visible_get():
            #     continue
            for l in o.data.layers:
                # skip non rendered layers
                if l.hide:
                    continue

                if l.info.startswith('MA_'):
                    # No point in storing information of masking layers...
                    continue

                ## Can't check viewlayers and final fileout name if Render scene not even created...
                """ if not l.viewlayer_render or l.viewlayer_render == 'exclude':
                    continue

                fo_name = check_outname(o, l) # get name used for output file folder (same in AE)
                if not fo_name:
                    print(f'! Could not found fileout name for {o.name} > {l.info}')
                    continue
                """

                ldic = {}
                ## Check opacity, blend mode
                if l.opacity < 1.0:
                    ldic['opacity'] = l.opacity

                if l.blend_mode != 'REGULAR':
                    ldic['blend_mode'] = l.blend_mode

                if l.use_mask_layer:

                    multi_mask = {}

                    ## dict key as number for masks
                    # for i, ml in enumerate(l.mask_layers):
                    #     mask = {}
                    #     if ml.hide:
                    #         continue
                    #     mask['name'] = ml.name
                    #     if ml.invert: # create key get only if inverted
                    #         mask['invert'] = ml.invert
                    #     # multi_mask[ml.name] = mask
                    #     multi_mask[i] = mask

                    ## dict key as mask name
                    for ml in l.mask_layers:
                        mask = {}
                        if ml.hide:
                            continue
                        # mask['name'] = ml.name
                        if ml.invert: # create key get only if inverted
                            mask['invert'] = ml.invert # ! no key if no invert
                        multi_mask[ml.name] = mask

                    if multi_mask:
                        ldic['masks'] = multi_mask

                ## add to full dic
                if ldic:
                    # add source object ? might be usefull to pin point layer
                    ldic['object'] = o.name
                    dic[fn.normalize_layer_name(l, get_only=True)] = ldic

        if dic:
            self.l_infos.parent.mkdir(exist_ok=True) # create render folder if needed
            with self.l_infos.open('w') as fd:
                json.dump(dic, fd, indent='\t')
            self.report({'INFO'}, f'Exported json at: {self.l_infos.as_posix()}')
        else:
            self.report({'WARNING'}, f'No custom data to write with those objects/layers')
            return {"CANCELLED"}

        return {"FINISHED"}


class GPEXP_OT_restore_layers_state(bpy.types.Operator):
    bl_idname = "gp.restore_layers_state"
    bl_label = "Restore Layers State "
    bl_description = "Restore original state of layers"
    bl_options = {"REGISTER"} # , "UNDO"

    def execute(self, context):
        layers_info_path = Path(bpy.data.filepath).parent / 'render' / 'infos.json'
        if not layers_info_path.exists():
            self.report({"ERROR"}, 'No Info Found')
            return {"CANCELLED"}
        
        layers_info = json.loads(layers_info_path.read_text(encoding='utf-8'))

        pool = [o for o in context.selected_objects if o.type == 'GPENCIL']
        for gp in pool:
            for layer_name, info in layers_info.items():
                if gp.name == info['object']:
                    if not (layer := gp.data.layers.get(layer_name)):
                        return
                    
                    if "opacity" in info:
                        layer.opacity = info['opacity']

                    if "blend_mode" in info:
                        layer.blend_mode = info['blend_mode']

        return {"FINISHED"}


class GPEXP_OT_layers_state(bpy.types.Operator):
    bl_idname = "gp.layers_state"
    bl_label = "Set Layers State"
    bl_description = "Display state of layer that migh need adjustement"
    bl_options = {"REGISTER"} # , "UNDO"

    # clear_unused_view_layers :BoolProperty(name="Clear unused view layers",
    #     description="Delete view layer that aren't used in the nodetree anymore",
    #     default=True)

    no_popup : BoolProperty(name='No Popup',
        default=False, description='To use for call in CLI or from other operators', options={'SKIP_SAVE'})

    all_objects : BoolProperty(name='On All Object',
        default=True, description='On All object, else use selected objects') # , options={'SKIP_SAVE'}

    set_full_opacity : BoolProperty(name='Set Full Opacity',
        default=True, description='Check/Set full opacity') # , options={'SKIP_SAVE'}

    set_use_lights : BoolProperty(name='Disable Use Light',
        default=True, description='Check/Set use lights disabling') # , options={'SKIP_SAVE'}

    set_blend_mode : BoolProperty(name='Set Regular Blend Mode',
        default=True, description='Check/Set blend mode to regular') # , options={'SKIP_SAVE'}

    clear_frame_out_of_range : BoolProperty(name='Clear Frames Out Of Scene Range',
        default=False, description='Delete frames that before scene start and after scene end range\
            \nWith a tolerance of one frame to avoid problem\
            \nAffect all layers)') # , options={'SKIP_SAVE'}

    opacity_exclude_list : StringProperty(name='Skip',
        default='MA, MASK, mask, MSK, msk', description='Skip prefixes from this list when changing opacity\
            \nSeparate multiple value with a comma (ex: MAIN)') # , options={'SKIP_SAVE'}

    hide_invisible_materials : BoolProperty(name='Hide Materials named invisible',
        default=True, description='Hide material with name starting with "invisible"') # , options={'SKIP_SAVE'}

    # @classmethod
    # def poll(cls, context):
    #     return context.object

    def invoke(self, context, event):
        ## if no existing infos.json generated, call ops
        l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json'
        if not l_infos.exists(): # only if infos not created
            bpy.ops.gp.export_infos_for_compo('INVOKE_DEFAULT')

        if self.no_popup:
            return self.execute(context)
        return context.window_manager.invoke_props_dialog(self)

    def draw(self, context):
        layout = self.layout
        layout.prop(self, 'all_objects')
        total = len([o for o in context.scene.objects if o.type == 'GPENCIL'])

        target_num = total if self.all_objects else len([o for o in context.selected_objects if o.type == 'GPENCIL'])
        layout.label(text=f'{target_num}/{total} targeted GP')

        layout.separator()
        layout.prop(self, 'clear_frame_out_of_range')
        layout.separator()
        layout.label(text='Set (or only perform a check):')
        row = layout.row()
        row.prop(self, 'set_full_opacity')
        if self.set_full_opacity:
            row.prop(self, 'opacity_exclude_list')
        layout.prop(self, 'set_use_lights')
        layout.prop(self, 'set_blend_mode')
        layout.prop(self, 'hide_invisible_materials')
        # layout.prop(self, 'clear_unused_view_layers')

    def execute(self, context):
        if self.all_objects:
            pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]
        else:
            pool = [o for o in context.selected_objects if o.type == 'GPENCIL']
            # pool = [context.object]

        changes = []
        for ob in pool:
            changes.append(f'>> {ob.name}')
            layers = ob.data.layers

            if self.clear_frame_out_of_range:
                ct = fn.clear_frame_out_of_range(ob, verbose=False)
                if ct:
                    changes.append(f'{ct} out of range frame deleted')

            for l in layers:
                used = False

                ## mask check
                # if l.mask_layers:
                #     print(f'-> masks')
                #     state = '' if l.use_mask_layer else ' (disabled)'
                #     print(f'{ob.name} > {l.info}{state}:')
                #     used = True
                #     for ml in l.mask_layers:
                #         mlstate = ' (disabled)' if ml.hide else ''
                #         mlinvert = ' <>' if ml.invert else ''
                #         print(f'{ml.info}{mlstate}{mlinvert}')


                if l.opacity != 1:
                    if l.opacity == 0:
                        ## Skip layer with zero opacity
                        print(f'Skipped layer opacity reset (0 opacity) : {l.info}')

                    elif any(x.strip() + '_' in l.info for x in self.opacity_exclude_list.strip(',').split(',') if x):
                        # Skip layer if name has exclusion prefix
                        print(f'Skipped layer opacity reset (prefix in exclusion list) : {l.info}')
                    else:
                        ## Set full opacity
                        full_opacity_state = '' if self.set_full_opacity else ' (check only)'
                        mess = f'{l.info} : opacity {l.opacity:.2f} >> 1.0{full_opacity_state}'
                        print(mess)
                        changes.append(mess)
                        if self.set_full_opacity:
                            l.opacity = 1.0
                        used = True

                if l.use_lights:
                    use_lights_state = '' if self.set_use_lights else ' (check only)'
                    mess = f'{l.info} : disable use lights{use_lights_state}'
                    print(mess)
                    # changes.append(mess) # don't report disable use_light... too many messages
                    if self.set_use_lights:
                        l.use_lights = False
                    used = True

                if l.blend_mode != 'REGULAR':
                    blend_mode_state = '' if self.set_blend_mode else ' (check only)'
                    mess = f'{l.info} : blend mode "{l.blend_mode}" >> regular{blend_mode_state}'
                    print(mess)
                    changes.append(mess)
                    if self.set_blend_mode:
                        l.blend_mode = 'REGULAR'
                    used = True

                if len(l.frames) == 1 and len(l.frames[0].strokes) == 0 and not l.hide:
                    # probably used as separator
                    l.hide = True
                    mess = f'{l.info} : No frames. Hiding layer'
                    print(mess)
                    changes.append(mess)
                    used = True

                if used:
                    print()
            if changes:
                changes.append('')

        ## Disable multiframe editing on all GP (can cause artifacts on render)
        gp_mu_edit_ct = 0
        for gp in bpy.data.grease_pencils:
            if gp.use_multiedit:
                print(f'Disabling multi-edit on GP {gp.name}')
                gp.use_multiedit = False
                gp_mu_edit_ct += 1

        if gp_mu_edit_ct:
            changes.append(f'{gp_mu_edit_ct} multiframe-edit mode disabled')

        ## Hide invisible named materials
        if self.hide_invisible_materials:
            for m in bpy.data.materials:
                if m.is_grease_pencil and m.name.lower().startswith('invisible'):
                    if not m.grease_pencil.hide:
                        print(f'Hiding gp material {m.name}')
                        m.grease_pencil.hide = True
                        changes.append(f'{m.name} material hidden')

        if not self.no_popup:
            fn.show_message_box(_message=changes, _title="Layers Check Report", _icon='INFO')

        return {"FINISHED"}


class GPEXP_OT_lower_layers_name(bpy.types.Operator):
    bl_idname = "gp.lower_layers_name"
    bl_label = "Normalize Layers Name"
    bl_description = "Make the object and layers name lowercase with dashed converted to underscore (without touching layer prefix and suffix)"
    bl_options = {"REGISTER", "UNDO"}

    # @classmethod
    # def poll(cls, context):
    #     return context.object and context.object.type == 'GPENCIL'

    all_objects : BoolProperty(name='On All Object',
        default=True, description='On All object, else use selected objects') # , options={'SKIP_SAVE'}

    object_name : BoolProperty(name='Normalize Object Name',
        default=True, description='Make the object name lowercase') # , options={'SKIP_SAVE'}

    layer_name : BoolProperty(name='Normalize Layers Names',
        default=True, description='Make the layers name lowercase') # , options={'SKIP_SAVE'}

    # dash_to_undescore : BoolProperty(name='Dash To Underscore',
    #     default=True, description='Make the layers name lowercase') # , options={'SKIP_SAVE'}

    def invoke(self, context, event):
        # self.ctrl=event.ctrl
        # self.alt=event.alt
        if event.alt:
            self.all_objects=True
        # return self.execute(context)
        return context.window_manager.invoke_props_dialog(self)

    def draw(self, context):
        layout = self.layout
        layout.prop(self, 'all_objects')
        if self.all_objects:
            gp_ct = len([o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)])
        else:
            gp_ct = len([o for o in context.selected_objects if o.type == 'GPENCIL'])

        layout.label(text=f'{gp_ct} to lower-case')
        layout.separator()
        layout.label(text=f'Choose what to rename:')
        layout.prop(self, 'object_name')
        layout.prop(self, 'layer_name')
        # if self.layer_name:
        #     box = layout.box()
        #     box.prop(self, 'dash_to_undescore')

        if not self.object_name and not self.layer_name:
            layout.label(text=f'At least one choice!', icon='ERROR')

    def execute(self, context):
        if self.all_objects:
            pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]
        else:
            pool = [o for o in context.selected_objects if o.type == 'GPENCIL']

        for ob in pool:

            if self.object_name:
                rename_data = ob.name == ob.data.name
                ob.name = ob.name.lower().replace('-', '_')
                if rename_data:
                    ob.data.name = ob.name

            if self.layer_name:
                for l in ob.data.layers:
                    # if self.dash_to_undescore:
                    l.info = l.info.replace('-', '_')
                    fn.normalize_layer_name(l) # default : lower=True, dash_to_underscore=self.dash_to_undescore

        return {"FINISHED"}


class GPEXP_OT_auto_number_object(bpy.types.Operator):
    bl_idname = "gp.auto_number_object"
    bl_label = "Auto Number Object"
    bl_description = "Automatic prefix number based on origin distance to camera and in_front values\nCtrl + Clic to delete name to delete numbering"
    bl_options = {"REGISTER", "UNDO"}

    # @classmethod
    # def poll(cls, context):
    #     return context.object and context.object.type == 'GPENCIL'

    all_objects : BoolProperty(name='On All GP Object',
        default=True, description='On All object, else use selected Grease Pencil objects') # , options={'SKIP_SAVE'}

    rename_data : BoolProperty(name='Rename Gpencil Data',
        default=True, description='Rename Also the Grease Pencil data using same name as object') # , options={'SKIP_SAVE'}

    delete : BoolProperty(default=False, options={'SKIP_SAVE'})

    def invoke(self, context, event):
        # if event.alt:
        #     self.all_objects=True
        if event.ctrl or self.delete:
            regex_num = re.compile(r'^(\d{3})_')
            ct = 0
            gps = [o for o in context.selected_objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]
            for o in gps:
                if regex_num.match(o.name):
                    o.name = o.name[4:]
                    ct += 1
                self.report({'INFO'}, f'{ct}/{len(gps)} number prefix removed from object names')

            return {"FINISHED"}

        return context.window_manager.invoke_props_dialog(self)

    def draw(self, context):
        layout = self.layout
        layout.prop(self, 'all_objects')
        if self.all_objects:
            gp_ct = len([o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)])
        else:
            gp_ct = len([o for o in context.selected_objects if o.type == 'GPENCIL'])

        layout.prop(self, 'rename_data')
        layout.label(text=f'{gp_ct} objects to renumber')
        if not gp_ct:
            layout.label(text='No Gpencil object to renumber', icon = 'ERROR')

    def execute(self, context):
        if self.all_objects:
            pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]
        else:
            pool = [o for o in context.selected_objects if o.type == 'GPENCIL']

        def reversed_enumerate(collection: list):
            for i in range(len(collection)-1, -1, -1):
                yield i, collection[i]

        fronts = []

        ## separate In Front objects:

        for i, o in reversed_enumerate(pool):
            if o.show_in_front:
                fronts.append(pool.pop(i))

        cam_loc = context.scene.camera.matrix_world.to_translation()

        # filter by distance to camera object (considering origins)
        pool.sort(key=lambda x: math.dist(x.matrix_world.to_translation(), cam_loc))
        fronts.sort(key=lambda x: math.dist(x.matrix_world.to_translation(), cam_loc))
        # re-insert fitlered infront object before others
        pool = fronts + pool

        ct = 10
        regex_num = re.compile(r'^(\d{3})_')
        for o in pool:
            renum = regex_num.search(o.name)

            if not renum:
                o.name = f'{str(ct).zfill(3)}_{o.name}'

            else:
                ## either replace or leave untouched
                # continue
                o.name =  f'{str(ct).zfill(3)}_{o.name[4:]}'

            ct += 10
            if self.rename_data and o.name != o.data.name:
                o.data.name = o.name

        return {"FINISHED"}


class GPEXP_OT_check_masks(bpy.types.Operator):
    bl_idname = "gp.check_masks"
    bl_label = "Check Masks"
    bl_description = "Check and report all masked GP layers"
    bl_options = {"REGISTER", "UNDO"}

    @classmethod
    def poll(cls, context):
        return context.object and context.object.type == 'GPENCIL'

    def execute(self, context):
        # if self.all_objects:
        #     pool = [o for o in context.scene.objects if o.type == 'GPENCIL']
        # else:
        #     pool = [o for o in context.selected_objects if o.type == 'GPENCIL']
        changes = []
        pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]
        for o in pool:
            for l in o.data.layers:
                if l.use_mask_layer:
                    obj_stat = f'{o.name} >>'
                    if not obj_stat in changes:
                        changes.append(obj_stat)
                        print(obj_stat)

                    hide_state = ' (hided)' if l.hide else ''
                    text = f'  {l.info}{hide_state}:' # :masks:
                    changes.append(text)
                    print(text)

                    has_masks = False
                    for ml in l.mask_layers:
                        # 'hide', 'invert', 'name'
                        h = ' hided' if ml.hide else ''
                        i = ' (inverted)' if ml.invert else ''
                        text = f'   - {ml.name}{h}{i}'
                        changes.append(text)
                        print(text)
                        has_masks = True

                    if not has_masks:
                        text = 'No masks!'
                        changes.append(text)
                        print(text)
                    changes.append('')

        if changes:
            fn.show_message_box(_message=changes, _title="Masks Check Report", _icon='INFO')
        else:
            fn.show_message_box(_message='No Masks!', _title="Masks Check Report", _icon='INFO')

        return {"FINISHED"}

class GPEXP_OT_select_layer_in_comp(bpy.types.Operator):
    bl_idname = "gp.select_layer_in_comp"
    bl_label = "Select Layer In Compositor"
    bl_description = "Select associated render_layer node in compositing"
    bl_options = {"REGISTER", "UNDO"}

    @classmethod
    def poll(cls, context):
        return context.object and context.object.type == 'GPENCIL'

    
    def invoke(self, context, event):
        self.additive = event.shift
        return self.execute(context)

    def execute(self, context):
        gp = context.object.data
        act = gp.layers.active
        pool = fn.build_layers_targets_from_dopesheet(context)
        if not pool:
            self.report({'ERROR'}, 'No layers found in current GP dopesheet')
            return {"CANCELLED"}

        if not context.scene.node_tree:
            self.report({'ERROR'}, 'No compo node-tree in active scene')
            return {"CANCELLED"}

        scn = context.scene
        node_scene = fn.get_compo_scene(create=False) or scn
        nodes = node_scene.node_tree.nodes
        rl_nodes = [n for n in nodes if n.type == 'R_LAYERS']
        if not rl_nodes:
            self.report({'ERROR'}, 'No render layers nodes in active scene')
            return {"CANCELLED"}

        # Deselect all nodes if shift is not pressed
        if not self.additive:
            for n in nodes:
                n.select = False

        used_vl = [n.layer for n in rl_nodes]
        selected = []
        infos = []

        for l in pool:
            if not l.select:
                continue
            vl_name = l.viewlayer_render
            if not vl_name:
                mess = f'{l.info} has no viewlayers'
                print(mess)
                infos.append(mess)
                continue

            if not vl_name in used_vl:
                mess = f'{l.info}: view layer "{vl_name}" not used in scene renderlayer nodes'
                print(mess)
                infos.append(mess)
                continue

            for n in rl_nodes:
                if n.layer == vl_name:
                    print(f'{l.info} -> Select node {n.name}')
                    selected.append(n.name)
                    n.select = True

        if not infos and not selected:
            self.report({'ERROR'}, 'Nothing selected')
            return {"CANCELLED"}

        infos = infos + [f'-- Selected {len(selected)} nodes --']  + selected
        fn.show_message_box(_message=infos, _title="Selected viewlayer in compo", _icon='INFO')

        # Change viewed scene if not in current scene
        if selected and scn != node_scene:
            context.window.scene = node_scene
        return {"FINISHED"}

classes=(
GPEXP_OT_auto_number_object,
GPEXP_OT_lower_layers_name,
GPEXP_OT_export_infos_for_compo,
GPEXP_OT_restore_layers_state,
GPEXP_OT_layers_state,
GPEXP_OT_check_masks,
GPEXP_OT_select_layer_in_comp,
)

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)