"""Raton-poubelle: rebuild the in-game GLB from the Tripo export.

The Tripo model (sources/trash_panda_tripo_source.glb) ships a Mixamo rig and
thirteen clips, but no tail bones: the tail was skinned to the hips and both
thighs, so it wobbled with every stride. This script:

1. drops the stray 2 m icosphere shipped in the export;
2. adds a four-bone tail chain under the hips and moves the tail vertices onto it;
3. builds the six clips the trash_panda sheet expects (Idle, Walk, Bite, Throw,
   Hurt, Death) from Tripo clips, plus a keyed Bite, each with a tail layer;
4. exports assets/models/enemies/trash_panda/trash_panda.glb and saves a .blend.

Run headless from big-egg-3d/:
  blender --background --factory-startup --python tools/blender/trash_panda_tripo.py -- [--preview DIR]
"""
import bpy, math, os, sys
from mathutils import Vector, Quaternion

HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(os.path.dirname(HERE))
SOURCE = os.path.join(HERE, "sources", "trash_panda_tripo_source.glb")
BLEND = os.path.join(HERE, "trash_panda_tripo.blend")
EXPORT = os.path.join(PROJECT, "assets", "models", "enemies", "trash_panda", "trash_panda.glb")
FPS = 24
MX = "mixamorig:"

# Tail centreline read from side and back renders (Blender space: model faces -Y).
TAIL_POINTS = [Vector(p) for p in ((0, 0.06, 0.47), (0, 0.15, 0.40), (0, 0.18, 0.28), (0, 0.16, 0.15), (0, 0.15, 0.02))]
TAIL_BONES = ["Tail_0", "Tail_1", "Tail_2", "Tail_3"]
TAIL_RADIUS = 0.08
# Body capsules competing with the tail for the vertices behind the hips.
BODY_CHAINS = [
    (["Hips", "Spine", "Spine1", "Spine2", "Neck"], 0.15),
    (["LeftUpLeg", "LeftLeg", "LeftFoot"], 0.07),
    (["RightUpLeg", "RightLeg", "RightFoot"], 0.07),
]


def smoothstep(a, b, x):
    t = min(1.0, max(0.0, (x - a) / (b - a)))
    return t * t * (3 - 2 * t)


def closest_on_polyline(p, pts):
    """Distance to the polyline, and arc position s (segment index + fraction)."""
    best = (1e9, 0.0)
    for k in range(len(pts) - 1):
        a, b = pts[k], pts[k + 1]
        ab = b - a
        u = max(0.0, min(1.0, (p - a).dot(ab) / ab.length_squared))
        d = (p - (a + ab * u)).length
        if d < best[0]:
            best = (d, k + u)
    return best


# ---------------------------------------------------------------- import

def load():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    bpy.context.scene.render.fps = FPS
    bpy.ops.import_scene.gltf(filepath=SOURCE)
    stray = bpy.data.objects.get("Icosphere")
    if stray and stray.type == "MESH" and stray.parent is None:
        bpy.data.objects.remove(stray)
    arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
    mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
    arm.name = "TrashPandaRig"
    mesh.name = "TrashPanda"
    return arm, mesh


# ---------------------------------------------------------------- tail rig

def add_tail_bones(arm):
    bpy.context.view_layer.objects.active = arm
    arm.select_set(True)
    bpy.ops.object.mode_set(mode="EDIT")
    eb = arm.data.edit_bones
    inv = arm.matrix_world.inverted()
    parent = eb[MX + "Hips"]
    for k, name in enumerate(TAIL_BONES):
        b = eb.new(name)
        b.head = inv @ TAIL_POINTS[k]
        b.tail = inv @ TAIL_POINTS[k + 1]
        b.roll = 0.0
        b.parent = parent
        b.use_connect = k > 0
        parent = b
    bpy.ops.object.mode_set(mode="OBJECT")


