"""Build web/world/models/player_anims.glb -- one anim-only GLB carrying every clip the player needs.

Run on the M1 Ultra (the FBX libraries live there). The OUTPUT glb is committed per the copies rule,
so the game itself never needs this box:

    scp tools/character/build_player_anims.py johnking@100.91.239.7:~/Documents/shades-laneD-out/
    ssh johnking@100.91.239.7 '/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
        -P ~/Documents/shades-laneD-out/build_player_anims.py'
    scp johnking@100.91.239.7:~/Documents/shades-laneD-out/player_anims.glb web/world/models/

This is the 90sDJsim peds/idle.glb + peds/walk.glb shape (an anim-only clip carrier, no mesh),
extended to the clip set SHADES needs. The character itself is a byte copy of a ped -- see MODELS.md.

WHY NOT merge the clips onto the ped, character_kit/scripts/merge_anims.py style (which is what
PLAN3D 5-D.1 assumes):

  The ped GLBs encode their metre scale as a NODE scale of 0.01 on mixamorig*:Hips, with every child
  bone in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the
  glTF importer silently drops that 0.01: straight after import the armature already reads
  Hips.head_local=(0,-0.03,0.99) in METRES while HeadTop_End=(0,-4.39,76.88) is in CENTIMETRES, and
  LeftToe_End sits 96 m under the floor. The rig is exploded before a single clip is merged -- the
  peds cannot round-trip through Blender at all. (merge_anims.py works because its base,
  Hum_M_1_mixamo.fbx, is an FBX carrying no such trick -- and that base is exactly why dancer.glb
  lands 30x small, head bone at 0.0563 m.)

  So: never rebuild the character. Ship the ped untouched and carry the clips beside it.

Bind pose here is deliberately IRRELEVANT: the carrier rig exists only to hold actions. player.js
lifts g.animations off this file, canonicalises the bone namespace, and keeps rotation tracks only
(quaternions are scale-invariant, so a carrier at any scale retargets cleanly onto the ped). This
file's own skeleton is never rendered and its scale is never trusted.
"""
import bpy, os, re, sys

HOME = os.path.expanduser("~")
ANIM = f"{HOME}/Documents/3D=models/animations"
FBX = f"{HOME}/Documents/FBX"
OUT = f"{HOME}/Documents/shades-laneD-out/player_anims.glb"

# (clip name in-game, source fbx). These names are the contract with player.js CLIPS.
# Locomotion is in-place; the game translates the player itself (PLAN3D 5-D.2).
CLIPS = [
    ("Idle",          f"{ANIM}/Happy Idle.fbx"),
    ("Walk",          f"{ANIM}/Walk.fbx"),
    ("Run",           f"{FBX}/Running.fbx"),
    ("Falling",       f"{FBX}/Falling.fbx"),      # limb flail; player.js pitches the root itself
    ("CrouchToStand", f"{FBX}/Crouch To Stand.fbx"),
    ("Reaction",      f"{FBX}/Reaction.fbx"),     # stagger on a debris glance
    # --- M3 verbs, fetched from Mixamo 2026-07-16 (see tools/character/mixamo_wishlist.txt).
    # Substitutions where Mixamo has no such clip: Turning Key → Pulling Lever,
    # Standing Up Ready → Standing Up, Covering Head → Taking Cover.
    # Hammering / Sweeping Floor / Bracing don't exist on Mixamo — skipped.
    ("ClimbLadder",   f"{FBX}/Climbing Ladder.fbx"),
    ("Crank",         f"{FBX}/Pulling Lever.fbx"),      # turnbuckle work
    ("Dig",           f"{FBX}/Digging.fbx"),
    ("PickUp",        f"{FBX}/Picking Up Object.fbx"),
    ("Carry",         f"{FBX}/Carrying.fbx"),
    ("CarryTurn",     f"{FBX}/Carrying Turn.fbx"),
    ("CarryIdle",     f"{FBX}/Box Idle.fbx"),
    ("StandUp",       f"{FBX}/Standing Up.fbx"),
    ("TakeCover",     f"{FBX}/Taking Cover.fbx"),       # hail/debris shelter
    ("StumbleBack",   f"{FBX}/Stumble Backwards.fbx"),
    ("PlantSeeds",    f"{FBX}/Dig And Plant Seeds.fbx"),# garden repair verb
]

PREFIX_RE = re.compile(r"mixamorig\d*:")


def imported(fn):
    """Run an import op; return (new objects, new actions)."""
    before_o, before_a = set(bpy.data.objects), set(bpy.data.actions)
    fn()
    return set(bpy.data.objects) - before_o, list(set(bpy.data.actions) - before_a)


