import bpy
import os
import re
import json

from mathutils import Vector
from pathlib import Path
from itertools import groupby
from math import isclose
from collections import defaultdict
from time import time

from . constant import RD_SCENE_NAME

### -- rules

def is_valid_name(name):
    '''return True if name correspond to a valid object
    Don't start with a dot '.'
    Is not "note"
    '''

    if name.startswith('.'):
        return False

    ## FIXME: /!\ "note" as an exclude word is not good practice, temporary fix
    if name.lower() == 'note':
        return False

    return True

### -- node basic

def create_node(type, tree=None, **kargs):
    '''Get a type, a tree to add in, and optionnaly multiple attribute to set
    return created node
    '''
    tree = tree or bpy.context.scene.node_tree

    node = tree.nodes.new(type)
    for k,v in kargs.items():
        setattr(node, k, v)

    return node

def new_aa_node(tree, **kargs):
    '''create AA node'''
    aa = create_node('CompositorNodeAntiAliasing', tree) # type = ANTIALIASING
    aa.threshold = 1.0 # 0.5
    aa.contrast_limit = 0.25 # 0.5
    aa.corner_rounding = 0.25
    aa.hide = True

    for k,v in kargs.items():
        setattr(aa, k, v)
    return aa

def create_aa_nodegroup(tree):
    ngroup = bpy.data.node_groups.get('AA')
    if not ngroup:
        ngroup = bpy.data.node_groups.new('AA', 'CompositorNodeTree')
        ng_in = create_node('NodeGroupInput', tree=ngroup, location=(-600,0))
        ng_out = create_node('NodeGroupOutput', tree=ngroup, location=(600,0))

        sep = create_node('CompositorNodeSepRGBA', tree=ngroup, location=(-150,0))
        comb = create_node('CompositorNodeCombRGBA', tree=ngroup, location=(350,25))

        if bpy.app.version < (4,0,0):
            ngroup.inputs.new('NodeSocketColor', 'Image')
            ngroup.outputs.new('NodeSocketColor', 'Image')
        else:
            ngroup.interface.new_socket('Image', in_out='INPUT', socket_type='NodeSocketColor')
            ngroup.interface.new_socket('Image', in_out='OUTPUT', socket_type='NodeSocketColor')

        # in AA
        # ngroup.links.new(comb.outputs[0], ng_out.inputs[0]) # <- connect without out AA
        aa = new_aa_node(ngroup, location=(-400, 0))
        # ngroup.links.new(ng_in.outputs[0], sep.inputs[0])
        ngroup.links.new(ng_in.outputs[0], aa.inputs[0])
        ngroup.links.new(aa.outputs[0], sep.inputs[0])

        # ngroup.links.new(ng_in.outputs[0], sep.inputs[0])
        for i in range(3):
            ngroup.links.new(sep.outputs[i], comb.inputs[i])

        # alpha AA
        alpha_aa = new_aa_node(ngroup, location=(100,-150))
        ngroup.links.new(sep.outputs[3], alpha_aa.inputs[0])
        ngroup.links.new(alpha_aa.outputs[0], comb.inputs[3])

        ngroup.links.new(comb.outputs[0], ng_out.inputs[0])



    ng = create_node('CompositorNodeGroup', tree=tree)
    ng.node_tree = ngroup
    ng.name = ngroup.name
    ng.hide=True
    return ng


## -- object and scene settings

def activate_workspace(name='', context=None):
    if not name:
        return
    if context is None:
        context = bpy.context

    if context.window.workspace.name == name:
        print(f'Already in {name} workspace')
        return

    if (wkspace := bpy.data.workspaces.get(name)):
        context.window.workspace = wkspace
        return True

    # Same name with spaces as underscore
    dir_name = name.replace(' ', '_')
    filepath = Path(__file__).parent / 'app_templates' / dir_name / 'startup.blend'

    ret = bpy.ops.workspace.append_activate(idname=name, filepath=str(filepath))
    if ret != {'FINISHED'}:
        print(f'Could not found {name} at {filepath}')
        return False

    return context.window.workspace

def copy_settings(obj_a, obj_b):
    exclusion = ['bl_rna', 'id_data', 'identifier','name_property','rna_type','properties', 'stamp_note_text','use_stamp_note',
     'settingsFilePath', 'settingsStamp', 'select', 'matrix_local', 'matrix_parent_inverse',
     'matrix_basis','location','rotation_euler', 'rotation_quaternion', 'rotation_axis_angle', 'scale']

    for attr in dir(obj_a):
        if attr.startswith('__'):
            continue
        if attr in exclusion:
            continue
        # print('attr: ', attr)
        # if obj_a.is_property_readonly(attr): # block when things aren't attribute
        #     continue
        try:
            val = getattr(obj_a, attr)
        except AttributeError:
            # print(f'cant get {attr}')
            pass

        try:
            setattr(obj_b, attr, val)
        except:
            # print(f"can't set {attr}")
            pass

def set_file_output_format(fo):

    env_file_format = json.loads(os.environ.get('GP_RENDER_FILE_FORMAT', '{}'))
    if not env_file_format:
        env_file_format = {
            'file_format': 'OPEN_EXR_MULTILAYER',
            'exr_codec': 'ZIP',
            'color_depth': '16',
            'color_mode': 'RGBA'
        }

    for k, v in env_file_format.items():
        setattr(fo.format, k, v)

def set_scene_aa_settings(scene=None, aa=True):
    '''aa == using native AA, else disable scene AA'''
    if not scene:
        scene = bpy.context.scene
    if aa:
        scene.eevee.taa_render_samples = 32
        scene.grease_pencil_settings.antialias_threshold = 1
    else:
        scene.eevee.taa_render_samples = 1
        scene.grease_pencil_settings.antialias_threshold = 0

def set_settings(scene=None, aa=True):
    '''aa == using native AA, else disable scene AA'''
    if not scene:
        scene = bpy.context.scene

    # specify scene settings for these kind of render
    set_scene_aa_settings(scene=scene, aa=aa)

    scene.render.film_transparent = True
    scene.render.use_compositing = True
    scene.render.use_sequencer = False
    scene.view_settings.view_transform = 'Standard'

    scene.render.resolution_percentage = 100

    # output (fast write settings since this is just to delete afterwards...)
    scene.render.filepath = f'//render/preview/{scene.name}/preview_'
    im_settings = scene.render.image_settings
    # im_settings.file_format = 'JPEG'
    # im_settings.color_mode = 'RGB'
    # im_settings.quality = 0

    ## Same as output nodes
    im_settings.file_format = 'OPEN_EXR'
    im_settings.color_mode = 'RGBA'
    im_settings.color_depth = '16'
    im_settings.exr_codec = 'ZIP'

def scene_aa(scene=None, toggle=True):
    '''Change scene AA settings and commute AA nodes according to toggle'''
    if not scene:
        scene=bpy.context.scene

    # Enable/disable native anti-alias on active scene
    set_scene_aa_settings(scene=scene, aa=toggle)

    ## Set AA on scene where object and viewlayers exists
    local_nodes = scene.node_tree.nodes
    if (group_node := next((n for n in local_nodes if n.name.startswith('NG_')), None)):
        # Get a viewlayer connected to a NG_ and check which scene is referred
        if (rlayer := next((i.links[0].from_node for i in group_node.inputs if i.links and i.links[0].from_node.type == 'R_LAYERS'), None)):
            if rlayer.scene and rlayer.scene != scene:
                print(f'Set AA to {toggle} on scene "{rlayer.scene.name}"')
                set_scene_aa_settings(scene=rlayer.scene, aa=toggle)

    ## Mute/Unmute AA nodegroups
    for n in local_nodes:
        if n.type == 'GROUP' and n.name.startswith('NG_'):
            # n.mute = False # mute whole nodegroup ?
            for gn in n.node_tree.nodes:
                if gn.type == 'GROUP' and gn.node_tree.name == 'AA':
                    gn.mute = toggle

def new_scene_from(name, src_scn=None, regen=True, crop=True, link_cam=True, link_light=True):
    '''Get / Create a scene from name and source scene to get settings from'''
    scn = bpy.data.scenes.get(name)
    if scn and not regen:
        return scn
    elif scn and regen:
        bpy.data.scenes.remove(scn)

    src_scn = src_scn or bpy.context.scene # given scene, or active scene
    scn = bpy.data.scenes.new(name)
    ## copy original settings over to new scene
    # copy_settings(current, scn) # BAD
    for attr in ['frame_start', 'frame_end', 'frame_current', 'camera', 'world']:
        setattr(scn, attr, getattr(src_scn, attr))
    copy_settings(src_scn.render, scn.render)

    ## link cameras (and lights ?)
    for ob in src_scn.objects:
        if link_cam and ob.type == 'CAMERA':
            scn.collection.objects.link(ob)
        if link_light and ob.type == 'LIGHT':
            scn.collection.objects.link(ob)

    # set adapted render settings
    set_settings(scn)

    if crop:
        scn.render.use_border = True
        scn.render.use_crop_to_border = True
    scn.use_nodes = True
    return scn