def skin_tail(arm, mesh):
    bone_pts = {b.name.replace(MX, ""): arm.matrix_world @ b.head_local for b in arm.data.bones}
    chains = [([bone_pts[n] for n in names], r) for names, r in BODY_CHAINS]
    groups = {name: mesh.vertex_groups.get(name) or mesh.vertex_groups.new(name=name) for name in TAIL_BONES}
    mw = mesh.matrix_world
    shares = []
    for v in mesh.data.vertices:
        p = mw @ v.co
        if p.z > 0.55 or p.y < -0.02:
            shares.append(0.0)
            continue
        d_t, s = closest_on_polyline(p, TAIL_POINTS)
        d_b = min(closest_on_polyline(p, pts)[0] - r for pts, r in chains)
        margin = d_b - (d_t - TAIL_RADIUS)
        t = smoothstep(-0.02, 0.03, margin)
        # The root of the tail stays partly on the hips so the join does not crease.
        t *= 0.4 + 0.6 * smoothstep(0.0, 0.6, s)
        if t <= 0.0:
            shares.append(0.0)
            continue
        for g in v.groups:
            g.weight *= (1.0 - t)
        sc = min(max(s, 0.5), len(TAIL_BONES) - 0.5)
        ws = [max(0.0, 1.0 - abs(sc - (k + 0.5))) for k in range(len(TAIL_BONES))]
        tot = sum(ws)
        for k, w in enumerate(ws):
            if w > 0:
                groups[TAIL_BONES[k]].add([v.index], t * w / tot, "REPLACE")
        shares.append(t)
    bpy.context.view_layer.objects.active = mesh
    for o in bpy.context.selected_objects:
        o.select_set(False)
    mesh.select_set(True)
    bpy.ops.object.vertex_group_limit_total(group_select_mode="ALL", limit=4)
    bpy.ops.object.vertex_group_normalize_all(group_select_mode="ALL", lock_active=False)
    return shares


# ---------------------------------------------------------------- clip helpers

def sample(arm, action_name, frames):
    """Pose of every bone at each frame of a Tripo clip, as local channels."""
    act = bpy.data.actions[action_name]
    arm.animation_data.action = act
    arm.animation_data.action_slot = act.slots[0]
    out = []
    for f in frames:
        bpy.context.scene.frame_set(f)
        out.append({pb.name: (pb.location.copy(), pb.rotation_quaternion.copy(), pb.scale.copy()) for pb in arm.pose.bones})
    return out


def local_axis_rot(arm, bone_name, world_axis, degrees):
    """Rotation about an armature-space axis, expressed in the bone's rest frame."""
    m = arm.data.bones[bone_name].matrix_local.to_3x3().inverted()
    return Quaternion((m @ Vector(world_axis)).normalized(), math.radians(degrees))


def tail_axes(arm):
    """Per tail bone: sideways-swing axis and lift axis, in armature space."""
    axes = []
    for name in TAIL_BONES:
        b = arm.data.bones[name]
        d = (b.tail_local - b.head_local).normalized()
        side = d.cross(Vector((1, 0, 0))).normalized()
        axes.append((side, Vector((1, 0, 0))))
    return axes


def write_action(arm, name, poses):
    """Create an action keyed on every bone at every frame (no inherited pose)."""
    from bpy_extras import anim_utils
    act = bpy.data.actions.new(name)
    act.use_fake_user = True
    slot = act.slots.new(id_type="OBJECT", name=arm.name)
    bag = anim_utils.action_ensure_channelbag_for_slot(act, slot)
    n = len(poses)
    for pb in arm.pose.bones:
        base = f'pose.bones["{pb.name}"]'
        # Keep quaternion signs continuous so interpolation never takes the long way round.
        quats = []
        for pose in poses:
            q = pose[pb.name][1].copy()
            if quats and quats[-1].dot(q) < 0:
                q.negate()
            quats.append(q)
        channels = (("location", 3, lambda i, c: poses[i][pb.name][0][c]),
                    ("rotation_quaternion", 4, lambda i, c: quats[i][c]),
                    ("scale", 3, lambda i, c: poses[i][pb.name][2][c]))
        for prop, count, get in channels:
            for c in range(count):
                fc = bag.fcurves.new(f"{base}.{prop}", index=c, group_name=pb.name)
                fc.keyframe_points.add(n)
                co = []
                for i in range(n):
                    co += [float(i), float(get(i, c))]
                fc.keyframe_points.foreach_set("co", co)
                fc.keyframe_points.foreach_set("interpolation", [1] * n)  # LINEAR
                fc.update()
    return act


