"""Skeleton-guided tail labels and weights. Fused-web surgery is disabled.
Blender entrypoint: -- input.glb output.glb evidence.json
No source file is changed. Ambiguous/absent tail anatomy is a hard failure.
"""
import bpy,bmesh,json,sys,math,numpy as np
from pathlib import Path
from mathutils import Vector
from mathutils.kdtree import KDTree

def repair(rig, meshes):
    bones={b.name:b for b in rig.data.bones}
    tails=[b for b in bones.values() if 'tail' in b.name.lower()]
    if not tails: raise ValueError('No named tail chain; cannot safely separate anatomy')
    roots=[b for b in tails if b.parent not in tails]
    if len(roots)!=1: raise ValueError('Ambiguous tail chains')
    root=rig.matrix_world@roots[0].head_local
    legs=[b for b in bones.values() if any(s in b.name.lower() for s in ('leg','foot','ff.','ffb.')) and not any(s in b.name.lower() for s in ('pole','target'))]
    rear=[b for b in legs if 'backupperleg' in b.name.lower()]
    if len(rear)!=2: raise ValueError('Expected two named BackUpperLeg bones; unsupported skeleton')
    hip=sum((rig.matrix_world@b.head_local for b in rear),Vector())/2
    backward=root-hip;backward.z=0;backward.normalize()
    if backward.length<.5:raise ValueError('Cannot determine body facing from tail and hips')
    lateral=Vector((-backward.y,backward.x,0))
    allpoints=[o.matrix_world@v.co for o in meshes for v in o.data.vertices]
    height=max(p.z for p in allpoints)-min(p.z for p in allpoints)
    tailnames={b.name for b in tails};legnames={b.name for b in legs}
    totals={'tail_bones':sorted(tailnames),'leg_bones':sorted(legnames),'root':list(root),'height_m':height,'meshes':[]}
    for o in meshes:
        world=o.matrix_world.copy();inv=world.inverted()
        original_points=[world@v.co for v in o.data.vertices]
        original_weights=[{o.vertex_groups[g.group].name:g.weight for g in v.groups} for v in o.data.vertices]
        oldkd=KDTree(len(original_points))
        for i,p in enumerate(original_points):oldkd.insert(p,i)
        oldkd.balance()
        bm=bmesh.new();bm.from_mesh(o.data)
        bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=height*1e-6)
        before_boundary=sum(e.is_boundary for e in bm.edges)
        sealed_source=0
        bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
        bm.to_mesh(o.data);bm.free()
        points=[world@v.co for v in o.data.vertices]
        cutoffset=.075
        def threshold(p):return height*cutoffset+max(0,root.z-p.z)*.20
        def is_tail(p):return (p-root).dot(backward)>threshold(p)
        core=[p for p in points if is_tail(p)]
        if len(core)<20:raise ValueError('No tail corridor was detected')
        rear_span=max((p-root).dot(backward) for p in core)
        tip=[p.z for p in core if (p-root).dot(backward)>rear_span*.85]
        down_span=root.z-float(np.median(tip))
        hanging=down_span>rear_span*.75
        cutoffset=.025 if hanging else .075
        cut_faces=0;caps=[]
        preset=o.data.attributes.get('_TAIL_REGION')
        if hanging and not preset:raise ValueError('Hanging tail requires a new separated reference; fused-mesh surgery is disabled')
        bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
        sealed_after=0;bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));boundary_after=sum(e.is_boundary for e in bm.edges);bm.to_mesh(o.data);bm.free();o.data.update()
        preset=o.data.attributes.get('_TAIL_REGION')
        regions=[preset.data[v.index].value>.5 if preset else is_tail(world@v.co) for v in o.data.vertices]
        for a in list(o.data.attributes):
            if a.name=='_TAIL_REGION':o.data.attributes.remove(a)
        geo=o.data.attributes.new('_TAIL_REGION','FLOAT','POINT')
        for item,value in zip(geo.data,regions):item.value=float(value)
        old=[]
        for v in o.data.vertices:
            _,idx,_=oldkd.find(world@v.co);old.append(original_weights[idx])
        for b in bones:
            if not o.vertex_groups.get(b):o.vertex_groups.new(name=b)
        adjacency=[set() for v in o.data.vertices]
        for e in o.data.edges:
            a,b=e.vertices
            if regions[a]==regions[b]:adjacency[a].add(b);adjacency[b].add(a)
        rigid_attr=o.data.attributes.get('_HEAD_RIGID')
        rigid=[bool(rigid_attr and rigid_attr.data[v.index].value>.5) for v in o.data.vertices]
        weights=[]
        for v,istail,ww in zip(o.data.vertices,regions,old):
            p=world@v.co
            if istail:
                # Tail root is deliberately rigid in this cut-only pass. The source
                # hanging tail does not follow the donor's raised rest-chain curve.
                if hanging and not preset:ww={roots[0].name:1.}
                else:
                    # A pose-matched generated tail follows the actual donor chain.
                    ds=[]
                    for b in tails:
                        a=rig.matrix_world@b.head_local;c=rig.matrix_world@b.tail_local;delta=c-a
                        t=max(0,min(1,(p-a).dot(delta)/max(delta.length_squared,1e-12)))
                        ds.append((b.name,(p-(a+delta*t)).length))
                    closest=sorted(ds,key=lambda q:q[1])[:2];ww={n:1/max(dist,height*.015)**3 for n,dist in closest}
            else:
                ww={n:w for n,w in ww.items() if n not in tailnames}
                if p.z<height*.40:
                    only={n:w for n,w in ww.items() if n in legnames}
                    if sum(only.values())>.05:ww=only
                if not ww:ww={rear[0].parent.name:1.}
            if rigid[v.index]:ww={'Head':1.}
            s=sum(ww.values());weights.append({n:w/s for n,w in ww.items()})
        # Smooth within geometric groups only. Tail weights cannot leak to legs.
        for _ in range(2):
            new=[]
            for i,ww in enumerate(weights):
                if rigid[i]:new.append({'Head':1.});continue
                eligible=[j for j in adjacency[i] if (set(weights[j])<=legnames)==(set(ww)<=legnames)]
                if not eligible:new.append(ww);continue
                result={n:w*.75 for n,w in ww.items()}
                for j in eligible:
                    for n,w in weights[j].items():result[n]=result.get(n,0)+w*.25/len(eligible)
                top=sorted(result.items(),key=lambda t:-t[1])[:4];s=sum(w for _,w in top);new.append({n:w/s for n,w in top})
            weights=new
        for vg in o.vertex_groups:vg.remove(list(range(len(o.data.vertices))))
        tail_on_leg=leg_on_tail=0.;tailcount=legcount=0
        for i,(ww,istail) in enumerate(zip(weights,regions)):
            for n,w in ww.items():o.vertex_groups[n].add([i],w,'REPLACE')
            if istail:tailcount+=1;leg_on_tail=max(leg_on_tail,sum(w for n,w in ww.items() if n in legnames))
            else:legcount+=1;tail_on_leg=max(tail_on_leg,sum(w for n,w in ww.items() if n in tailnames))
        totals['meshes'].append({'mesh':o.name,'cut_faces':cut_faces,'source_hole_cap_triangles':sealed_source,'post_cut_cap_triangles':sealed_after,'hanging_tail':hanging,'cap_faces':len(caps),'boundary_edges_before':before_boundary,'boundary_edges_after':boundary_after,'tail_vertices':tailcount,'non_tail_vertices':legcount,'max_tail_weight_on_non_tail':tail_on_leg,'max_leg_weight_on_tail':leg_on_tail,'tail_binding':'two nearest tail-chain segments','clearance_shift_max_m':height*.055})
        if tailcount<20:raise ValueError('Tail labels lost during topology operation')
        # No cutting occurs: the source must already have a free tail.
        if boundary_after>before_boundary:raise ValueError(f'Tail cut introduced open boundary edges: {before_boundary} -> {boundary_after}')
    return totals