def get_compo_scene(scene_name=None, create=True):
    '''Get / Create a dedicated compositing scene to link GP
    use passed scene name, if no name is passed fall back to compo_scene propertie
    return None if field is empty'''

    scene_name = scene_name or bpy.context.scene.gp_render_settings.node_scene
    if not scene_name:
        # return None or render scene
        return

    scn = bpy.data.scenes.get(scene_name)
    if scn:
        return scn

    if not create:
        return

    ## -- Create render scene
    current = bpy.context.scene

    ## With data
    scn = bpy.data.scenes.new(scene_name)

    ## copy original settings over to new scene
    for attr in ['frame_start', 'frame_end', 'frame_current']: # , 'camera', 'world'
        setattr(scn, attr, getattr(current, attr))
    copy_settings(current.render, scn.render)

    ## Copy markers
    # for marker in current.timeline_markers:
    #     new_marker = scn.timeline_markers.new(marker.name, frame=marker.frame)
    #     new_marker.camera = marker.camera

    scn.use_nodes = True

    ## Clear node tree
    scn.node_tree.nodes.clear()
    set_settings(scn)
    scn.gp_render_settings['use_aa'] = True
    # Set compo scene target in it's own property as well
    scn.gp_render_settings.node_scene = scene_name
    return scn

def get_render_scene(scene_name=None, create=True):
    '''Get / Create a dedicated render scene to link GP'''

    scene_name = scene_name or RD_SCENE_NAME

    render_scn = bpy.data.scenes.get(scene_name)
    if render_scn:
        return render_scn
    
    if not create:
        return

    ## -- Create render scene
    current = bpy.context.scene

    ## With data
    render_scn = bpy.data.scenes.new(scene_name)

    ## copy original settings over to new scene
    # copy_settings(current, render_scn) # BAD
    for attr in ['frame_start', 'frame_end', 'frame_current', 'camera', 'world']:
        setattr(render_scn, attr, getattr(current, attr))
    copy_settings(current.render, render_scn.render)

    ## link cameras (and lights ?)
    for ob in current.objects:
        if ob.type in ('CAMERA', 'LIGHT'):
            render_scn.collection.objects.link(ob)

    ## Copy markers
    for marker in current.timeline_markers:
        new_marker = render_scn.timeline_markers.new(marker.name, frame=marker.frame)
        new_marker.camera = marker.camera

    render_scn.use_nodes = True

    ## Clear node tree (initial view layer stuff)
    render_scn.node_tree.nodes.clear()
    # for n in reversed(render_scn.node_tree.nodes):
    #     render_scn.node_tree.nodes.remove(n)

    set_settings(render_scn)
    render_scn.gp_render_settings['use_aa'] = True
    return render_scn

def get_view_layer(name, scene=None):
    '''get viewlayer name
    return existing/created viewlayer
    '''

    scene = scene or get_render_scene()

    ### pass double letter prefix as suffix
    ## pass_name = re.sub(r'^([A-Z]{2})(_)(.*)', r'\3\2\1', 'name')
    ## pass_name = f'{name}_{passe}'
    pass_vl = scene.view_layers.get(name)
    if not pass_vl:
        pass_vl = scene.view_layers.new(name)
        pass_vl.use_pass_z = True
    return pass_vl

def set_resolution_from_cam_prop(cam=None, scene=None):
    if scene is None:
        scene = bpy.context.scene
    if not cam:
        cam = scene.camera
    if not cam:
        return ('ERROR', 'No active camera')

    res = cam.get('resolution')
    if not res:
        return ('ERROR', 'Cam has no resolution attribute')

    rd = scene.render
    if rd.resolution_x == res[0] and rd.resolution_y == res[1]:
        return ('INFO', f'Resolution already at {res[0]}x{res[1]}')
    else:
        rd.resolution_x, rd.resolution_y = res[0], res[1]
        return ('INFO', f'Resolution to {res[0]}x{res[1]}')

## -- node location tweaks

def real_loc(n):
    if not n.parent:
        return n.location
    return n.location + real_loc(n.parent)

def get_frame_transform(f, node_tree=None):
    '''Return real transform location of a frame node
    only works with one level of nesting (not recursive)
    '''
    if not node_tree:
        node_tree = f.id_data
    if f.type != 'FRAME':
        return
        # return real_loc(f), f.dimensions

    childs = [n for n in node_tree.nodes if n.parent == f]
    if not childs:
        return f.location, f.dimensions

    # real_locs = [f.location + n.location for n in childs]

    xs = [n.location.x for n in childs] + [n.location.x + n.dimensions.x for n in childs]
    ys = [n.location.y for n in childs] + [n.location.y - n.dimensions.y for n in childs]
    xs.sort(key=lambda loc: loc) # x val : ascending
    ys.sort(key=lambda loc: loc) # ascending # , reversed=True) # y val : descending

    loc = Vector((min(xs), max(ys)))
    dim = Vector((max(xs) - min(xs) + 60, max(ys) - min(ys) + 60))

    return loc, dim


## -- get all frames with their real transform.

def bbox(f, frames):
    xs=[]
    ys=[]
    for n in frames[f]: # nodes of passed frame
        # Better as Vectors ?
        if n.type == 'FRAME':
            if n not in frames.keys():
                # print(f'frame {n.name} not in frame list')
                continue
            all_xs, all_ys = bbox(n, frames) # frames[n]
            xs += all_xs
            ys += all_ys

        else:
            loc = real_loc(n)
            xs += [loc.x, loc.x + n.dimensions.x] # + (n.dimensions.x/get_dpi_factor())
            ys += [loc.y, loc.y - n.dimensions.y] # - (n.dimensions.y/get_dpi_factor())


    # margin ~= 30
    # return xs and ys
    return [min(xs)-30, max(xs)+30], [min(ys)-30, max(ys)+30]

def get_frames_bbox(node_tree):
    '''Return a dic with all frames
    ex: {frame_node: (location, dimension), ...}
    '''

    # create dic of frame object with his direct child nodes nodes
    frames = defaultdict(list)
    frames_bbox = {}
    for n in node_tree.nodes:
        if not n.parent:
            continue
        # also contains frames
        frames[n.parent].append(n)

    # Dic for bbox coord
    for f, nodes in frames.items():
        if f.parent:
            continue

        xs, ys = bbox(f, frames)
        # xs, ys = bbox(nodes, frames)

        ## returning: list of corner coords
        # coords = [
        #     Vector((xs[0], ys[1])),
        #     Vector((xs[1], ys[1])),
        #     Vector((xs[1], ys[0])),
        #     Vector((xs[0], ys[0])),
        # ]
        # frames_bbox[f] = coords

        ## returning: (loc vector, dimensions vector)
        frames_bbox[f] = Vector((xs[0], ys[1])), Vector((xs[1] - xs[0], ys[1] - ys[0]))

    return frames_bbox

## -- nodes helper functions

def remove_nodes_by_viewlayer(viewlayer_list, scene=None):
    '''Take a list of viewlayer and optionaly a scene to target nodetree 
    remove nodes related to this viewlayer in nodetree
    '''

    scene = scene or bpy.context.scene

    vl_names = [v.name for v in viewlayer_list]

    for n in reversed(scene.node_tree.nodes):
        if n.type == 'R_LAYERS' and n.layer in vl_names:
            for lnk in n.outputs[0].links:
                grp = lnk.to_node
                if grp.type != 'GROUP':
                    continue
                if not grp.name.startswith('NG'):
                    continue
                sockin = lnk.to_socket
                sockout = grp.outputs.get(sockin.name)
                if not sockout:
                    continue

                for grplink in sockout.links:
                    if grplink.to_node.type != 'OUTPUT_FILE':
                        continue
                    fo_socket = grplink.to_socket
                    fo = grplink.to_node
                    fo.file_slots.remove(fo_socket)

                # remove input and output from group
                # grp.inputs.remove(sockin) # do not clear inside !!
                # grp.outputs.remove(sockout) # do not clear inside !!
                
                ngroup = grp.node_tree
                if bpy.app.version < (4,0,0):
                    for i in range(len(grp.inputs))[::-1]:
                        if grp.inputs[i].name == sockin.name:
                            ngroup.inputs.remove(ngroup.inputs[i])
                            break
                    for i in range(len(grp.outputs))[::-1]:
                        if grp.outputs[i].name == sockout.name:
                            ngroup.outputs.remove(ngroup.outputs[i])
                            break
                else:
                    g_inputs = [s for s in ngroup.interface.items_tree if s.in_out == 'INPUT']
                    g_outputs = [s for s in ngroup.interface.items_tree if s.in_out == 'OUTPUT']
                    for i in range(len(grp.inputs))[::-1]:
                        if grp.inputs[i].name == sockin.name:
                            ngroup.interface.remove(g_inputs[i])
                            break
                    for i in range(len(grp.outputs))[::-1]:
                        if grp.outputs[i].name == sockout.name:
                            ngroup.interface.remove(g_outputs[i])
                            break

            # Remove render_layer node
            scene.node_tree.nodes.remove(n)