def with_tail(arm, poses, curve):
    """curve(frame_index, bone_index) -> (side_degrees, lift_degrees)."""
    axes = tail_axes(arm)
    for i, pose in enumerate(poses):
        for k, name in enumerate(TAIL_BONES):
            side, lift = curve(i, k)
            q = local_axis_rot(arm, name, axes[k][0], side) @ local_axis_rot(arm, name, axes[k][1], lift)
            loc, _, scl = pose.get(name, (Vector(), Quaternion(), Vector((1, 1, 1))))
            pose[name] = (loc, q, scl)
    return poses


def sway(amplitude, cycles, n, lag=0.7, lift=0.0, lift_cycles=0):
    """Looping tail swing: `cycles` full swings over the clip, growing toward the tip."""
    def curve(i, k):
        t = i / n
        grow = 0.45 + 0.35 * k
        s = amplitude * grow * math.sin(2 * math.pi * cycles * t - k * lag)
        l = lift * grow * math.sin(2 * math.pi * lift_cycles * t - k * lag) if lift_cycles else 0.0
        return s, l
    return curve


def envelope(keys, i):
    """Piecewise-smooth value from [(frame, value), ...]."""
    if i <= keys[0][0]:
        return keys[0][1]
    for (f0, v0), (f1, v1) in zip(keys, keys[1:]):
        if i <= f1:
            u = smoothstep(f0, f1, i)
            return v0 + (v1 - v0) * u
    return keys[-1][1]


# ---------------------------------------------------------------- clips

def recenter_hips(arm, poses):
    """Cancel the constant forward offset baked into Tripo's run clip."""
    hips = arm.data.bones[MX + "Hips"]
    m = (arm.matrix_world @ hips.matrix_local).to_3x3()
    world = [m @ p[MX + "Hips"][0] for p in poses]
    mean = sum(world, Vector()) / len(world)
    fix = m.inverted() @ Vector((-mean.x, -mean.y, 0.0))
    for p in poses:
        loc, q, s = p[MX + "Hips"]
        p[MX + "Hips"] = (loc + fix, q, s)
    return poses


def build_bite(arm, base):
    """Snap forward: coil back, lunge with head and claws, recover. 20 frames."""
    X = (1, 0, 0)  # +X leans forward what sits above the pivot (model faces -Y)
    # (frame, value) tracks per bone, in degrees about X.
    tracks = {
        "Spine": [(0, 0), (5, -8), (9, 7), (12, 7), (20, 0)],
        "Spine1": [(0, 0), (5, -6), (9, 5), (12, 5), (20, 0)],
        "Spine2": [(0, 0), (5, -4), (9, 3), (12, 3), (20, 0)],
        "Neck": [(0, 0), (5, -8), (9, 4), (12, 4), (20, 0)],
        # Counter the torso lean so the muzzle, not the crown, leads the snap.
        "Head": [(0, 0), (5, -14), (9, -12), (10, -6), (12, -10), (20, 0)],
        "LeftArm": [(0, 0), (5, 20), (9, -55), (12, -50), (20, 0)],
        "RightArm": [(0, 0), (5, 20), (9, -55), (12, -50), (20, 0)],
        "LeftForeArm": [(0, 0), (5, -30), (9, -10), (12, -15), (20, 0)],
        "RightForeArm": [(0, 0), (5, -30), (9, -10), (12, -15), (20, 0)],
    }
    # The hips lunge a few centimetres forward and down with the strike.
    lunge = [(0, 0), (5, -0.3), (9, 1), (12, 1), (20, 0)]
    hips = arm.data.bones[MX + "Hips"]
    to_local = (arm.matrix_world @ hips.matrix_local).to_3x3().inverted()
    poses = []
    for i in range(21):
        pose = dict(base)
        for bone, keys in tracks.items():
            name = MX + bone
            loc, q, s = base[name]
            pose[name] = (loc, local_axis_rot(arm, name, X, envelope(keys, i)) @ q, s)
        loc, q, s = base[MX + "Hips"]
        pose[MX + "Hips"] = (loc + to_local @ (Vector((0, -0.11, -0.02)) * envelope(lunge, i)), q, s)
        poses.append(pose)
    lift = [(0, 0), (5, 18), (9, -10), (13, 0), (20, 0)]
    side = [(0, 0), (9, 0), (12, 10), (16, -6), (20, 0)]
    return with_tail(arm, poses, lambda i, k: (envelope(side, i - k) * (0.5 + 0.3 * k), envelope(lift, i - k) * (0.6 + 0.2 * k)))