if __name__=='__main__':
    src,out,report=map(Path,sys.argv[sys.argv.index('--')+1:])
    if out.exists():raise FileExistsError(out)
    bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
    bpy.ops.import_scene.gltf(filepath=str(src));rig=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
    meshes=[o for o in bpy.context.scene.objects if o.type=='MESH' and any(m.type=='ARMATURE' for m in o.modifiers)]
    rig.data.pose_position='REST';bpy.context.view_layer.update()
    data=repair(rig,meshes)
    sys.path.insert(0,str(Path(__file__).parent))
    from fragments import clean
    data['fragments_after'],meshes=clean(meshes,rig)
    if not data['fragments_after']['pass']:raise ValueError('Detached islands remain after tail stage')
    rig.data.pose_position='POSE'
    bpy.ops.object.select_all(action='DESELECT')
    for o in bpy.context.scene.objects:
        if o.type in ['ARMATURE','EMPTY'] or o in meshes:o.select_set(True)
    bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,export_yup=True,export_image_format='JPEG',export_jpeg_quality=86,export_animations=True,export_animation_mode='ACTIONS',export_anim_single_armature=True,export_force_sampling=True,export_optimize_animation_size=True,export_influence_nb=4,export_all_influences=False,export_leaf_bone=False,export_attributes=True)
    report.write_text(json.dumps(data,indent=2))