def merge_gplayer_viewlayers(ob=None, act=None, layers=None, scene=None):
    '''ob is not needed if active and layers are passed'''
    if ob is None:
        ob = bpy.context.object
    if act is None:
        act = ob.data.layers.active
    if layers is None:
        layers = [l for l in ob.data.layers if l.select and l != act]

    if act is None:
        return ({'ERROR'}, 'Active layer not found. Should be active layer on active object!')

    rd_scn = scene or get_render_scene(create=False) # bpy.context.scene
    node_scene = get_compo_scene(create=False) or rd_scn

    if not act.viewlayer_render:
        return ({'ERROR'}, f'Active layer {act.info} has no viewlayer assigned')

    # List layers and viewlayers
    vls = [rd_scn.view_layers.get(l.viewlayer_render) for l in layers
        if l.viewlayer_render and l.viewlayer_render != act.viewlayer_render and rd_scn.view_layers.get(l.viewlayer_render)]
    # Remove duplication
    vls = list(set(vls)) 


    # Remove viewlayer related nodes
    # FIXME it's possible nodes are nodes searched in the right scene if launched from RenderGP
    remove_nodes_by_viewlayer(vls, node_scene) # send compositing scene

    # Assign view layer from active to selected
    for l in layers:
        l.viewlayer_render = act.viewlayer_render

    ## Delete unused viewlayers ()

    used_vl_name = [n.layer for n in rd_scn.node_tree.nodes if n.type == 'R_LAYERS' and n.layer]
    used_vl_name = list(set(used_vl_name))

    for vl in vls:
        # rd_scn.view_layers.remove(vl)
        if vl.name == 'exclude':
            # keep exclude
            continue 
        if not vl.name in used_vl_name:
            rd_scn.view_layers.remove(vl)

def group_adjacent_layer_prefix_rlayer(ob, excluded_prefix=[], first_name=True):
    '''Set viewlayer and renderlayers by Gp layer adjacent prefix
    Call merge_gplayer_viewlayers with grouped prefix
    :excluded_prefix: List of prefixes to exclude from merge or str with comma separated values
    :first_name: Keep the viewlayer of the bottom layer in group, else last
    '''

    pattern = os.environ.get('GP_RENDER_MERGE_PATTERN', r'^([A-Z]{2})_')
    re_prefix = re.compile(pattern, flags=re.IGNORECASE)

    if isinstance(excluded_prefix, str):
        excluded_prefix = [p.strip() for p in excluded_prefix.split(',')]

    ## Create adjacent grp list: [('CO', [layer1, layer2]), ('LN', [layer3, layer4])]
    adjacent_prefix_groups = [
        (g[0], list(g[1])) for g in
        groupby([l for l in ob.data.layers],
        key=lambda l: re_prefix.search(l.info).group(1) if re_prefix.search(l.info) else '')
    ]

    for prefix, layer_grp in adjacent_prefix_groups:
        ## Remove layer that are in excluded viewlayer
        ## Else None/exclusion vl can expand rest of the adjacent layers
        for l in reversed(layer_grp):
            if not l.viewlayer_render or l.viewlayer_render == 'exclude':
                print(f'prefix "{prefix}": remove "{l.info}" from grouping adjacent layers')
                layer_grp.remove(l) # remove traget the layer directly

        if len(layer_grp) < 2:
            continue
        if not prefix or prefix in excluded_prefix:
            continue

        ref = layer_grp[0] if first_name else layer_grp[-1]

        merge_gplayer_viewlayers(ob, act=ref, layers=layer_grp)


def clear_nodegroup(name, full_clear=False):
    '''remove duplication of a nodegroup (.???)
    also remove the base one if full_clear True
    '''

    for ng in reversed(bpy.data.node_groups):
        pattern = name + r'\.\d{3}'

        if not full_clear and ng.users:
            continue

        if re.search(pattern, ng.name):
            bpy.data.node_groups.remove(ng)

        if full_clear and ng.name == name:
            # if full clear
            bpy.data.node_groups.remove(ng)

def rearrange_rlayers_in_frames(node_tree):
    '''rearrange RL nodes in all frames in nodetree'''
    frames_l = [n for n in node_tree.nodes if n.type == 'FRAME']
    for f in frames_l:
        all_in_frames = [n for n in node_tree.nodes if n.parent == f]
        rlayers = [n for n in all_in_frames if n.type == 'R_LAYERS']
        if not rlayers:
            continue
        all_in_frames.sort(key=lambda x: x.location.y, reverse=True) # descending
        rlayers.sort(key=lambda x: x.location.y, reverse=True) # descending

        top = all_in_frames[0].location.y
        for rl in rlayers:
            # move to top with equal size
            rl.location.y = top

            if rl.dimensions.y == 0:
                # Newly created nodes
                top -= 180 + 20 # down by probable size + gap of 20
            else:
                top -= rl.dimensions.y + 20 # place next down by height + gap of 20


def rearrange_frames(node_tree):
    frame_d = get_frames_bbox(node_tree) # dic : {frame_node:(loc vector, dimensions vector), ...}
    if not frame_d:
        print('no frame found')
        return

    # print([f.name for f in frame_d.keys()])

    ## order the dict by frame.y location
    frame_d = {key: value for key, value in sorted(frame_d.items(), key=lambda pair: pair[1][0].y - pair[1][1].y, reverse=True)}
    frames = [[f, v[0], v[1].y] for f, v in frame_d.items()] # [frame_node, real_loc, real dimensions]

    top = frames[0][1].y # upper node location.y
    # top = 0 #always start a 0
    offset = 0
    for f in frames:
        ## f[1] : real loc Vector
        ## f[0] : frame

        ## move frame by offset needed (delta between real_loc and "fake" loc , minus offset)
        f[0].location.y = (f[1].y - f[0].location.y) - offset # avoid offset when recalculating from 0 top
        # f[0].location.y = f[1].y - top - offset
        offset += f[2] + 200 # gap

        f[0].update()

def reorder_inputs(ng):
    rl_nodes = [s.links[0].from_node for s in ng.inputs if s.is_linked and s.links and s.links[0].from_node.type == 'R_LAYERS']
    rl_nodes.sort(key=lambda x: x.location.y, reverse=True)
    names = [n.layer for n in rl_nodes]
    inputs_names = [s.name for s in ng.inputs]
    filtered_names = [n for n in names if n in inputs_names]

    if bpy.app.version < (4,0,0):
        for dest, name in enumerate(filtered_names):
            ## rebuild list at each iteration so index are good
            inputs_names = [s.name for s in ng.inputs]
            src = inputs_names.index(name)
            # reorder on node_tree not directly on node!
            ng.node_tree.inputs.move(src, dest)
        
    else:
        n_inputs = [s for s in ng.node_tree.interface.items_tree if s.in_out == 'INPUT']
        for dest, name in enumerate(filtered_names):
            item = next((s for s in ng.node_tree.interface.items_tree if s.in_out == 'INPUT' and s.name == name), None)
            if not item: # Dbg
                print(f'!PROBLEM with input "{name}"')
                continue
            # Need to offset index (inputs are listed after output in item_tree list)
            dest = dest + n_inputs[0].position
            ng.node_tree.interface.move(item, dest)
            ng.node_tree.interface_update(bpy.context)

def reorder_outputs(ng):
    ordered_out_name = [nis.name for nis in ng.inputs if nis.name in [o.name for o in ng.outputs]]
    for s_name in ordered_out_name:
        all_outnames = [o.name for o in ng.outputs]
        # reorder on nodetree, not on node !
        if bpy.app.version < (4,0,0):
            ng.node_tree.outputs.move(all_outnames.index(s_name), ordered_out_name.index(s_name))
        else:
            item = next((s for s in ng.node_tree.interface.items_tree if s.in_out == 'OUTPUT' and s.name == s_name), None)
            if not item: # Dbg
                print(f'!PROBLEM with output "{s_name}"')
                continue
            ng.node_tree.interface.move(item, ordered_out_name.index(s_name))
            ng.node_tree.interface_update(bpy.context)

def clear_disconnected(fo):
    for inp in reversed(fo.inputs):
        if not inp.is_linked:
            print(f'Deleting unlinked fileout slot: {inp.name}')
            fo.inputs.remove(inp)

def get_pairs(ng, fo):
    ## pairs of connected socket indices (from nodegroup to fileout), ex: [(0, 3), (1, 0), (2, 1), (3, 2)]
    pairs = [(si, next((i for i, inp in enumerate(fo.inputs) if inp == o.links[0].to_socket), None)) 
     for si, o in enumerate(ng.outputs) if o.is_linked and o.links[0].to_node == fo]
    return pairs

def reorder_fileout(fo, ng=None):
    if not ng: # get connected nodegroup
        for s in fo.inputs:
            if s.is_linked and s.links and s.links[0].from_node.type == 'GROUP':
                ng = s.links[0].from_node
                break
    if not ng:
        print(f'No nodegroup to refer to filter {fo.name}')
        return

    ## Order based on sockets indices
    # for _ in range(len(ng.outputs)):
    for _ in range(len(fo.inputs)):
        # print(f'reorder iteration {_}')
        pairs = get_pairs(ng, fo) # build index pairs
        pair = next((p for p in pairs if p[0] is not None and p[1] is not None and p[0] != p[1]), None)
        if pair is None:
            ## if pair is None, we have iterated over all pairs without index difference (ordered)
            break
        print(pair)
        fo.inputs.move(pair[1], pair[0])