# Blender 4.4+ ("slotted actions") moved fcurves from action.fcurves into
# action.layers[].strips[].channelbags[].fcurves, and dropped action.fcurves outright by 5.0
# (which is why merge_anims.py no longer runs there as written). Support both layouts.
def fcurve_owners(act):
    """Yield (owning collection, fcurve) so callers can both read and remove."""
    if hasattr(act, "fcurves"):
        for fc in list(act.fcurves):
            yield act.fcurves, fc
        return
    for layer in act.layers:
        for strip in layer.strips:
            for bag in strip.channelbags:
                for fc in list(bag.fcurves):
                    yield bag.fcurves, fc


def fcurve_groups(act):
    if hasattr(act, "groups"):
        yield from act.groups
        return
    for layer in act.layers:
        for strip in layer.strips:
            for bag in strip.channelbags:
                yield from bag.groups


bpy.ops.wm.read_factory_settings(use_empty=True)
print("\n===BUILD===")

carrier = None
prefix = None
valid = set()
actions = []

for name, src in CLIPS:
    if not os.path.exists(src):
        print(f"  ! MISSING {name:<14} {src}")
        continue
    objs, acts = imported(lambda s=src: bpy.ops.import_scene.fbx(filepath=s, automatic_bone_orientation=True))
    if not acts:
        print(f"  ! NO ACTION {name:<12} {os.path.basename(src)}")
    keep = None
    if carrier is None:  # the first clip donates its armature as the carrier; its meshes are dropped
        keep = next((o for o in objs if o.type == "ARMATURE"), None)
        if keep is None:
            sys.exit(f"FATAL: no armature in {src}")
        carrier = keep
        m = next((PREFIX_RE.match(b.name) for b in carrier.pose.bones if PREFIX_RE.match(b.name)), None)
        if not m:
            sys.exit(f"FATAL: carrier has no mixamorig namespace: {[b.name for b in carrier.pose.bones][:4]}")
        prefix = m.group(0)
        valid = {b.name for b in carrier.pose.bones}
        print(f"carrier   : {os.path.basename(src)}  armature={carrier.name}  "
              f"bones={len(valid)}  namespace={prefix!r}")
    for act in acts:
        act.name = name
        act.use_fake_user = True
        kept = dropped = 0
        for coll, fc in fcurve_owners(act):
            fc.data_path = PREFIX_RE.sub(prefix, fc.data_path)   # retarget onto the carrier namespace
            bone = re.search(r'pose\.bones\["([^"]+)"\]', fc.data_path)
            if bone and bone.group(1) not in valid:
                coll.remove(fc)
                dropped += 1
            else:
                kept += 1
        for g in fcurve_groups(act):
            g.name = PREFIX_RE.sub(prefix, g.name)
        actions.append(act)
        print(f"  + {name:<14} {act.frame_range[1] - act.frame_range[0]:6.1f}f  fcurves={kept}"
              + (f"  (dropped {dropped} unbindable)" if dropped else ""))
    for o in objs:  # keep the action (and the carrier); bin every mesh and every donor rig
        if o is not keep:
            bpy.data.objects.remove(o, do_unlink=True)

if not actions:
    sys.exit("FATAL: no clips merged")

# --- one NLA track per action: the glTF exporter emits one animation per track ---
if not carrier.animation_data:
    carrier.animation_data_create()
carrier.animation_data.action = None
for act in actions:
    tr = carrier.animation_data.nla_tracks.new()
    tr.name = act.name
    strip = tr.strips.new(act.name, int(act.frame_range[0]), act)
    # Blender 4.4+ slotted actions: a strip with no slot exports as an empty animation
    if hasattr(strip, "action_slot") and strip.action_slot is None and getattr(act, "slots", None):
        strip.action_slot = act.slots[0]
print(f"NLA tracks: {len(carrier.animation_data.nla_tracks)}")

os.makedirs(os.path.dirname(OUT), exist_ok=True)
bpy.ops.object.select_all(action="DESELECT")
carrier.select_set(True)
bpy.ops.export_scene.gltf(
    filepath=OUT, export_format="GLB",
    use_selection=True,          # carrier only -- no meshes reach the file
    export_animations=True,
    export_skins=False,          # nothing is skinned to this rig; it is a clip carrier
    export_animation_mode="NLA_TRACKS",
    export_apply=False,
)
print(f"exported {OUT} ({os.path.getsize(OUT) // 1024} kB)")
print("===ENDBUILD===")