def lift_head(arm, poses, neck, head):
    """Raise the muzzle: the game camera looks down at 52 degrees and otherwise
    sees only the crown of a raccoon whose Tripo poses look at the ground."""
    for pose in poses:
        for bone, degrees in ((MX + "Neck", -neck), (MX + "Head", -head)):
            loc, q, s = pose[bone]
            pose[bone] = (loc, local_axis_rot(arm, bone, (1, 0, 0), degrees) @ q, s)
    return poses


def build_clips(arm):
    clips = {}
    n = 368
    idle = sample(arm, "idle", range(0, n + 1))  # last frame repeats the first: seamless loop
    clips["Idle"] = with_tail(arm, idle, sway(5.0, 5, n, lift=2.0, lift_cycles=3))
    run = recenter_hips(arm, sample(arm, "run", range(0, 31)))
    clips["Walk"] = with_tail(arm, run, sway(11.0, 1, 30, lag=0.9, lift=5.0, lift_cycles=2))
    base = sample(arm, "idle", [0])[0]
    clips["Bite"] = build_bite(arm, base)
    throw = sample(arm, "pitch_baseball", range(4, 59))
    env = [(0, 0), (14, -1), (30, 1), (40, -1), (54, 0)]
    clips["Throw"] = with_tail(arm, throw, lambda i, k: (14 * envelope(env, i - 2 * k) * (0.5 + 0.3 * k), 0.0))
    hurt = sample(arm, "hit_to_body_01", range(0, 32))
    flick = [(0, 0), (4, 22), (10, -6), (18, 3), (31, 0)]
    clips["Hurt"] = with_tail(arm, hurt, lambda i, k: (4 * math.sin(i * 0.5 - k), envelope(flick, i - k) * (0.6 + 0.2 * k)))
    fall = sample(arm, "fall", range(0, 73))
    limp = [(0, 0), (20, 1), (45, 0.3), (72, 0)]
    clips["Death"] = with_tail(arm, fall, lambda i, k: (12 * envelope(limp, i) * math.sin(i * 0.35 - k * 0.8), 10 * envelope(limp, i - k)))
    # Death ends on its back, face up: it needs no lift. Bite starts from the
    # lifted idle pose so the two clips blend without the head dipping.
    for name, (neck, head) in {"Idle": (6, 10), "Walk": (12, 16), "Bite": (6, 10),
                               "Throw": (4, 6), "Hurt": (5, 8)}.items():
        lift_head(arm, clips[name], neck, head)
    for tripo in list(bpy.data.actions):
        bpy.data.actions.remove(tripo)
    for name, poses in clips.items():
        write_action(arm, name, poses)
    arm.animation_data.action = bpy.data.actions["Idle"]
    arm.animation_data.action_slot = bpy.data.actions["Idle"].slots[0]
    return clips


def export(arm, mesh):
    for o in bpy.context.scene.objects:
        o.select_set(o in (arm, mesh))
    bpy.context.view_layer.objects.active = arm
    bpy.ops.export_scene.gltf(
        filepath=EXPORT, export_format="GLB", use_selection=True, use_active_scene=True,
        export_animation_mode="ACTIONS", export_animations=True, export_force_sampling=True,
        export_frame_step=1, export_anim_single_armature=True, export_image_format="AUTO",
        export_yup=True, export_apply=False)


if __name__ == "__main__":
    args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    arm, mesh = load()
    add_tail_bones(arm)
    shares = skin_tail(arm, mesh)
    print("TAIL_VERTS", sum(1 for s in shares if s > 0.5), "of", len(shares))
    build_clips(arm)
    if "--preview" in args:
        # Tail share as a vertex colour for weight renders.
        attr = mesh.data.color_attributes.new("tail_share", "FLOAT_COLOR", "POINT")
        for i, s in enumerate(shares):
            attr.data[i].color = (s, 0.15, 1.0 - s, 1.0)
        mesh.data.color_attributes.active_color = attr
        bpy.ops.wm.save_as_mainfile(filepath=os.path.join(args[args.index("--preview") + 1], "preview.blend"))
    else:
        bpy.ops.wm.save_as_mainfile(filepath=BLEND)
        export(arm, mesh)
        print("EXPORTED", EXPORT, os.path.getsize(EXPORT))