def reorganise_NG_nodegroup(ng):
    '''refit node content to avoid overlap'''
    ngroup = ng.node_tree
    ng_in = ngroup.nodes.get('Group Input')
    offset = 35
    y = 0
    for s in ng_in.outputs:
        if s.is_linked:
            s.links[0].to_node.location.y = y
        y -= offset

def connect_to_group_output(n):
    for o in n.outputs:
        if o.is_linked:
            if o.links[0].to_node.type == 'GROUP_OUTPUT':
                return o.links[0].to_socket
            val = connect_to_group_output(o.links[0].to_node)
            if val:
                return val
    return False

def connect_to_group_input(n):
    for i in n.inputs:
        if i.is_linked:
            if i.links[0].from_node.type == 'GROUP_INPUT':
                return i.links[0].from_socket
            val = connect_to_group_input(i.links[0].from_node)
            if val:
                return val
    return False

def all_connected_forward(n, nlist=[]):
    '''return list of all forward connected nodes recursively (include passed nodes)'''
    for o in n.outputs:
        if o.is_linked:
            for lnk in o.links:
                if lnk.to_node.type == 'GROUP_OUTPUT':
                    if n not in nlist:
                        return nlist + [n]
                    else:
                        return nlist
                else:
                    nlist = all_connected_forward(lnk.to_node, nlist)
    if n in nlist:
        return nlist
    return nlist + [n]

def all_connected_forward_from_socket(socket):
    '''return a list of all nodes connected forward after socket'''
    node_list = []
    for ln in socket.links:
        for n in all_connected_forward(ln.to_node):
            if n not in node_list:
                node_list.append(n)
    # node_list = list(set(node_list))
    return node_list

def node_height(n):
    return n.height if not n.hide else 30

def reorder_nodegroup_content(ngroup):
    if isinstance(ngroup, bpy.types.Node):
        ngroup = ngroup.node_tree

    grp_in = None
    for n in ngroup.nodes:
        if n.type == 'GROUP_INPUT':
            grp_in = n
            break
    if not grp_in:
        return

    n_threads = []
    for out in grp_in.outputs:
        n_thread = all_connected_forward_from_socket(out)
        if n_thread:
            n_threads.append(n_thread)

    level = grp_in.location.y
    for thread in n_threads:
        top = max([n.location.y for n in thread])
        bottom = min([n.location.y - node_height(n) for n in thread])
        thread_h = top - bottom
        # move all nodes to adjust to level
        diff_to_add = level - top
        for n in thread:
            n.location.y += diff_to_add
        # move level to bottom
        level -= thread_h + 2 # add a gap of two

def clear_nodegroup_content_if_disconnected(ngroup):
    '''Get a nodegroup.node_tree
    delete orphan nodes that are not connected from group input node
    '''
    if isinstance(ngroup, bpy.types.Node):
        # case where a node is sent instead of the group
        ngroup = ngroup.node_tree

    for n in reversed(ngroup.nodes):
        if n.type in ('GROUP_INPUT', 'GROUP_OUTPUT'):
            continue
        if not connect_to_group_input(n) and not connect_to_group_output(n): # is disconnected from both side
            ngroup.nodes.remove(n)

    reorder_nodegroup_content(ngroup)

def clean_nodegroup_inputs(ng, skip_existing_pass=True):
    '''Clear inputs to output of passed nodegroup if not connected'''
    ngroup = ng.node_tree
    rl_nodes = [n.layer for n in ng.id_data.nodes if n.type == 'R_LAYERS']
    for i in range(len(ng.inputs))[::-1]:
        if not ng.inputs[i].is_linked:
            if skip_existing_pass and any(ng.inputs[i].name == x for x in rl_nodes):
                # a render layer of this name still exists
                continue
            ngroup.inputs.remove(ngroup.inputs[i])
    # clear_nodegroup_content_if_disconnected(ngroup)

def bridge_reconnect_nodegroup(ng, socket_name=None):
    '''
    Reconnect group_in and group out that have been disconnected
    :socket: only use this specific socket type
    '''
    ngroup = ng.node_tree
    ng_in = ngroup.nodes.get('Group Input')
    ng_out = ngroup.nodes.get('Group Output')
    for sockin in ng_in.outputs:
        if socket_name and sockin.name != socket_name:
            continue
        if not sockin.name: # last empty output is listed
            continue
        sockout = ng_out.inputs.get(sockin.name)
        if not sockout:
            continue
        if len(sockin.links) and connect_to_group_output(sockin.links[0].to_node):
            continue
        ## need reconnect
        aa = create_aa_nodegroup(ngroup)
        ngroup.links.new(sockin, aa.inputs[0])
        ngroup.links.new(aa.outputs[0], sockout)
        print(f'{ng.name}: Bridged {sockin.name}')


def random_color(alpha=False):
    import random
    if alpha:
        return (random.uniform(0,1), random.uniform(0,1), random.uniform(0,1), 1)
    return (random.uniform(0,1), random.uniform(0,1), random.uniform(0,1))

def nodegroup_merge_inputs(ngroup, aa=True):
    '''Get a nodegroup
    merge every group inputs with alpha over
    then connect to antialias and a new output
    '''

    ng_in = ngroup.nodes.get('Group Input')
    ng_out = ngroup.nodes.get('Group Output')

    x, y = ng_in.location.x + 200, 0

    offset_x, offset_y = 150, -100

    # merge all inputs in alphaover nodes
    prev = None
    for i in range(len(ng_in.outputs)-1): # skip waiting point
        inp = ng_in.outputs[i]
        if not prev:
            prev = ng_in
            continue

        # live connect
        ao = create_node('CompositorNodeAlphaOver', tree=ngroup, location=(x,y), hide=True)
        ngroup.links.new(prev.outputs[0], ao.inputs[1])
        ngroup.links.new(inp, ao.inputs[2])

        x += offset_x
        y += offset_y
        prev = ao

    # Create one output and link
    out_name = 'preview'
    if bpy.app.version < (4,0,0):
        # out_name = ngroup.inputs[0].name # name like first inputout_name
        out = ngroup.outputs.new('NodeSocketColor', out_name)
    else:
        # out_name = next((s for s in ngroup.interface.items_tree if s.in_out == 'INPUT')).name # name like first input
        out = ngroup.interface.new_socket(out_name, in_out='OUTPUT', socket_type='NodeSocketColor')
    
    ## create a merged name as output ??
    if aa:
        # create AA and link
        aa = create_aa_nodegroup(ngroup) # new_aa_node(ngroup)
        aa.location = (ao.location.x + 200, ao.location.y)
        ngroup.links.new(ao.outputs[0], aa.inputs[0]) # node_tree
        ngroup.links.new(aa.outputs[0], ng_out.inputs[0])
    else:
        # link directly
        ngroup.links.new(ao.outputs[0], ng_out.inputs[0])


## -- renumbering funcs

def get_numbered_output(out, slot_name):
    '''Return output slot name without looking for numbering ???_
    '''
    pattern = r'^(?:\d{3}_)?' # optional non capture group of 3 digits + _
    pattern = f'{pattern}{slot_name}'
    for inp in out.inputs:
        if re.match(pattern, inp.name):
            return inp


def add_fileslot_number(fs, number):
    field_attr = 'name' if hasattr(fs, 'name') else 'path'

    elems = getattr(fs, field_attr).split('/')
    for i, e in enumerate(elems):
        if re.match(r'^\d{3}_', e):
            elems[i] = re.sub(r'^(\d{3})', lambda x: str(number).zfill(3), e)
        else:
            elems[i] = f'{str(number).zfill(3)}_{e}'
    new = '/'.join(elems)

    setattr(fs, field_attr, new)
    return new

def renumber(fo, offset=10):
    '''Force renumber all the slots with a 3'''

    if fo.type != 'OUTPUT_FILE': return
    ct = 10 # start at 10
    slots = fo.layer_slots if fo.format.file_format == 'OPEN_EXR_MULTILAYER' else fo.file_slots 
    for fs in slots:
        add_fileslot_number(fs, ct)
        ct += offset

def get_num(string) -> int:
    '''get a tring or a file_slot object
    return leading number or None
    '''
    if not isinstance(string, str):
        if hasattr(string, 'path'):
            string = string.path
        else:
            string = string.name

    num = re.search(r'^(\d{3})_', string)
    if num:
        return int(num.group(1))

def delete_numbering(fo): # padding=3
    '''Delete prefix numbering on all slots on passed file output'''

    if fo.type != 'OUTPUT_FILE': return

    if fo.format.file_format == 'OPEN_EXR_MULTILAYER':
        slots = fo.layer_slots
        field_attr = 'name'
    else:
        slots = fo.file_slots
        field_attr = 'path'

    for fs in slots:
        elems = getattr(fs, field_attr).split('/')
        for i, e in enumerate(elems):
            elems[i] = re.sub(r'^\d{3}_', '', e)

        new = '/'.join(elems)
        setattr(fs, field_attr, new)

def reverse_fileout_inputs(fo):
    count = len(fo.inputs)
    for i in range(count):
        fo.inputs.move(count-1, i)

def renumber_keep_existing(fo, offset=10, invert=True):
    '''Renumber by keeping existing numbers and inserting new one whenever possible
    Big and ugly function that do the trick nonetheless...
    '''

    if fo.type != 'OUTPUT_FILE': return
    ct = 10

    if invert:
        reverse_fileout_inputs(fo)

    fsl = fo.layer_slots if fo.format.file_format == 'OPEN_EXR_MULTILAYER' else fo.file_slots 

    last_idx = len(fsl) - 1
    prev = None
    prev_num = None
    for idx, fs in enumerate(fsl):
        # print('-->', idx, fs.path)

        if idx == last_idx: # handle last
            if get_num(fs) is not None:
                break
            if idx > 0:
                prev = fsl[idx-1]
                num = get_num(prev)
                if num is not None:
                    add_fileslot_number(fs, num + offset)
                else:
                    add_fileslot_number(fs, ct)
            else:
                add_fileslot_number(fs, 10) # there is only one slot (maybe don't number ?)
            break

        # update the ct with the current taken number if any
        number = get_num(fs)
        if number is not None:
            prev = fs
            ct = number + offset
            continue # skip already numbered

        # analyse all next slots until there is numbered
        divider = 0
        # print(f'range(1, {len(fsl) - idx}')
        for i in range(1, len(fsl) - idx):
            next_num = get_num(fsl[idx + i])
            if next_num is not None:
                divider = i+1
                break

        if idx == 0: # handle first
            prev_num = 0
            prev = None
            if next_num is None:
                add_fileslot_number(fs, 0)
            elif next_num == 0:
                print(f'Cannot insert value before 0 to {fsl.path}')
                continue
            else:
                add_fileslot_number(fs, int(next_num / 2))
        else:
            prev = fsl[idx-1]
            test_prev = get_num(prev)
            if test_prev is not None:
                prev_num = test_prev

        if not divider:
            if prev_num is not None:
                add_fileslot_number(fs, prev_num + offset)
            else:
                add_fileslot_number(fs, ct)

        else:
            if prev_num is not None:
                # iterate rename
                gap_inc = int((next_num - prev_num) / divider)
                if gap_inc < 1: # same values !
                    print(f'cannot insert a median value at {fs.path} between {prev_num} and {next_num}')
                    continue

                ct = prev_num
                for temp_id in range(idx, idx+i):
                    ct += gap_inc
                    add_fileslot_number(fsl[temp_id], ct)
            else:
                print("what's going on ?\n")

        # first check if it has a number (if not bas)
        prev = fs
        ct += offset

    if invert:
        reverse_fileout_inputs(fo)

def has_channel_color(layer):
    '''Return True if gp_layer.channel_color is different than the default (0.2, 0.2, 0.2) '''
    if not any(isclose(i, 0.2, abs_tol=0.001) for i in layer.channel_color):
        return True

def normalize(text):
    return text.lower().replace('-', '_')

PATTERN = r'^(?P<grp>-\s)?(?P<tag>[A-Z]{2}_)?(?P<name>.*?)(?P<sfix>_[A-Z]{2})?(?P<inc>\.\d{3})?$' # numering
def normalize_layer_name(layer, prefix='', desc='', suffix='', lower=True, dash_to_underscore=True, point_to_underscore=True, get_only=False):
    '''GET a layer and argument to build and assign name'''
    import re

    name = layer.info

    pattern = PATTERN
    sep = '_'
    res = re.search(pattern, name.strip())


    grp = '' if res.group('grp') is None else res.group('grp')
    tag = '' if res.group('tag') is None else res.group('tag')
    # tag2 = '' if res.group('tag2') is None else res.group('tag2')
    name = '' if res.group('name') is None else res.group('name')
    sfix = '' if res.group('sfix') is None else res.group('sfix')
    inc = '' if res.group('inc') is None else res.group('inc')

    if grp:
        grp = '  ' + grp # name is strip(), so grp first spaces are gones.

    if prefix:
        if prefix == 'prefixkillcode':
            tag = ''
        else:
            tag = prefix.upper().strip() + sep
    # if prefix2:
    #     tag2 = prefix2.upper().strip() + sep
    if desc:
        name = desc

    if suffix:
        if suffix == 'suffixkillcode':
            sfix = ''
        else:
            sfix = sep + suffix.upper().strip()

    # check if name is available without the increment ending
    if lower:
        name = name.lower()
    if dash_to_underscore:
        name = name.replace('-', '_')
    if point_to_underscore:
        name = name.replace('.', '_')

    new = f'{grp}{tag}{name}{sfix}' # lower suffix ?
    if get_only:
        return new
    if new != layer.info:
        old = layer.info
        print(f'{old} >> {new}')
        layer.info = new

        # Also change name string in modifier target !
        for ob in [o for o in bpy.data.objects if o.type == 'GPENCIL' and o.data == layer.id_data]:
            for m in ob.grease_pencil_modifiers:
                if hasattr(m, 'layer') and m.layer:
                    if m.layer == old:
                        print(f' - updated in {ob.name} modifier {m.name}')
                        m.layer = new

# unused currently
def build_dope_gp_list(layer_list):
    '''Take a list of GP layers return a dict with pairs {gp data : own layer list}'''
    from collections import defaultdict
    gps = defaultdict(list)
    for l in layer_list:
        gps[l.id_data].append(l)
    return gps

def build_layers_targets_from_dopesheet(context):
    '''Return all selected layers on context GP dopesheet according to seelction and filters'''
    ob = context.object
    gpl = context.object.data.layers
    act = gpl.active
    dopeset = context.space_data.dopesheet


    if dopeset.show_only_selected:
        pool = [o for o in context.selected_objects if o.type == 'GPENCIL']
    else:
        pool = [o for o in context.scene.objects if o.type == 'GPENCIL']
        if not dopeset.show_hidden:
            pool = [o for o in pool if o.visible_get()]

    layer_pool = [l for o in pool for l in o.data.layers]
    layer_pool = list(set(layer_pool)) # remove dupli-layers from same data source with

    # apply search filter
    if dopeset.filter_text:
        layer_pool = [l for l in layer_pool if (dopeset.filter_text.lower() in l.info.lower()) ^ dopeset.use_filter_invert]

    return layer_pool

""" # old show message gox without operator support
def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'):
    '''get a str to display or a list of [str, str]
    can have an icon [[str, icon], str, [str, icon]]
    '''

    def draw(self, context):
        for l in _message:
            if isinstance(l, str):
                self.layout.label(text=l)
            else:
                self.layout.label(text=l[0], icon=l[1])

    if isinstance(_message, str):
        _message = [_message]
    bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon)
 """

def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'):
    '''Show message box with element passed as string or list
    if _message if a list of lists:
        if sublist have 2 element:
            considered a label [text,icon]
        if sublist have 3 element:
            considered as an operator [ops_id_name, text, icon]
    '''

    def draw(self, context):
        for l in _message:
            if isinstance(l, str):
                self.layout.label(text=l)
            else:
                if len(l) == 2: # label with icon
                    self.layout.label(text=l[0], icon=l[1])
                elif len(l) == 3: # ops
                    self.layout.operator_context = "INVOKE_DEFAULT"
                    self.layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry

    if isinstance(_message, str):
        _message = [_message]
    bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon)


## -- camera framing and object anim checks

def get_bbox_3d(ob) -> list:
    bbox_coords = ob.bound_box
    return [ob.matrix_world @ Vector(b) for b in bbox_coords]

def is_render_included(o, scn) -> bool:
    '''return True if object is in at least one non-excluded collection
    in all passed scene viewlayer
    '''

    if o.hide_render:
        return False
    for vl in scn.view_layers:
        all_cols = get_collection_childs_recursive(vl.layer_collection)
        for c in all_cols:
            print(c.name)
            if o in c.collection.objects[:]:
                if not c.exclude:
                    return True
    return False


def get_crop_pixel_coord(scn) -> dict:
    # width height probably not needed. might need
    px_width = (scn.render.border_max_x - scn.render.border_min_x) * scn.render.resolution_x
    px_height = (scn.render.border_max_y - scn.render.border_min_y) * scn.render.resolution_y

    pos_x = (scn.render.border_min_x + ((scn.render.border_max_x - scn.render.border_min_x) / 2)) * scn.render.resolution_x

    ## coord y > image center coord from bottom-left (Blender)
    # pos_y = (scn.render.border_min_y + ((scn.render.border_max_y - scn.render.border_min_y) / 2)) * scn.render.resolution_y,

    ## image center coord from top-left (AE)
    pos_y = ((1 - scn.render.border_max_y) + ((scn.render.border_max_y - scn.render.border_min_y) / 2)) * scn.render.resolution_y

    coord = {
        'position_x' : round(pos_x),
        'position_y' : round(pos_y),
        'width' : round(px_width),
        'height' : round(px_height),
        'scene_res_x': scn.render.resolution_x,
        'scene_res_y': scn.render.resolution_y,
    }
    return coord

def export_crop_to_json() -> dict:
    '''Export crop to json coords for AE'''

    blend = Path(bpy.data.filepath)
    ## Use a fixed name (easier to load from After effects)
    json_path = blend.parent / 'render' / 'crop_infos.json'
    ## Use blend name (to support version)
    # json_path = blend.parent / 'render' / f'{blend.stem}.json'

    ## per scene : json_path = Path(bpy.data.filepath).parent / 'render' / f'{scn.name}.json'
    # json_path = Path(bpy.data.filepath).parent / 'render' / f'{scn.name}.json' # f'{ob.name}.json'

    coord_dic = {}

    for scn in bpy.data.scenes:
        if scn.render.use_border and scn.render.use_crop_to_border: # Only usefull if cropped
            scn_border = get_crop_pixel_coord(scn)
            ## Only scn name (meaning only one name to refer if multiple GP)
            # coord_dic[scn.name] = scn_border

            ## use name of first found visible GP (scene name if no visible GP)
            gps = [o for o in scn.objects if o.type == 'GPENCIL' if is_render_included(o, scn)] # o.visible_get() < only work on active window
            if gps and scn.name != 'Scene': # always export Scene with Scene name...
                for ob in gps:
                    coord_dic[ob.name] = scn_border
                    print(f'Added gp {ob.name} crop info')
            else:
                coord_dic[scn.name] = scn_border
                print(f'Added scene {scn.name} crop info')

    if coord_dic:
        json_path.parent.mkdir(parents=False, exist_ok=True)
        # save bbox
        with json_path.open('w') as fd:
            json.dump(coord_dic, fd, indent='\t')

        print(f'Coords saved at: {json_path}')
    return coord_dic

def set_border_region_from_coord(coords, scn=None, margin=30, export_json=True):
    '''Get a list of point coord in worldcamera view space (0 to 1) on each axis
    set border (of passed scene :scn: ) with given coordinate
    return the coords list as pixel coordinate
    '''

    scn = scn or bpy.context.scene

    coords2d_x = sorted([c[0] for c in coords])
    coords2d_y = sorted([c[1] for c in coords])

    margin_width = margin / scn.render.resolution_x
    margin_height = margin / scn.render.resolution_y

    # set crop
    scn.render.border_min_x = coords2d_x[0] - margin_width
    scn.render.border_max_x = coords2d_x[-1] + margin_width

    scn.render.border_min_y = coords2d_y[0] - margin_height
    scn.render.border_max_y = coords2d_y[-1] + margin_height

    ## get clamped relative value
    # relative_bbox2d_coords = [
    # (scn.render.border_min_x, scn.render.border_min_y),
    # (scn.render.border_min_x, scn.render.border_max_y),
    # (scn.render.border_max_x, scn.render.border_max_y),
    # (scn.render.border_max_x, scn.render.border_min_y),
    # ]

    pixel_bbox2d_coords = [
    (scn.render.border_min_x*scn.render.resolution_x, scn.render.border_min_y*scn.render.resolution_y),
    (scn.render.border_min_x*scn.render.resolution_x, scn.render.border_max_y*scn.render.resolution_y),
    (scn.render.border_max_x*scn.render.resolution_x, scn.render.border_max_y*scn.render.resolution_y),
    (scn.render.border_max_x*scn.render.resolution_x, scn.render.border_min_y*scn.render.resolution_y),
    ]
    # if export_json:
    #     export_crop_to_json(scn)
    return pixel_bbox2d_coords

def get_gp_box_all_frame(ob, cam=None):
    '''set crop to object bounding box considering whole animation. Cam should not be animated (render in bg_cam)
    return 2d bbox in pixels
    '''
    from bpy_extras.object_utils import world_to_camera_view
    coords_cam_list = []
    scn = bpy.context.scene
    cam = cam or scn.camera
    start = time()

    if ob.animation_data and ob.animation_data.action: # use frame set on all frames
        print(f'{ob.name} has anim')
        # frame_nums = sorted(list(set([f.frame_number for l in ob.data.layers if len(l.frames) for f in l.frames if len(f.strokes) and scn.frame_start <= f.frame_number <= scn.frame_end])))
        for num in range(scn.frame_start, scn.frame_end+1):
            scn.frame_set(num)
            for l in ob.data.layers:
                if l.hide or l.opacity == 0.0:
                    continue
                if l.active_frame:
                    for s in l.active_frame.strokes:
                        if len(s.points) == 1: # skip isolated points
                            continue
                        coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points]
    else:
        # if object is not animated no need to frame_set to update object position
        print(f'{ob.name} no anim')
        for l in ob.data.layers:
            if l.hide or l.opacity == 0.0:
                continue
            for f in l.frames:
                if not (scn.frame_start <= f.frame_number <= scn.frame_end):
                    continue
                for s in f.strokes:
                    if len(s.points) == 1: # skip isolated points
                        continue
                    coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points]

    print(time() - start) # Dbg-time
    return coords_cam_list

def has_anim(ob):
    # TODO make a better check (check if there is only one key in each channel, count as not animated)
    return ob.animation_data and ob.animation_data.action

def has_keyframe(ob, attr):
    anim = ob.animation_data
    if anim is not None and anim.action is not None:
        for fcu in anim.action.fcurves:
            if fcu.data_path == attr:
                return len(fcu.keyframe_points) > 0
    return False

def get_gp_box_all_frame_selection(oblist=None, scn=None, cam=None, timeout=40):
    '''
    get points of all selection
    return 2d bbox in pixels
    return None if timeout (too long to process, better to do it visually)
    '''

    from bpy_extras.object_utils import world_to_camera_view

    t0 = time()
    coords_cam_list = []
    scn = scn or bpy.context.scene
    oblist = oblist or [o for o in scn.objects if o.select_get()]

    cam = cam or scn.camera
    start = time()

    if any(has_anim(ob) for ob in oblist):
        print(f'at least one is animated: {oblist}')
        for num in range(scn.frame_start, scn.frame_end+1):
            scn.frame_set(num)
            for ob in oblist:
                for l in ob.data.layers:
                    if l.hide or l.opacity == 0.0 or l.info.startswith('MA_'): # hardcoded Mask exclusion !
                        continue
                    if not l.active_frame:
                        continue
                    for s in l.active_frame.strokes:
                        if len(s.points) == 1: # skip isolated points
                            continue
                        coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points]

                if time() - t0 > timeout:
                    print(f'timeout (more than {timeout}s to calculate) evaluating frame position of objects {oblist}')
                    return
    else:
        print(f'No anim')
        for ob in oblist:
            # if object is not animated no need to frame_set to update object position
            for l in ob.data.layers:
                if l.hide or l.opacity == 0.0 or l.info.startswith('MA_'): # hardcoded Mask exclusion !
                    continue
                for f in l.frames:
                    if time() - t0 > timeout:
                        print(f'timeout (more than {timeout}s to calculate) evaluating frame position of objects {oblist}')
                        return
                    if not (scn.frame_start <= f.frame_number <= scn.frame_end):
                        continue
                    for s in f.strokes:
                        if len(s.points) == 1: # skip isolated points
                            continue
                        coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points]


    print(f'{len(coords_cam_list)} gp points listed {time() - start:.1f}s')
    return coords_cam_list

def get_bbox_2d(ob, cam=None):
    from bpy_extras.object_utils import world_to_camera_view
    scn = bpy.context.scene
    cam = cam or scn.camera
    coords2d = [world_to_camera_view(scn, cam, p) for p in get_bbox_3d(ob)]
    coords2d_x = sorted([c[0] for c in coords2d])
    coords2d_y = sorted([c[1] for c in coords2d])

    bbox2d_coords = [
        (coords2d_x[0], coords2d_y[0]),
        (coords2d_x[0], coords2d_y[-1]),
        (coords2d_x[-1], coords2d_y[-1]),
        (coords2d_x[-1], coords2d_y[0]),
        ]

    return [Vector(b) for b in bbox2d_coords]

def set_box_from_selected_objects(scn=None, cam=None, export_json=False):
    scn = scn or bpy.context.scene
    cam = cam or scn.camera

    selection = [o for o in scn.objects if o.select_get()] # selected_objects
    coords = get_gp_box_all_frame_selection(oblist=selection, scn=scn, cam=cam)
    if not coords:
        return f'Border not set: Timeout during analysis of {len(selection)} objects'

    _bbox_px = set_border_region_from_coord(coords, margin=30, scn=scn, export_json=export_json)

def get_cam_frame_center_world(cam):
    '''get camera frame center world position in 3d space'''
    ## ortho cam note: scale must be 1,1,1 (parent too) to fit right in cam-frame rectangle

    import numpy as np
    frame = cam.data.view_frame()
    mat = cam.matrix_world
    frame = [mat @ v for v in frame]

    # return np.add.reduce(frame) / 4
    return Vector(np.sum(frame, axis=0) / 4)

def get_coord_in_cam_space(scene, cam_ob, co, ae=False):
    '''Get 2d coordinate of vector in cam space
    :scene: scene where camera is used (needed to get resolution)
    :cam_ob: camera object
    :co: the Vector3 coordinate to find in cam space
    :ae: if True, Return after effects coord, top-left corner origin (blender is bottom-left)

    '''
    import bpy_extras
    co_2d = bpy_extras.object_utils.world_to_camera_view(scene, cam_ob, co)

    if ae:
        # y coordinate from top
        co_2d = Vector((co_2d.x, 1 - co_2d.y))

    ## Convert to pixel values based on scene resolution and percentage
    render_scale = scene.render.resolution_percentage / 100
    render_size = (
        int(scene.render.resolution_x * render_scale),
        int(scene.render.resolution_y * render_scale),
    )

    return (
        round(co_2d.x * render_size[0]), # x
        round(co_2d.y * render_size[1]), # y
        )


## -- After effects exports

def get_ae_keyframe_clipboard_header(scn):
    '''Need to use tabs for AE'''

    ## multiline version, but space instead of tabs break syntax for AE
    # import textwrap
    # t = f'''\
    #     Adobe After Effects 8.0 Keyframe Data

    #         Units Per Second    {scn.render.fps}
    #         Source Width    {scn.render.resolution_x}
    #         Source Height   {scn.render.resolution_y}
    #         Source Pixel Aspect Ratio   1
    #         Comp Pixel Aspect Ratio    1

    #     Transform   Position
    #         Frame   X pixels    Y pixels    Z pixels
    #     '''
    # t = textwrap.dedent(t)
    # ## spaces to tab
    # t = re.sub(r'\s{3-4}', u'\t', t) # ! Still spaces

    ## Direct use of tabs is safer
    t = 'Adobe After Effects 8.0 Keyframe Data\n\n'
    t += '\tUnits Per Second\t%s\n'%scn.render.fps
    t += '\tSource Width\t%s\n'%scn.render.resolution_x
    t += '\tSource Height\t%s\n'%scn.render.resolution_y
    t += '\tSource Pixel Aspect Ratio\t1\n'
    t += '\tComp Pixel Aspect Ratio\t1\n\n'
    t += 'Transform\tPosition\n'
    t += '\tFrame\tX pixels\tY pixels\tZ pixels\t\n'

    return t

## -- Collection handle

def get_collection_childs_recursive(col, cols=[], include_root=True):
    '''return a list of all the sub-collections in passed col'''
    # force start from fresh list (otherwise same cols list is used at next call)
    cols = cols or []

    for sub in col.children:
        if sub not in cols:
            cols.append(sub)
        if len(sub.children):
            cols = get_collection_childs_recursive(sub, cols)

    if include_root and col not in cols: # add root col
        cols.append(col)

    return cols

def unlink_objects_from_scene(oblist, scn):
    all_col = get_collection_childs_recursive(scn.collection)
    for col in all_col:
        for ob in reversed(col.objects):
            if ob in oblist:
                col.objects.unlink(ob)

def remove_scene_nodes_by_obj_names(scn, name_list, negative=False):
    for n in reversed(scn.node_tree.nodes):
        if negative:
            if (n.parent and n.parent.label not in name_list) or (n.type == 'FRAME' and n.label not in name_list):
                scn.node_tree.nodes.remove(n)
        else:
            if (n.parent and n.parent.label in name_list) or (n.type == 'FRAME' and n.label in name_list):
                scn.node_tree.nodes.remove(n)

def split_object_to_scene(objs=None, scene_name=None):
    '''Create a new scene from object selection'''

    if not scene_name:
        active = bpy.context.object
        scene_name = active.name
    if not objs:
        objs = [o for o in bpy.context.selected_objects]

    if bpy.data.scenes.get(scene_name):
        print(f'Scene "{scene_name}" Already Exists')
        raise Exception(f'Scene "{scene_name}" Already Exists')

    src = bpy.context.scene

    bpy.ops.scene.new(type='LINK_COPY')
    new = bpy.context.scene
    new.name = scene_name

    ## OPT
    ## set individual output for composite or go in /tmp ? (might not be needed)
    # new.render.filepath = f'//render/preview/{bpy.path.clean_name(new.name.lower())}/preview_'
    # new.render.filepath = f'/tmp/'

    ## unlink unwanted objects from collection
    all_col = get_collection_childs_recursive(new.collection)
    for col in all_col:
        for sob in reversed(col.objects):
            if sob.type in ('CAMERA', 'LIGHT'):
                continue
            if sob not in objs:
                col.objects.unlink(sob)

    frame_names = [n.label for n in new.node_tree.nodes if n.type == 'FRAME' if new.objects.get(n.label)]
    remove_scene_nodes_by_obj_names(new, frame_names, negative=True)

    bpy.ops.gp.clean_compo_tree()

    # add crop
    new.render.use_border = True
    new.render.use_crop_to_border = True
    new.render.use_compositing = True
    new.render.use_sequencer = False

    ## remove asset from original scene
    #src_frame_names = [n.label for n in src.node_tree.nodes if n.type == 'FRAME' and n.label in [o.name for o in objs]]
    #remove_scene_nodes_by_obj_names(src, src_frame_names)
    remove_scene_nodes_by_obj_names(src, frame_names, negative=False)

    # unlink objects ?
    unlink_objects_from_scene(objs, src)

    # border to GP objects of the scene
    gp_objs = [o for o in new.objects if o.type == 'GPENCIL']
    coords = get_gp_box_all_frame_selection(oblist=gp_objs, scn=new, cam=new.camera)
    if not coords:
        return f'Scene "{scene_name}" created. But Border was not set (Timeout during GP analysis), should be done by hand if needed then use export crop to json'

    set_border_region_from_coord(coords, margin=30, scn=new, export_json=True)
    export_crop_to_json()


def clear_frame_out_of_range(o, verbose=False):
    '''get a GP object
    delete frame out of active scene range in all layers
    return number of deleted frame
    '''

    scn = bpy.context.scene
    ct = 0
    if o.type != 'GPENCIL':
        print(f'{o.name} not a Gpencil')
        return 0
    for l in o.data.layers:
        first = True
        for f in reversed(l.frames):

            # after
            if f.frame_number > scn.frame_end + 1:
                if verbose:
                    print(f'del: obj {o.name} > layer {l.info} > frame {f.frame_number}')
                l.frames.remove(f)
                ct += 1

            # before
            elif f.frame_number < scn.frame_start - 1:
                if first:
                    first = False
                    continue
                if verbose:
                    print(f'del: obj {o.name} > layer {l.info} > frame {f.frame_number}')
                l.frames.remove(f)
                ct += 1

    # print('INFO', f'{ct} frames deleted')
    return ct


## not used
def clear_frame_out_of_range_all_object():
    scene = bpy.context.scene
    ct = 0
    for o in scene.objects:
        if o.type == 'GPENCIL':
            nct = clear_frame_out_of_range(o, verbose=False)
            print(f'{o.name}: {nct} frames deleted')
            ct += nct
    print(f'{ct} gp frames deleted')
    return ct

def set_scene_output_from_active_fileout_item():
    scn = bpy.context.scene
    rd = scn.render
    ntree =scn.node_tree
    fo = ntree.nodes.active

    if fo.type != 'OUTPUT_FILE':
        return
    sl = fo.file_slots[fo.active_input_index]
    full_path = os.path.join(fo.base_path, sl.path)

    rd.filepath = full_path

    fmt = fo.format if sl.use_node_format else sl.format
    ## set those attr first to avoid error settings other attributes in next loop
    rd.image_settings.file_format = fmt.file_format
    rd.image_settings.color_mode = fmt.color_mode
    rd.image_settings.color_depth = fmt.color_depth if fmt.color_depth else 8 # Force set since Sometimes it's weirdly set to "" (not in enum choice)

    excluded = ['file_format', 'color_mode', 'color_depth',
        'view_settings', 'views_format']

    ''' ## all attrs
    # 'cineon_black', 'cineon_gamma', 'cineon_white',
    #  'color_depth', 'color_mode', 'compression', 'display_settings',
    #  'exr_codec', 'file_format', 'jpeg2k_codec', 'quality',
    #  'rna_type', 'stereo_3d_format', 'tiff_codec', 'use_cineon_log',
    #  'use_jpeg2k_cinema_48', 'use_jpeg2k_cinema_preset', 'use_jpeg2k_ycc',
    #  'use_preview', 'use_zbuffer']
    '''

    for attr in dir(fmt):
        if attr.startswith('__') or attr.startswith('bl_') or attr in excluded:
            continue
        if hasattr(scn.render.image_settings, attr) and not scn.render.image_settings.is_property_readonly(attr):
            setattr(scn.render.image_settings, attr, getattr(fmt, attr))


def set_layer_colors(skip_if_colored=False):
    '''Set hardcoded color to grease pencil layer channel according to prefixes'''

    ## UW -> TO (here used fo CU): (0.015996, 0.246201, 0.246201) # Indigo
    ## invisible (close to violet light) in UW: (0.246201, 0.132868, 0.496933)
    prefix_color = {
        # 'MA_': (0.09, 0.08, 0.46), # Vivid blue
        'MA_': (0.65, 0.4, 0.6), # Pink Light

        'FX_': (0.12, 0.33, 0.58), # (0.3, 0.49, 0.63) # Blue Light
        # 'CO_': (0.35, 0.0085, 0.25),
        'CO_': (0.5,0.1,0.5), # Clear Pink
        # 'CU': (0.092070, 0.177356, 0.447959), # Blue clear
        'CU_': (0.02, 0.27, 0.27), # Indigo
    }

    for ob in bpy.context.scene.objects:
        if ob.type != 'GPENCIL':
            continue
        if skip_if_colored and any([has_channel_color(l) for l in ob.data.layers]):
            continue
        for l in ob.data.layers:
            # if l.info.startswith(prefix_color.keys()):
            color = prefix_color.get(l.info[:3])
            if not color:
                continue
            print(l.info, '->', color)
            l.channel_color = color

    bpy.context.preferences.edit.use_anim_channel_group_colors = True


def different_gp_mat(mata, matb):
    '''return None if no difference (False), string describing color difference (True)'''
    a = mata.grease_pencil
    b = matb.grease_pencil
    if a.color[:] != b.color[:]:
        return f'{mata.name} and {matb.name} stroke color is different'
    if a.fill_color[:] != b.fill_color[:]:
        return f'{mata.name} and {matb.name} fill_color color is different'
    if a.show_stroke != b.show_stroke:
        return f'{mata.name} and {matb.name} stroke has different state'
    if a.show_fill != b.show_fill:
        return f'{mata.name} and {matb.name} fill has different state'

## Clean dups
def clean_mats_duplication(ob, skip_different_materials=True):
    '''Clean object material stack of duplication
    if a material is named "mat.001" and a "mat" exists, replace with the one with original name

    :skip_different_materials: Don't replace a "mat.???" if orignal "mat" has different color
    '''

    import re
    diff_ct = 0
    todel = []
    if ob.type != 'GPENCIL':
        return
    if not hasattr(ob, 'material_slots'):
        return
    for i, ms in enumerate(ob.material_slots):
        mat = ms.material
        if not mat:
            continue
        match = re.search(r'(.*)\.\d{3}$', mat.name)
        if not match:
            continue
        basemat = bpy.data.materials.get(match.group(1))
        if not basemat:
            continue
        if not basemat.is_grease_pencil:
            ## Skip material with matching "stem" but not being GP material
            continue
        diff = different_gp_mat(mat, basemat)
        if diff:
            print(f'! {ob.name} : {diff}')
            diff_ct += 1
            if skip_different_materials:
                continue

        if mat not in todel:
            todel.append(mat)
        ms.material = basemat
        print(f'{ob.name} : slot {i} >> replaced {mat.name}')
        mat.use_fake_user = False

    ### delete (only when using on all objects loop, else can delete another objects mat...)
    ## for m in reversed(todel):
    ##     bpy.data.materials.remove(m)

    if diff_ct:
        print(f'{diff_ct} mat skipped >> same name but different color settings!')
        # return ('INFO', f'{diff_ct} mat skipped >> same name but different color settings!')

def recursive_node_connect_check(l, target_node):
    if l.to_node == target_node:
        return True    
    for o in l.to_node.outputs:
        for sl in o.links:
            if recursive_node_connect_check(sl, target_node):
                return True
    return False

def connect_to_file_output(node_list, file_out=None, base_path='', excludes=None, remap_names=None, file_format=None):
    """Connect selected nodes output to file output(s)
    if a file output is selected, add intputs on it

    Args:
        node_list (list[bpy.types.Nodes,]): Nodes to connect
    
        file_out (bpy.types.CompositorNode, optional): File output node to connect to instead of new
            Defaults to None
        base_path (str, optional): Directory of images to render.
            if not passed, will use source node layer name
            Defaults to ''.

        file_format (dict, optionnal): converts each dictionary key into a file output format 
            attribute and assigns the corresponding value.
            Defaults to None.

        excludes (dict, optionnal): List of output names to exclude {node_name: [outputs,]}.
            Defaults toNone.

        remap_names (dict, optionnal): List of output names to remap {node_name: {output_name: new_name}}.

        frame (bpy.types.CompositorNode, optional): If given, create nodes into a frame.
            Defaults to None.

    Returns:
        list[bpy.types.CompositorNode]: All nodes created.
    """

    scene = bpy.context.scene
    nodes = scene.node_tree.nodes
    links = scene.node_tree.links
    if not isinstance(node_list, list):
        node_list = [node_list]
    node_list = [n for n in node_list if n.type != 'OUTPUT_FILE']
    if not node_list:
        return

    excludes = excludes or {}

    for node in node_list:
        exclusions = excludes.get(node.name) or []
        ## create one output facing node and connect all
        outs = [o for o in node.outputs if not o.is_unavailable and not 'crypto' in o.name.lower() and o.name not in exclusions]
        cryptout = [o for o in node.outputs if not o.is_unavailable and 'crypto' in o.name.lower() and o.name not in exclusions]
        
        if node.type == 'R_LAYERS':
            out_base = node.layer
        elif node.label:
            out_base = node.label
        else:
            out_base = node.name
        out_base = bpy.path.clean_name(out_base)
        out_name = f'OUT_{out_base}'

        if outs:
            fo = file_out
            if not fo:
                fo = nodes.get(out_name)
            if not fo:
                # color = (0.2,0.3,0.5)
                fo = create_node('CompositorNodeOutputFile', tree=scene.node_tree, location=(real_loc(node)[0]+500, real_loc(node)[1]+50), width=600)
                fo.inputs.remove(fo.inputs[0]) # Remove default image input
                if file_format:
                    for k, v in file_format.items():
                        setattr(fo.format, k, v)
                else:
                    set_file_output_format(fo)

                fo.name = out_name
                if node.parent:
                    fo.parent = node.parent

                if base_path:
                    fo.base_path = base_path
                else:
                    if fo.format.file_format == 'OPEN_EXR_MULTILAYER':
                        fo.base_path = f'//render/{out_base}/{out_base}_'
                    else:
                        fo.base_path = f'//render/{out_base}'

            for o in outs:
                if next((l for l in o.links if recursive_node_connect_check(l, fo)), None):
                    continue
                
                if (socket_remaps := remap_names.get(node.name)) and (custom_name := socket_remaps.get(o.name)):
                    slot_name = bpy.path.clean_name(custom_name) # clean name ?
                else:
                    slot_name = bpy.path.clean_name(o.name)

                # if fo.format.file_format == 'OPEN_EXR_MULTILAYER':
                #     slot_name = slot_name
                # else:
                #     slot_name = f'{slot_name}/{slot_name}_'
                # fo.file_slots.new(slot_name)
                fs = fo.file_slots.new('tmp') # slot_name)
                ls = fo.layer_slots.new('tmp') # slot_name + 'layer')

                ls = fo.layer_slots[-1]
                ls.name = slot_name

                fs = fo.file_slots[-1]
                fs.path = f'{slot_name}/{slot_name}_' # Error 'NodeSocketColor' object has no attribute 'path'
                

                out_input = fo.inputs[-1]
                links.new(o, out_input)

            clear_disconnected(fo)
            fo.update()

        ## Create separate file out for cryptos
        if cryptout:
            out_name += '_cryptos'
            fo = file_out
            if not fo:
                fo = nodes.get(out_name)
            if not fo:
                # color = (0.2,0.3,0.5)
                fo = create_node('CompositorNodeOutputFile', tree=scene.node_tree, location=(real_loc(node)[0]+400, real_loc(node)[1]-200), width=220)
                fo.inputs.remove(fo.inputs[0]) # Remove default image input
                if file_format:
                    for k, v in file_format.items():
                        setattr(fo.format, k, v)
                else:
                    set_file_output_format(fo) # OPEN_EXR_MULTILAYER, RGBA, ZIP
                fo.format.color_depth = '32' # For crypto force 32bit
                
                fo.name = out_name
                if node.parent:
                    fo.parent = node.parent

                if base_path:
                    fo.base_path = base_path
                else:
                    if fo.format.file_format == 'OPEN_EXR_MULTILAYER':
                        ## FIXME: find a better organization for separated crypto pass
                        fo.base_path = f'//render/{out_base}/cryptos/cryptos_'
                    else:
                        fo.base_path = f'//render/{out_base}'

            for o in cryptout:
                ## Skip already connected
                ## TODO Test recusively to find fo (some have interconnected sockets)
                # if next((l for l in o.links if l.to_node == fo), None):
                if next((l for l in o.links if recursive_node_connect_check(l, fo)), None):
                    continue

                # if remap_names and (custom_name := remap_names.get(o.name)):
                if (socket_remaps := remap_names.get(node.name)) and (custom_name := socket_remaps.get(o.name)):
                    slot_name = bpy.path.clean_name(custom_name) # clean name ?
                else:
                    slot_name = bpy.path.clean_name(o.name) # directly use name in multi layer exr

                # if fo.format.file_format == 'OPEN_EXR_MULTILAYER':
                #     slot_name = slot_name
                # else:
                #     slot_name = f'{slot_name}/{slot_name}_'
                # fo.file_slots.new(slot_name)
                
                # Setting both file_slots and layer_slots...
                fs = fo.file_slots.new('tmp')
                ls = fo.layer_slots.new('tmp')

                ls = fo.layer_slots[-1]
                ls.name = slot_name

                fs = fo.file_slots[-1]
                fs.path = f'{slot_name}/{slot_name}_' # Error 'NodeSocketColor' object has no attribute 'path'
                

                out_input = fo.inputs[-1]
                links.new(o, out_input)
            clear_disconnected(fo)
            fo.update()
        
        