#!/usr/bin/env python3
"""Render Exercise Library visuals from an anatomical 3D rig.

A skeleton profile (skeleton.json: bone lengths incl. shoulder/pelvis widths
and feet, plus per-joint ROM) and a per-exercise motion script (motion.json:
key frames of anatomical joint angles - flexion/abduction/rotation measured
from neutral standing - a root anchor + trunk orientation, optional IK pins,
timing, and a camera) resolve through 3D forward kinematics and orthographic
projection into stick-figure frames. See SYSTEM.md for the format and the
visual language, kinematics.py for the math and conventions.

Outputs per exercise: frames/frame-N.svg, preview.gif (tweened, looping),
visual.svg (the primary frame). `--sheet` writes contact-sheet.png of every
key frame; `--demo` writes demo-sheet.png showing rig customizations (body
profiles, flipped camera, theme); `--orbit` writes orbit.gif per named
exercise (the camera sweeps 360 degrees while the motion loops — props
rotate with the figure). `--export` copies the app's bundled resources
verbatim; `--fixtures` regenerates the projected-geometry snapshots pinning
the in-app Swift solver to this pipeline. `--check` is compute-only: it
simulates the full tween loop per exercise and fails hard on solver
inconsistencies (pin drift, teleports, angle wraps, authored-vs-resolved
pose contradictions, sunk geometry, resolved ROM) — `--export` always runs
it first and refuses to ship a failing exercise. SVGs need no dependencies;
GIFs/sheets need Pillow.
"""

import copy
import json
import math
import sys
from pathlib import Path

import kinematics as K

LIB = Path(__file__).parent
CANVAS = (320, 180)
GROUND_Y = 152
# The default viewpoint is slightly elevated: the camera pitches down a
# touch, so the floor reads as a plane (drawn as a rectangle) instead of a
# line. Motions can override via "camera": {"pitch": ...}.
CAMERA_PITCH = 10.0
FLOOR_HALF_DEPTH = 30.0

PALETTES = {
    "default": {
        "right": "#3a3f4b", "left": "#a9afba",
        "right_working": "#0d9488", "left_working": "#86cfc5",
        "ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
        "equipment": "#c5cad4", "prop": "#6b7180",
    },
    "indigo": {
        "right": "#3a3f4b", "left": "#a9afba",
        "right_working": "#4f46e5", "left_working": "#a5b4fc",
        "ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
        "equipment": "#c5cad4", "prop": "#6b7180",
    },
}
WIDTHS = {"near": 6, "far": 5, "spine": 6, "head": 6, "nose": 4}

# Depth shading is a smooth per-vertex gradient, not a near/far switch: every
# drawn joint is blended between the near and far colors by its own camera depth
# relative to its limb pair's central plane, so the ink flows along a bone that
# reaches in depth and fades to a shared mid-tone where the two members cross
# (face-on, or mid-orbit) instead of snapping. A joint reaches full contrast
# once it sits this fraction of the shoulder/pelvis half-width in front of (or
# behind) that plane, so a straight limb in profile is still fully dark/light.
SHADE_SPAN_FRAC = 0.6

# Draw order is by camera depth (far parts first, head always on top, filled
# opaque so overhead arms are occluded by the face). Depths are bucketed so
# side views stay stable; ties fall back to this fixed rank.
FIXED_RANK = {"arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4}
DEPTH_BUCKET = 3.0

PAIRS = (("arm_r", "arm_l"), ("leg_r", "leg_l"))

# Prop joint refs -> (limb, chain index): extremities are index 2, mid joints
# (elbows/knees) index 1, so equipment can ride either joint.
JOINT_LIMB = {
    "hand_r": ("arm_r", 2), "elbow_r": ("arm_r", 1),
    "hand_l": ("arm_l", 2), "elbow_l": ("arm_l", 1),
    "foot_r": ("leg_r", 2), "knee_r": ("leg_r", 1),
    "foot_l": ("leg_l", 2), "knee_l": ("leg_l", 1),
}


def profiles():
    return K.load_skeleton()["profiles"]


def dirv(deg):
    """Unit vector for a y-up angle, in y-down canvas coordinates."""
    r = math.radians(deg)
    return (math.cos(r), -math.sin(r))


def angle_of(a, b):
    """Y-up world angle of the canvas segment a->b."""
    return math.degrees(math.atan2(-(b[1] - a[1]), b[0] - a[0]))


# ------------------------------------------------------------------- solver

def mirror_frame(nf, width):
    """Mirror a normalized frame's canvas anchors for the flipped camera."""
    out = copy.deepcopy(nf)
    out["root"]["pos"][0] = width - out["root"]["pos"][0]
    out["pins"] = {k: [width - x, y] for k, (x, y) in out["pins"].items()}
    return out


def _chain_depth(pts):
    return sum(p[2] for p in pts) / len(pts)


def _bucket(depth):
    return round(depth / DEPTH_BUCKET)


def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH, mat=None):
    """Resolve one normalized frame into drawable 2D geometry.

    Returns (nf with IK-resolved angles and original pins, geo, order, shade):
    geo maps parts to canvas points, order is the depth-sorted draw order,
    shade maps each limb to "near"/"far" (near pair members draw dark and in
    front - the visual language; canvas-right wins depth ties in face-on
    views). The far member of each pair also gets the profile's readability
    offset, scaled by how side-on the view is, so overlapping limbs stay
    distinguishable in profile views.
    """
    p0 = K.pose(nf, prof, cam, pitch)
    shade = {}
    for right, left in PAIRS:
        dr, dl = _chain_depth(p0["points"][right]), _chain_depth(p0["points"][left])
        if _bucket(dr) == _bucket(dl):
            ax_r = p0["points"][right][0][0]  # view x == canvas offset from anchor
            near = right if ax_r >= p0["points"][left][0][0] else left
        else:
            near = right if dr > dl else left
        shade[right] = "near" if near == right else "far"
        shade[left] = "near" if near == left else "far"

    fo = prof.get("farOffset", [6, 2])
    off = ((-fo[0] if flipped else fo[0]) * p0["k"], fo[1] * p0["k"])

    work = copy.deepcopy(nf)
    for limb, (_attach, _sigma, pin) in K.LIMBS.items():
        if shade[limb] == "far" and pin in work["pins"]:
            work["pins"][pin] = [work["pins"][pin][0] - off[0],
                                 work["pins"][pin][1] - off[1]]
    # Pins are canvas targets in the authored, unpitched view: solve IK flat,
    # then tilt the *posed* body - the camera elevation is pure presentation,
    # so contacts straddle the floor plane instead of pins going out of reach.
    work, _ = K.resolve(work, prof, cam, 0.0)
    work["pins"] = dict(nf["pins"])  # keep authored pins; only angles resolved
    p = K.pose(work, prof, cam, pitch)

    anchor = nf["root"]["pos"]

    def scr(v, limb_off=(0, 0)):
        return (anchor[0] + v[0] + limb_off[0], anchor[1] - v[1] + limb_off[1])

    pts = p["points"]
    pelvis, mid, neck_b = scr(pts["pelvis"]), scr(pts["mid"]), scr(pts["neckB"])
    geo = {"headR": prof["headR"], "head": scr(pts["head"]),
           "spine": [pelvis,
                     (2 * mid[0] - (pelvis[0] + neck_b[0]) / 2,
                      2 * mid[1] - (pelvis[1] + neck_b[1]) / 2),
                     neck_b]}
    depths = {"spine": _chain_depth([pts["pelvis"], pts["mid"], pts["neckB"]])}
    for limb in K.LIMBS:
        limb_off = off if shade[limb] == "far" else (0, 0)
        geo[limb] = [scr(v, limb_off) for v in pts[limb]]
        depths[limb] = _chain_depth(pts[limb])

    # Per-vertex depth shading: each drawn joint is toned by its OWN camera depth,
    # not the whole limb's average, so the ink flows smoothly along a limb that
    # reaches toward or away from the camera instead of the bone snapping to one
    # flat tone. Each pair is measured against its central depth plane and
    # normalized by the profile half-width (x SHADE_SPAN_FRAC), chosen so a
    # straight limb in a profile view still reaches full near-dark / far-light
    # (the per-vertex average reproduces the old flat per-limb tone) while the
    # two members still fade to a shared mid-tone as they cross. The binary
    # `shade` above stays untouched - it alone drives the far offset and order.
    nearness = {}
    for right, left in PAIRS:
        zr = [v[2] for v in pts[right]]
        zl = [v[2] for v in pts[left]]
        ref = (sum(zr) / len(zr) + sum(zl) / len(zl)) / 2
        half = prof.get("shoulderHalf" if "arm" in right else "hipHalf", 10) * p["k"]
        span = half * SHADE_SPAN_FRAC
        for limb, zs in ((right, zr), (left, zl)):
            nearness[limb] = [(0.5 + 0.5 * max(-1.0, min(1.0, (z - ref) / span)))
                              if span else 0.5 for z in zs]

    # Shoulder girdle and pelvis, drawn with the spine so the limbs visibly hang
    # from real width. Endpoints use each side's drawn attach (far offset included)
    # so the bars meet the limbs exactly.
    def attach(key, limb):
        return scr(pts[key], off if shade[limb] == "far" else (0, 0))

    geo["girdle"] = [attach("shoulder_l", "arm_l"), neck_b, attach("shoulder_r", "arm_r")]
    geo["pelvisBar"] = [attach("hip_l", "leg_l"), pelvis, attach("hip_r", "leg_r")]

    # The exercise mat: a world-space quad on the ground plane, rotating with
    # the camera about the figure's vertical axis (`mat` = screen-x bounds of
    # the motion's footprint in the authored view).
    if mat is not None:
        yg = -(GROUND_Y + 4 - anchor[1])
        rc = K.mmul(K.rot_x(pitch), K.rot_y(-cam))
        geo["floor"] = [scr(K.mvec(rc, (dx, yg, dz))[:2] + (0,))
                        for dx, dz in ((mat[0] - anchor[0], FLOOR_HALF_DEPTH),
                                       (mat[1] - anchor[0], FLOOR_HALF_DEPTH),
                                       (mat[1] - anchor[0], -FLOOR_HALF_DEPTH),
                                       (mat[0] - anchor[0], -FLOOR_HALF_DEPTH))]

    # The nose tick rides the head's anterior axis; it foreshortens naturally
    # and disappears when the face points at (or away from) the camera.
    nd = p["nose_dir"]
    mag = math.hypot(nd[0], nd[1])
    if mag > 0.3:
        ux, uy = nd[0] / mag, -nd[1] / mag
        hx, hy = geo["head"]
        r = prof["headR"]
        geo["nose"] = ((hx + ux * r, hy + uy * r),
                       (hx + ux * (r + 7 * mag), hy + uy * (r + 7 * mag)))

    geo["nearness"] = nearness
    order = sorted(depths, key=lambda part: (_bucket(depths[part]),
                                             FIXED_RANK[part])) + ["head"]
    return work, geo, order, shade


def mat_bounds(norms, prof, cam, pitch=CAMERA_PITCH):
    """The motion's footprint in the authored view: min/max screen x of every
    figure point across the key frames, padded - the exercise mat spans it."""
    lo, hi = float("inf"), float("-inf")
    for nf in norms:
        _, geo, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
        xs = [geo["head"][0] - geo["headR"], geo["head"][0] + geo["headR"]]
        for part in ("arm_r", "arm_l", "leg_r", "leg_l", "spine", "girdle", "pelvisBar"):
            xs += [pt[0] for pt in geo.get(part, [])]
        lo, hi = min(lo, min(xs)), max(hi, max(xs))
    return lo - 12, hi + 12


ZOOM_ANCHOR = (CANVAS[0] / 2.0, float(GROUND_Y))


def apply_zoom(geo, prims, zoom):
    """Presentation-only camera zoom about the ground-center anchor: scales the
    drawn figure, equipment, and stroke widths so standing-height motions fit
    the canvas. Applied after all solving — authored coordinates, pins, props,
    and the fixture geometry stay in full-size canvas units (the in-app
    renderer applies the same transform at draw time)."""
    if zoom == 1.0:
        return geo, prims
    ax, ay = ZOOM_ANCHOR

    def pt(p):
        return (ax + (p[0] - ax) * zoom, ay + (p[1] - ay) * zoom)

    g = dict(geo)
    g["zoom"] = geo.get("zoom", 1.0) * zoom
    g["headR"] = geo["headR"] * zoom
    g["head"] = pt(geo["head"])
    for key in ("spine", "girdle", "pelvisBar", "floor") + tuple(K.LIMBS):
        if key in g:
            g[key] = [pt(p) for p in geo[key]]
    if "nose" in geo:
        g["nose"] = (pt(geo["nose"][0]), pt(geo["nose"][1]))

    def zp(prim):
        q = dict(prim)
        if "pts" in q:
            q["pts"] = [list(pt(p)) for p in q["pts"]]
        if "c" in q:
            q["c"] = list(pt(q["c"]))
        if "r" in q:
            q["r"] = q["r"] * zoom
        if "w" in q:
            q["w"] = q["w"] * zoom
        return q

    bg, fg = prims
    return g, ([zp(p) for p in bg], [zp(p) for p in fg])


def ease(t):
    return 3 * t * t - 2 * t * t * t


def timeline(norms, fps=20):
    frames = []
    for i, kf in enumerate(norms):
        frames += [kf] * max(1, round(kf["hold"] * fps))
        nxt = norms[(i + 1) % len(norms)]
        steps = max(1, round(kf["tween"] * fps))
        frames += [K.lerp_frames(kf, nxt, ease(s / steps)) for s in range(1, steps)]
    return frames


# -------------------------------------------------------------------- props
# Equipment layer (see SYSTEM.md): `scene` shapes and `cable`s draw behind the
# figure in the recessive equipment gray; joint-attached items (`bar`,
# `dumbbell`, `pad`) draw over the limbs in the darker prop gray, following
# the resolved hand/foot positions frame by frame.
#
# Props have world-space 3D form: everything is authored (and resolved) in the
# authored view — scene points and cable anchors carry an optional depth `z`
# (+ toward the camera), a scene line an optional `depth` extrusion half-width
# that makes it a slab — then rotated about the world-vertical axis through
# the root anchor for the orbiting presentation, exactly like the figure and
# the mat. Positions that follow the figure (joint anchors) come from the
# *rotated* geometry; fixed points, directions, and offsets (scene shapes,
# cable anchors, bar angles, pad perpendiculars, roller offsets) resolve in
# the *authored* view and rotate — never recompute them in the rotated view.

def flip_props(props, width):
    """Mirror the props horizontally, matching the flipped camera (which views
    from the opposite side, so authored depths flip sign too). Joint-attached
    props follow the mirrored limbs automatically; only fixed coordinates,
    depths, and world angles need mirroring."""
    def fx(p):
        return [width - p[0], p[1], -p[2]] if len(p) > 2 else [width - p[0], p[1]]
    out = []
    for prop in props:
        p = json.loads(json.dumps(prop))
        if p["type"] == "scene":
            for s in p["shapes"]:
                if "z" in s:
                    s["z"] = -s["z"]
                if s["kind"] == "line":
                    s["pts"] = [fx(pt) for pt in s["pts"]]
                elif s["kind"] == "circle":
                    s["c"] = fx(s["c"])
        elif p["type"] == "cable":
            p["from"] = fx(p["from"])
        elif p["type"] in ("bar", "pad") and "angle" in p:
            p["angle"] = 180 - p["angle"]
        elif p["type"] == "roller":
            p["side"] = -p.get("side", 1)   # mirroring flips the perpendicular's handedness
        out.append(p)
    return out


def prop_rotation(pitch, yaw_offset):
    """View-space rotation carrying authored-view prop geometry to the camera
    `yaw_offset` degrees past the authored yaw: a rotation about the world-
    vertical axis through the root anchor, conjugated by the camera elevation.
    The identity at offset 0, so the authored view stays bit-exact."""
    if yaw_offset == 0:
        return K.IDENTITY
    return K.chain(K.rot_x(pitch), K.rot_y(-yaw_offset), K.rot_x(-pitch))


def joint_points(geo, ref):
    """Resolve a joint ref — `"hand_r"`, `"knee_l"`, or a midpoint list like
    `["knee_r", "foot_r"]` — to (point, unit direction of the bone ending at
    the first joint). None when the limb isn't drawn."""
    names = ref if isinstance(ref, list) else [ref]
    pts, direction = [], None
    for name in names:
        limb_name, idx = JOINT_LIMB[name]
        limb = geo.get(limb_name)
        if not limb:
            return None, None
        pts.append(limb[idx])
        if direction is None:
            a, b = limb[idx - 1], limb[idx]
            d = math.hypot(b[0] - a[0], b[1] - a[1]) or 1.0
            direction = ((b[0] - a[0]) / d, (b[1] - a[1]) / d)
    return ((sum(p[0] for p in pts) / len(pts),
             sum(p[1] for p in pts) / len(pts)), direction)


def resolve_props(props, geo, anchor, rot=K.IDENTITY, auth_geo=None, pitch=CAMERA_PITCH):
    """Props -> drawable primitives for one frame: (background, foreground).

    `geo` is the frame's drawn (possibly orbit-rotated) geometry, `auth_geo`
    the same frame at the authored camera (defaults to `geo`), `anchor` the
    frame's root canvas anchor, and `rot` the `prop_rotation` between them.
    Joint positions come from `geo`; everything authored — scene points,
    cable anchors, bar angles, pad perpendiculars, roller offsets — resolves
    against `auth_geo` and rotates through `rot`. `pitch` is the camera
    elevation, needed by `axis` props whose world-space direction projects
    through it (like the floor quad).
    """
    auth = auth_geo if auth_geo is not None else geo

    def place(pt, z=0.0):
        """Authored canvas point (+ depth toward the camera) -> drawn canvas."""
        v = K.mvec(rot, (pt[0] - anchor[0], anchor[1] - pt[1], z))
        return (anchor[0] + v[0], anchor[1] - v[1])

    def swing(vec, z=0.0):
        """Authored canvas-space direction/offset -> drawn canvas (y-down)."""
        v = K.mvec(rot, (vec[0], -vec[1], z))
        return (v[0], -v[1])

    bg, fg = [], []
    for p in props or []:
        t = p["type"]
        if t == "scene":
            for s in p["shapes"]:
                color = s.get("color", "equipment")
                z = s.get("z", 0.0)
                if s["kind"] == "line":
                    depth = s.get("depth", 0.0)
                    pts = [(pt[0], pt[1], z + (pt[2] if len(pt) > 2 else 0.0))
                           for pt in s["pts"]]
                    if depth:
                        # An extruded slab: the polyline swept through +/-depth,
                        # filled and outlined so it degenerates to the plain
                        # line whenever the sweep collapses edge-on.
                        quad = ([place(pt, pt[2] + depth) for pt in pts]
                                + [place(pt, pt[2] - depth) for pt in reversed(pts)])
                        bg.append({"kind": "poly", "pts": [list(q) for q in quad],
                                   "w": s.get("w", 4), "color": color})
                    else:
                        bg.append({"kind": "line",
                                   "pts": [list(place(pt, pt[2])) for pt in pts],
                                   "w": s.get("w", 4), "color": color})
                elif s["kind"] == "circle":
                    bg.append({"kind": "circle", "c": list(place(s["c"], z)),
                               "r": s["r"], "fill": s.get("fill", False),
                               "w": s.get("w", 3), "color": color})
        elif t == "cable":
            end, _ = joint_points(geo, p["to"])
            if end:
                start = place(p["from"], p["from"][2] if len(p["from"]) > 2 else 0.0)
                bg.append({"kind": "line", "pts": [list(start), list(end)],
                           "w": p.get("w", 2), "color": "equipment"})
        elif t == "roller":
            # A machine roller pad seen end-on: a disc riding the limb's lower
            # bone near the joint, on the `side` (+1/-1) of the bone it presses.
            # The offset resolves along the authored-view bone, then rotates.
            c, _ = joint_points(geo, p["at"])
            _, d = joint_points(auth, p["at"])
            if not c or not d:
                continue
            r = p.get("r", 5)
            back = p.get("back", 0)
            side = p.get("side", 1)
            px, py = d[1] * side, -d[0] * side
            off = swing((-d[0] * back + px * (r + 3), -d[1] * back + py * (r + 3)))
            fg.append({"kind": "circle", "c": [c[0] + off[0], c[1] + off[1]], "r": r,
                       "fill": True, "color": "prop"})
        elif t in ("bar", "dumbbell", "pad"):
            c, _ = joint_points(geo, p["at"])
            _, d = joint_points(auth, p["at"])
            if not c or not d:
                continue
            if p.get("axis") == "z":
                # A cross-body rod (barbell, pull-up bar): its true 3D axis is
                # the world left-right axis, projected through the camera
                # elevation like the floor quad — a small vertical sliver
                # end-on in a profile view (the plates read nearly concentric,
                # seen from slightly above), full span face-on, swinging with
                # the figure in between, symmetric at 0 and 180.
                pr = math.radians(pitch)
                ux, uy = swing((0.0, math.sin(pr)), math.cos(pr))
            elif t == "bar" or "angle" in p:
                axis = dirv(p.get("angle", 0))       # fixed authored-view angle
                ux, uy = swing(axis)                 # foreshortens under orbit
            else:
                axis = (-d[1], d[0])                 # perpendicular to the lower bone
                ux, uy = swing(axis)
            h = p.get("halfLen", {"bar": 24, "dumbbell": 7, "pad": 8}[t])
            a = (c[0] - ux * h, c[1] - uy * h)
            b = (c[0] + ux * h, c[1] + uy * h)
            fg.append({"kind": "line", "pts": [list(a), list(b)],
                       "w": p.get("w", {"bar": 4, "dumbbell": 3, "pad": 7}[t]),
                       "color": "prop"})
            plate = p.get("plateR", 4.5 if t == "dumbbell" else 0)
            if plate:
                for e in (a, b):
                    fg.append({"kind": "circle", "c": list(e), "r": plate,
                               "fill": True, "color": "prop"})
    return bg, fg


def svg_prims(prims, colors):
    lines = []
    for p in prims:
        color = colors[p["color"]]
        if p["kind"] == "line":
            d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"])
            lines.append(f'  <path d="{d}" stroke="{color}" stroke-width="{p.get("w", 4)}"'
                         f' stroke-linecap="round" stroke-linejoin="round"/>')
        elif p["kind"] == "poly":
            d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"]) + " Z"
            lines.append(f'  <path d="{d}" fill="{color}" stroke="{color}"'
                         f' stroke-width="{p.get("w", 4)}"'
                         f' stroke-linecap="round" stroke-linejoin="round"/>')
        elif p["kind"] == "circle":
            cx, cy = p["c"]
            if p.get("fill"):
                lines.append(f'  <circle cx="{cx:.1f}" cy="{cy:.1f}" r="{p["r"]}" fill="{color}"/>')
            else:
                lines.append(f'  <circle cx="{cx:.1f}" cy="{cy:.1f}" r="{p["r"]}"'
                             f' stroke="{color}" stroke-width="{p.get("w", 3)}"/>')
    return lines


def draw_prims(d, prims, colors, scale):
    for p in prims:
        color = colors[p["color"]]
        if p["kind"] in ("line", "poly"):
            pts = [(x * scale, y * scale) for x, y in p["pts"]]
            if p["kind"] == "poly":
                d.polygon(pts, fill=color)
                pts = pts + pts[:1]
            w = round(p.get("w", 4) * scale)
            d.line(pts, fill=color, width=w, joint="curve")
            for x, y in (pts[0], pts[-1]):
                d.ellipse([x - w / 2, y - w / 2, x + w / 2, y + w / 2], fill=color)
        elif p["kind"] == "circle":
            cx, cy = p["c"][0] * scale, p["c"][1] * scale
            r = p["r"] * scale
            if p.get("fill"):
                d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
            else:
                d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=color,
                          width=round(p.get("w", 3) * scale))


# ------------------------------------------------------------------- drawing

def floor_svg(geo, colors):
    """The exercise mat: a world-space quad on the ground plane, sized to the
    motion's footprint and rotating with the camera."""
    quad = geo.get("floor")
    if not quad:
        return ""
    d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in quad) + " Z"
    return (f'  <path d="{d}" fill="none" stroke="{colors["ground"]}"'
            f' stroke-width="{3 * geo.get("zoom", 1.0):g}" stroke-linejoin="round"/>')


def _lerp_hex(a, b, t):
    """Blend two "#rrggbb" colors; t=0 -> a, t=1 -> b."""
    a, b = a.lstrip("#"), b.lstrip("#")
    ar, ag, ab = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)
    br, bg, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)
    return "#%02x%02x%02x" % (round(ar + (br - ar) * t),
                              round(ag + (bg - ag) * t),
                              round(ab + (bb - ab) * t))


def shade_ink(part, working, colors, t):
    """The ink at nearness `t` (1 = near/dark, 0 = far/light): the plain near/far
    grays, or the accent teals when `part` is a working part."""
    far, near = ("left_working", "right_working") if part in working else ("left", "right")
    return _lerp_hex(colors[far], colors[near], t)


def shade_width(t):
    """Stroke width tracking nearness (far 5 -> near 6)."""
    return WIDTHS["far"] + (WIDTHS["near"] - WIDTHS["far"]) * t


def part_style(part, working, colors, nearness):
    """Single-tone style for the spine and its bars — the near ink (or working
    teal), at the spine width. (Limbs shade per vertex; see `svg_for_frame`.)"""
    t = nearness.get(part, 1.0)
    if isinstance(t, list):
        t = sum(t) / len(t) if t else 1.0
    return shade_ink(part, working, colors, t), WIDTHS["spine"]


def quad_points(p0, ctrl, p2, n=24):
    pts = []
    for i in range(n + 1):
        t = i / n
        pts.append(((1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * ctrl[0] + t ** 2 * p2[0],
                    (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * ctrl[1] + t ** 2 * p2[1]))
    return pts


def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
    bg, fg = prims
    w, h = CANVAS
    zf = geo.get("zoom", 1.0)
    defs, parts = [], [floor_svg(geo, colors)]
    parts += svg_prims(bg, colors)
    for part in order:
        if part == "head":
            parts += svg_prims(fg, colors)
            hx, hy = geo["head"]
            parts.append(f'  <circle id="head" cx="{hx:.1f}" cy="{hy:.1f}" r="{geo["headR"]}"'
                         f' fill="{colors["head_fill"]}" stroke="{colors["right"]}" stroke-width="{WIDTHS["head"] * zf:g}"/>')
            if "nose" in geo:
                (sx, sy), (ex, ey) = geo["nose"]
                parts.append(f'  <line id="nose" x1="{sx:.1f}" y1="{sy:.1f}" x2="{ex:.1f}" y2="{ey:.1f}"'
                             f' stroke="{colors["right"]}" stroke-width="{WIDTHS["nose"] * zf:g}" stroke-linecap="round"/>')
            continue
        if part not in geo:
            continue
        if part == "spine":
            color, _ = part_style(part, working, colors, geo["nearness"])
            for bar in ("girdle", "pelvisBar"):
                bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar])
                parts.append(f'  <path id="{bar}" d="{bd}" stroke="{color}" stroke-width="{5 * zf:g}"'
                             f' stroke-linecap="round" stroke-linejoin="round"/>')
            (ax, ay), (cx, cy), (bx, by) = geo["spine"]
            parts.append(f'  <path id="spine" d="M {ax:.1f} {ay:.1f} Q {cx:.1f} {cy:.1f} {bx:.1f} {by:.1f}"'
                         f' stroke="{color}" stroke-width="{WIDTHS["spine"] * zf:g}"'
                         f' stroke-linecap="round" stroke-linejoin="round"/>')
            continue
        # A limb shades per vertex: each bone segment is its own round-capped path,
        # stroked with a linear gradient between its two joints' depth tones so the
        # ink flows smoothly along the limb. Adjacent segments share a joint tone,
        # so the round caps meet seamlessly.
        chain = geo[part]
        tones = geo["nearness"].get(part) or [1.0] * len(chain)
        parts.append(f'  <g id="{part}">')
        for i in range(len(chain) - 1):
            (x0, y0), (x1, y1) = chain[i], chain[i + 1]
            c0 = shade_ink(part, working, colors, tones[i])
            c1 = shade_ink(part, working, colors, tones[i + 1])
            sw = shade_width((tones[i] + tones[i + 1]) / 2) * zf
            if c0 == c1 or (abs(x1 - x0) < 0.01 and abs(y1 - y0) < 0.01):
                stroke = c0
            else:
                gid = f"grad-{part}-{i}"
                defs.append(f'    <linearGradient id="{gid}" gradientUnits="userSpaceOnUse"'
                            f' x1="{x0:.1f}" y1="{y0:.1f}" x2="{x1:.1f}" y2="{y1:.1f}">'
                            f'<stop offset="0" stop-color="{c0}"/>'
                            f'<stop offset="1" stop-color="{c1}"/></linearGradient>')
                stroke = f"url(#{gid})"
            parts.append(f'    <path d="M {x0:.1f} {y0:.1f} L {x1:.1f} {y1:.1f}"'
                         f' stroke="{stroke}" stroke-width="{sw:g}" stroke-linecap="round"/>')
        parts.append('  </g>')
    lx = w - 96
    parts.append(f'  <g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="{colors["legend_text"]}">')
    parts.append(f'    <line x1="{lx}" y1="16" x2="{lx + 14}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
    parts.append(f'    <text x="{lx + 19}" y="20">near</text>')
    parts.append(f'    <line x1="{lx + 49}" y1="16" x2="{lx + 63}" y2="16" stroke="{colors["left"]}" stroke-width="4" stroke-linecap="round"/>')
    parts.append(f'    <text x="{lx + 68}" y="20">far</text>')
    parts.append('  </g>')
    parts.append('</svg>')
    header = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
              f'  <title>{name}</title>']
    if defs:
        header += ['  <defs>'] + defs + ['  </defs>']
    return "\n".join(header + parts) + "\n"


def draw_geo(geo, order, shade, working, colors, scale=2, font=None, prims=((), ())):
    from PIL import Image, ImageDraw

    bg, fg = prims
    w, h = CANVAS[0] * scale, CANVAS[1] * scale
    zf = geo.get("zoom", 1.0)
    img = Image.new("RGB", (w, h), "white")
    d = ImageDraw.Draw(img)

    def line(pts, color, width):
        pts = [(x * scale, y * scale) for x, y in pts]
        d.line(pts, fill=color, width=round(width * scale), joint="curve")
        r = width * scale / 2
        for x, y in (pts[0], pts[-1]):
            d.ellipse([x - r, y - r, x + r, y + r], fill=color)

    def gradient_line(pts, tones, part):
        """Stroke a limb chain with a per-vertex depth gradient: subdivide each
        bone into short steps whose ink and width interpolate between the two
        joints' tones, capping every sample so the stroke stays continuous."""
        steps = 12
        prev = None
        for i in range(len(pts) - 1):
            (x0, y0), (x1, y1) = pts[i], pts[i + 1]
            for s in range(steps + 1):
                u = s / steps
                x, y = (x0 + (x1 - x0) * u) * scale, (y0 + (y1 - y0) * u) * scale
                t = tones[i] + (tones[i + 1] - tones[i]) * u
                col = shade_ink(part, working, colors, t)
                r = shade_width(t) * zf * scale / 2
                if prev is not None:
                    d.line([prev, (x, y)], fill=col, width=round(2 * r))
                d.ellipse([x - r, y - r, x + r, y + r], fill=col)
                prev = (x, y)

    if geo.get("floor"):
        line(geo["floor"] + geo["floor"][:1], colors["ground"], 3 * zf)
    draw_prims(d, bg, colors, scale)
    for part in order:
        if part == "head":
            draw_prims(d, fg, colors, scale)
            hx, hy = geo["head"]
            r, sw = geo["headR"] * scale, round(WIDTHS["head"] * zf * scale)
            d.ellipse([hx * scale - r, hy * scale - r, hx * scale + r, hy * scale + r],
                      fill=colors["head_fill"], outline=colors["right"], width=sw)
            if "nose" in geo:
                line(list(geo["nose"]), colors["right"], WIDTHS["nose"] * zf)
            continue
        if part not in geo:
            continue
        if part == "spine":
            color, _ = part_style(part, working, colors, geo["nearness"])
            line(geo["girdle"], color, 5 * zf)
            line(geo["pelvisBar"], color, 5 * zf)
            line(quad_points(*geo["spine"]), color, WIDTHS["spine"] * zf)
            continue
        tones = geo["nearness"].get(part) or [1.0] * len(geo[part])
        gradient_line(geo[part], tones, part)

    lx = CANVAS[0] - 96
    line([(lx, 16), (lx + 14, 16)], colors["right"], 4)
    line([(lx + 49, 16), (lx + 63, 16)], colors["left"], 4)
    if font:
        d.text((lx * scale + 19 * scale, 16 * scale - 11 * scale / 2 - 2), "near",
               fill=colors["legend_text"], font=font)
        d.text((lx * scale + 68 * scale, 16 * scale - 11 * scale / 2 - 2), "far",
               fill=colors["legend_text"], font=font)
    return img.resize(CANVAS, Image.LANCZOS)


def legend_font(scale=2):
    from PIL import ImageFont
    try:
        return ImageFont.load_default(size=11 * scale)
    except TypeError:
        return ImageFont.load_default()


# --------------------------------------------------------------------- main

def load_motion(folder):
    return json.loads((folder / "motion.json").read_text())


def prepare(motion, figure="neutral", flip=False, strict=False):
    """Load a motion into (normalized frames, profile, camera yaw, pitch,
    props, zoom), validating each key frame against the skeleton's ROM."""
    skel = K.load_skeleton()
    prof = skel["profiles"][figure]
    cam = float(motion.get("camera", {}).get("yaw", 0.0)) + (180.0 if flip else 0.0)
    pitch = float(motion.get("camera", {}).get("pitch", CAMERA_PITCH))
    zoom = float(motion.get("camera", {}).get("zoom", 1.0))
    norms = [K.normalize_frame(kf) for kf in motion["frames"]]
    issues = []
    for i, nf in enumerate(norms, start=1):
        issues += K.validate_rom(nf, skel["joints"], f"frame {i}: ")
    if issues and strict:
        print(f"  {motion['name']}: ROM violations:")
        for msg in issues:
            print(f"    {msg}")
        sys.exit(1)
    if issues:
        print(f"  {motion['name']}: {len(issues)} ROM warning(s) — run --strict to list")
    props = motion.get("props", [])
    if flip:
        norms = [mirror_frame(nf, CANVAS[0]) for nf in norms]
        props = flip_props(props, CANVAS[0])
    return norms, prof, cam, pitch, props, zoom


def render_exercise(folder, figure="neutral", flip=False, strict=False):
    motion = load_motion(folder)
    working = set(motion.get("working", []))
    norms, prof, cam, pitch, props, zoom = prepare(motion, figure, flip, strict)

    mat = mat_bounds(norms, prof, cam, pitch)

    def geometry(nf):
        _, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
        return geo, order, shade

    resolved = []
    key_cells = []
    for nf in norms:
        out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
        resolved.append(out)
        prims = resolve_props(props, geo, nf["root"]["pos"], pitch=pitch)
        geo, prims = apply_zoom(geo, prims, zoom)
        key_cells.append((geo, order, shade, prims))

    frames_dir = folder / "frames"
    frames_dir.mkdir(exist_ok=True)
    for old in frames_dir.glob("frame-*.svg"):
        old.unlink()
    colors = PALETTES["default"]
    svgs = [svg_for_frame(motion["name"], geo, order, shade, working, colors, prims)
            for geo, order, shade, prims in key_cells]
    for i, svg in enumerate(svgs, start=1):
        (frames_dir / f"frame-{i}.svg").write_text(svg)
    (folder / "visual.svg").write_text(svgs[motion.get("primary", 1) - 1])

    try:
        font = legend_font()
        imgs = []
        for nf in timeline(resolved):
            geo, order, shade = geometry(nf)
            prims = resolve_props(props, geo, nf["root"]["pos"], pitch=pitch)
            geo, prims = apply_zoom(geo, prims, zoom)
            imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims))
        imgs[0].save(folder / "preview.gif", save_all=True, append_images=imgs[1:],
                     duration=50, loop=0)
        print(f"  {motion['name']}: {len(svgs)} frames, preview.gif")
    except ImportError:
        print(f"  {motion['name']}: Pillow missing — SVGs written, preview.gif skipped")


def render_orbit(folder, figure="neutral"):
    """An orbiting demo of what the app shows: the camera sweeps 360 degrees
    while the motion loops (or swings a `camera.sweep` pendulum when the
    motion authors one). Props rotate with the figure about the root anchor."""
    motion = load_motion(folder)
    working = set(motion.get("working", []))
    norms, prof, cam, pitch, props, zoom = prepare(motion, figure)
    resolved = [frame_geometry(nf, prof, cam, pitch=pitch)[0] for nf in norms]
    mat = mat_bounds(norms, prof, cam, pitch)
    sweep = motion.get("camera", {}).get("sweep")
    font = legend_font()
    colors = PALETTES["default"]
    ticks = timeline(resolved)
    imgs = []
    for i, nf in enumerate(ticks):
        off = (sweep * math.sin(2 * math.pi * i / len(ticks)) if sweep
               else 360.0 * i / len(ticks))
        # Pins are canvas targets in the AUTHORED view: resolve the pose there,
        # then rotate the posed body - never re-pin in the rotated view. The
        # authored-view geometry also feeds the props' directions and offsets.
        posed, auth_geo, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
        posed["pins"] = {}
        _, geo, order, shade = frame_geometry(posed, prof, cam + off, pitch=pitch, mat=mat)
        prims = resolve_props(props, geo, nf["root"]["pos"],
                              prop_rotation(pitch, off), auth_geo, pitch=pitch)
        geo, prims = apply_zoom(geo, prims, zoom)
        imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims))
    imgs[0].save(folder / "orbit.gif", save_all=True, append_images=imgs[1:],
                 duration=50, loop=0)
    print(f"  {motion['name']}: orbit.gif ({len(imgs)} frames)")


def contact_sheet(folders, figure="neutral", out=None):
    font = legend_font()
    cells = []
    for folder in folders:
        motion = load_motion(folder)
        working = set(motion.get("working", []))
        norms, prof, cam, pitch, props, zoom = prepare(motion, figure)
        mat = mat_bounds(norms, prof, cam, pitch)
        for i, nf in enumerate(norms, start=1):
            _, geo, order, shade = frame_geometry(nf, prof, cam, pitch=pitch, mat=mat)
            prims = resolve_props(props, geo, nf["root"]["pos"], pitch=pitch)
            geo, prims = apply_zoom(geo, prims, zoom)
            cells.append((f"{motion['name']} {i}/{len(norms)}",
                          draw_geo(geo, order, shade, working, PALETTES["default"],
                                   font=font, prims=prims)))
    save_sheet(cells, Path(out) if out else LIB / "contact-sheet.png")


def demo_sheet(folder):
    """One exercise's primary frame rendered five ways — the doors the rig
    opens: neutral / female / male profiles (same motion script, different
    proportions), the flipped camera, an alternate theme."""
    motion = load_motion(folder)
    working = set(motion.get("working", []))
    idx = motion.get("primary", 1) - 1
    font = legend_font()
    variants = [("neutral", "neutral", False, "default"),
                ("female profile", "female", False, "default"),
                ("male profile", "male", False, "default"),
                ("flipped camera", "neutral", True, "default"),
                ("themed (indigo)", "neutral", False, "indigo")]
    cells = []
    for label, figure, flip, palette in variants:
        norms, prof, cam, pitch, props, zoom = prepare(motion, figure, flip)
        mat = mat_bounds(norms, prof, cam, pitch)
        _, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch, mat)
        prims = resolve_props(props, geo, norms[idx]["root"]["pos"], pitch=pitch)
        geo, prims = apply_zoom(geo, prims, zoom)
        cells.append((f"{motion['name']} — {label}",
                      draw_geo(geo, order, shade, working, PALETTES[palette],
                               font=font, prims=prims)))
    save_sheet(cells, LIB / "demo-sheet.png", cols=3)


def save_sheet(cells, path, cols=4):
    from PIL import Image, ImageDraw

    rows = (len(cells) + cols - 1) // cols
    cw, ch, cap = CANVAS[0], CANVAS[1], 22
    sheet = Image.new("RGB", (cols * (cw + 8) + 8, rows * (ch + cap + 8) + 8), "white")
    d = ImageDraw.Draw(sheet)
    for i, (label, img) in enumerate(cells):
        x, y = 8 + (i % cols) * (cw + 8), 8 + (i // cols) * (ch + cap + 8)
        sheet.paste(img, (x, y))
        d.text((x + 6, y + ch + 3), label, fill=PALETTES["default"]["right"])
    sheet.save(path)
    print(f"  {path.name} ({len(cells)} cells)")


# ------------------------------------------------------------------- export

def export_app_resources(folders):
    """Write the app's bundled copies: skeleton.json plus one
    `<Name>.motion.json` and one `<Name>.info.md` per library entry (unique
    basenames — Xcode copies resources flat). The in-app solver is a port of
    kinematics.py and consumes the same files; figure-fixtures.json in
    WorkoutsTests holds it to this pipeline's geometry."""
    out = LIB.parent / "Workouts" / "Resources" / "ExerciseMotions"
    out.mkdir(parents=True, exist_ok=True)
    (out / "skeleton.json").write_text((LIB / "skeleton.json").read_text())
    (out / "body.json").unlink(missing_ok=True)  # the legacy profile file
    for folder in folders:
        motion = load_motion(folder)
        (out / f"{motion['name']}.motion.json").write_text(
            (folder / "motion.json").read_text())
        print(f"  exported {motion['name']}.motion.json")
        info = folder / "info.md"
        if info.exists():
            (out / f"{motion['name']}.info.md").write_text(info.read_text())
            print(f"  exported {motion['name']}.info.md")
    print(f"  exported skeleton.json -> {out}")


def write_fixtures(folders):
    """Regenerate `WorkoutsTests/Fixtures/figure-fixtures.json` — the projected
    geometry snapshots that pin the Swift solver to this pipeline: per exercise
    and key frame the draw order, shading, spine, head, nose, and limb chains,
    plus a mid-tween sample and (for a spread of prop flavors) orbit samples
    taken through the presentation path — resolve at the authored camera, clear
    the pins, rotate the posed body — with the resolved prop primitives."""
    ORBIT_SAMPLED = {"Bird Dog", "Lat Pull Down", "Leg Extension", "Abductor"}
    OFFSETS = (37.0, 180.0)
    T = 0.5

    def sample(geo, order, shade, prims=None):
        out = {"order": order, "shade": shade,
               "spine": [list(p) for p in geo["spine"]],
               "head": list(geo["head"])}
        if "nose" in geo:
            out["nose"] = [list(geo["nose"][0]), list(geo["nose"][1])]
        for limb in K.LIMBS:
            out[limb] = [list(p) for p in geo[limb]]
        if prims is not None:
            out["props"] = {"bg": prims[0], "fg": prims[1]}
        return out

    exercises = []
    for folder in folders:
        motion = load_motion(folder)
        norms, prof, cam, pitch, props, _zoom = prepare(motion, "neutral")
        resolved, frames = [], []
        for nf in norms:
            out, geo, order, shade = frame_geometry(nf, prof, cam)
            resolved.append(out)
            frames.append(sample(geo, order, shade))
        mid = K.lerp_frames(resolved[0], resolved[1], ease(T))
        _, geo, order, shade = frame_geometry(mid, prof, cam)
        entry = {"name": motion["name"], "camera": cam, "frames": frames,
                 "tween": {"t": T, "sample": sample(geo, order, shade)}}
        if motion["name"] in ORBIT_SAMPLED:
            anchor = norms[0]["root"]["pos"]
            posed, auth_geo, _, _ = frame_geometry(norms[0], prof, cam)
            posed["pins"] = {}
            orbit = []
            for off in OFFSETS:
                _, geo, order, shade = frame_geometry(posed, prof, cam + off)
                prims = resolve_props(props, geo, anchor,
                                      prop_rotation(CAMERA_PITCH, off), auth_geo)
                orbit.append({"yaw": cam + off,
                              "sample": sample(geo, order, shade, prims)})
            entry["orbit"] = orbit
        exercises.append(entry)

    out_path = LIB.parent / "WorkoutsTests" / "Fixtures" / "figure-fixtures.json"
    out_path.write_text(json.dumps(
        {"profile": "neutral", "pitch": CAMERA_PITCH, "exercises": exercises}))
    print(f"  {out_path.name} ({len(exercises)} exercises)")


def _dof_values(frame, joint_map):
    return {f"{name}.{dof}": frame[name][dof]
            for name, jt in joint_map.items() for dof in K.JOINT_DOFS[jt]}


def _resolved_rom_issues(nf, joints, slack):
    """`K.validate_rom`'s DoF walk, re-implemented here with a slack margin
    (kinematics.py itself stays untouched). Returns (name, dof, angle, lo,
    hi, over) tuples, `over` the degrees past the slack-widened range."""
    issues = []

    def check(joint_type, name, value):
        for dof, angle in value.items():
            lo_hi = joints.get(joint_type, {}).get(dof)
            if not lo_hi:
                continue
            lo, hi = lo_hi
            if angle < lo - slack:
                issues.append((name, dof, angle, lo, hi, lo - slack - angle))
            elif angle > hi + slack:
                issues.append((name, dof, angle, lo, hi, angle - hi - slack))

    for i, seg in enumerate(nf["spine"], start=1):
        check("spine", f"spine{i}", seg)
    for key, jt in K.FRAME_JOINTS.items():
        check(jt, key, nf[key])
    return issues


# ---------------------------------------------------------------------- check
# `--check` fail-fast validation: simulate the exact per-tick pipeline
# render_exercise drives (prepare -> frame_geometry per key frame -> timeline
# -> frame_geometry per tick) at pitch 0 with no mat — the camera elevation
# and the exercise mat are presentation-only, while pins and the ground line
# are authored flat — and check invariants the solver has historically
# swallowed silently. Compute-only: never touches frames/, preview.gif, or
# any other output file.

PIN_TO_LIMB = {pin: limb for limb, (_attach, _sigma, pin) in K.LIMBS.items()}

LIMB_JOINT_NAMES = {
    "arm_r": ("shoulder", "elbow", "hand"), "arm_l": ("shoulder", "elbow", "hand"),
    "leg_r": ("hip", "knee", "ankle", "toe"), "leg_l": ("hip", "knee", "ankle", "toe"),
}
SPINE_POINT_NAMES = ("pelvis", "ctrl", "neck")

# Joints whose DoFs a lerp actually sweeps between adjacent key frames.
WRAP_JOINTS = {
    "shoulder_r": "shoulder", "shoulder_l": "shoulder", "hip_r": "hip", "hip_l": "hip",
    "elbow_r": "elbow", "elbow_l": "elbow", "knee_r": "knee", "knee_l": "knee",
    "ankle_r": "ankle", "ankle_l": "ankle", "neck": "neck",
}
# Joints IK can actually rewrite (the pinned-limb pairs) — the ones worth
# comparing authored vs resolved for silent pin-vs-pose contradictions.
DRIFT_JOINTS = {
    "shoulder_r": "shoulder", "shoulder_l": "shoulder", "hip_r": "hip", "hip_l": "hip",
    "elbow_r": "elbow", "elbow_l": "elbow", "knee_r": "knee", "knee_l": "knee",
}

PIN_TOL = 3.0               # canvas units: drawn extremity vs its authored pin.
                            # Sized just above the solver's own rotation-gating
                            # residual band (~2.8px worst-case for a correctly
                            # authored on-pin branch; see BRANCH_REPRO_TOL in
                            # kinematics.py) so the gate fails authoring errors,
                            # not solver-inherent precision.
PIN_UNREACHABLE_TOL = 4.0   # canvas units: drawn extremity vs the reach-clamped target
CONTINUITY_TOL = 16.0       # canvas units: any drawn joint, tick to tick
WRAP_TOL = 180.0            # degrees: any angle DoF, adjacent resolved key frames
DRIFT_TOL = 45.0            # degrees: resolved vs authored, on pinned-limb DoFs
GROUND_TOL = 2.0            # canvas units of slack below GROUND_Y
ROM_SLACK = 5.0             # degrees of slack for the resolved-ROM re-check


def _dist(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def _pt(p):
    return f"({p[0]:.0f},{p[1]:.0f})"


def _joint_points(geo):
    """Every drawn joint worth a continuity check: head, spine, and every
    point of every limb chain (attach through extremity, incl. toes)."""
    pts = {"head": geo["head"]}
    for nm, p in zip(SPINE_POINT_NAMES, geo["spine"]):
        pts[f"spine.{nm}"] = p
    for limb, names in LIMB_JOINT_NAMES.items():
        for nm, p in zip(names, geo[limb]):
            pts[f"{limb}.{nm}"] = p
    return pts


def _ground_points(geo):
    """`_joint_points` plus the shoulder-girdle and pelvis bar endpoints."""
    pts = dict(_joint_points(geo))
    pts["girdle.l"], pts["girdle.r"] = geo["girdle"][0], geo["girdle"][2]
    pts["pelvisBar.l"], pts["pelvisBar.r"] = geo["pelvisBar"][0], geo["pelvisBar"][2]
    return pts


def _limb_reach(prof, limb):
    a, b = ((prof["upperArm"], prof["foreArm"]) if limb.startswith("arm")
            else (prof["thigh"], prof["shin"]))
    return a + b - 0.01  # mirrors solve_limb's own reach clamp


def _add(violations, cls, joint, where, mag, unit, noun, detail):
    violations.append({"cls": cls, "joint": joint, "where": where, "mag": mag,
                        "unit": unit, "noun": noun, "detail": detail})


def check_exercise(folder, figure="neutral"):
    """Simulate the complete render loop for one exercise — resolve every key
    frame, build the tween timeline, re-resolve every tick — at pitch 0 with
    no mat, collecting violations of the six invariants (pin fidelity,
    pin-unreachable, continuity, wrap, authored-vs-resolved drift, resolved
    ROM). Returns (name, [violation, ...]); an empty list means it passed.
    """
    name = folder.name
    violations = []
    try:
        motion = load_motion(folder)
        name = motion["name"]
        skel = K.load_skeleton()
        norms, prof, cam, _pitch, _props, _zoom = prepare(motion, figure)

        resolved = [frame_geometry(nf, prof, cam, pitch=0.0)[0] for nf in norms]
        n = len(resolved)

        # 6. RESOLVED ROM — the authored-frame ROM gate, re-run post-IK.
        for i, rf in enumerate(resolved, start=1):
            for jname, dof, angle, lo, hi, over in _resolved_rom_issues(rf, skel["joints"], ROM_SLACK):
                joint, where = f"{jname}.{dof}", f"keyframe {i}"
                _add(violations, "ROM", joint, where, over, "deg", "keyframes",
                     f"ROM {joint}: {where} resolves to {angle:.1f}, outside "
                     f"[{lo}, {hi}] (+/-{ROM_SLACK:g} slack) by {over:.1f}deg")

        # 4. AUTHORED-VS-RESOLVED DRIFT — pins silently overriding the pose.
        for i in range(n):
            for jkey, jt in DRIFT_JOINTS.items():
                for dof in K.JOINT_DOFS[jt]:
                    a, b = norms[i][jkey][dof], resolved[i][jkey][dof]
                    diff = abs(a - b)
                    if diff > DRIFT_TOL:
                        joint, where = f"{jkey}.{dof}", f"keyframe {i + 1}"
                        _add(violations, "DRIFT", joint, where, diff, "deg", "keyframes",
                             f"DRIFT {joint}: {where} authored {a:.1f} vs resolved "
                             f"{b:.1f} — {diff:.1f}deg")

        # 3. WRAP — adjacent resolved key frames, including the loop closure.
        if n > 1:
            for i in range(n):
                j = (i + 1) % n
                da = _dof_values(resolved[i], WRAP_JOINTS)
                db = _dof_values(resolved[j], WRAP_JOINTS)
                where = f"keyframe {i + 1}->{j + 1}"
                for key in da:
                    diff = abs(da[key] - db[key])
                    if diff > WRAP_TOL:
                        _add(violations, "WRAP", key, where, diff, "deg", "keyframe pairs",
                             f"WRAP {key}: {where} {da[key]:.1f} -> {db[key]:.1f} — "
                             f"{diff:.1f}deg")

        # 1/2/5. Per-tick pass: PIN / PIN-UNREACHABLE, CONTINUITY, GROUND.
        ticks = timeline(resolved)
        total = len(ticks)
        prev = None
        for t, nf in enumerate(ticks):
            _work, geo, _order, _shade = frame_geometry(nf, prof, cam, pitch=0.0)
            where = f"tick {t + 1}/{total}"

            for pin_name, pin_xy in nf["pins"].items():
                limb = PIN_TO_LIMB.get(pin_name)
                if limb is None:
                    continue
                attach, drawn = geo[limb][0], geo[limb][2]
                d_attach_pin = _dist(attach, pin_xy)
                reach = _limb_reach(prof, limb)
                if d_attach_pin > reach:
                    over = d_attach_pin - reach
                    # Sub-PIN_TOL overreach parks the extremity invisibly short
                    # of its pin (authors place pins at full extension); only a
                    # visible shortfall is an authoring error.
                    if over > PIN_TOL:
                        _add(violations, "PIN-UNREACHABLE", limb, where, over, "px", "ticks",
                             f"PIN-UNREACHABLE {limb}: {where} pin {_pt(pin_xy)} is "
                             f"{d_attach_pin:.1f}px from attach, reach {reach:.1f} — "
                             f"{over:.1f}px over")
                    scale = reach / d_attach_pin
                    target = (attach[0] + (pin_xy[0] - attach[0]) * scale,
                              attach[1] + (pin_xy[1] - attach[1]) * scale)
                    tol = PIN_UNREACHABLE_TOL
                else:
                    target, tol = pin_xy, PIN_TOL
                d = _dist(drawn, target)
                if d > tol:
                    _add(violations, "PIN", limb, where, d, "px", "ticks",
                         f"PIN {limb}: {where} drawn {_pt(drawn)} vs pin "
                         f"{_pt(target)} — {d:.1f}px")

            jp = _joint_points(geo)
            gp = _ground_points(geo)
            for joint, p in gp.items():
                if p[1] > GROUND_Y + GROUND_TOL:
                    depth = p[1] - (GROUND_Y + GROUND_TOL)
                    _add(violations, "GROUND", joint, where, depth, "px", "ticks",
                         f"GROUND {joint}: {where} at y={p[1]:.1f} — {depth:.1f}px "
                         f"below ground")
            if prev is not None:
                trans = f"tick {t}->{t + 1}/{total}"
                for joint, p in jp.items():
                    if joint not in prev:
                        continue
                    d = _dist(prev[joint], p)
                    if d > CONTINUITY_TOL:
                        _add(violations, "CONTINUITY", joint, trans, d, "px", "ticks",
                             f"CONTINUITY {joint}: {trans} moved {d:.1f}px")
            prev = jp
    except Exception as e:  # a solver bug must not crash the whole check run
        _add(violations, "ERROR", "-", "-", float("inf"), "", "",
             f"ERROR: {type(e).__name__}: {e}")
    return name, violations


def _report_exercise(name, violations):
    print(f"  {name}: FAIL ({len(violations)} violation(s))")
    groups = {}
    for v in violations:
        groups.setdefault((v["cls"], v["joint"]), []).append(v)
    for (cls, joint), vs in sorted(groups.items()):
        vs.sort(key=lambda v: -v["mag"])
        worst = vs[0]
        if len(vs) == 1:
            print(f"    {worst['detail']}")
        else:
            print(f"    {cls} {joint}: {len(vs)} {worst['noun']}, "
                  f"max {worst['mag']:.1f}{worst['unit']} (worst {worst['where']})")


def run_check(folders, figure="neutral"):
    """`--check`: simulate every render tick for each exercise and fail hard
    on the six invariant classes. Prints a compact per-exercise violation
    block (silence = pass) and a final tally; returns True iff every
    exercise passed clean."""
    total_violations = 0
    failed = 0
    for folder in folders:
        name, violations = check_exercise(folder, figure)
        total_violations += len(violations)
        if violations:
            failed += 1
            _report_exercise(name, violations)
    print(f"{len(folders)} exercises checked, {failed} failed, "
          f"{total_violations} violations")
    return failed == 0


def main():
    flags = [a for a in sys.argv[1:] if a.startswith("--")]
    names = [a for a in sys.argv[1:] if not a.startswith("--")]
    figure = "neutral"
    sheet = None            # False = off, None+flag = default path, str = custom path
    for f in flags:
        if f.startswith("--figure="):
            figure = f.split("=", 1)[1]
        elif f.startswith("--sheet"):
            sheet = f.split("=", 1)[1] if "=" in f else True
    folders = ([LIB / n for n in names] if names
               else sorted(p.parent for p in LIB.glob("*/motion.json")))
    if "--check" in flags:
        sys.exit(0 if run_check(folders, figure=figure) else 1)
    if "--export" in flags:
        if not run_check(folders, figure=figure):
            print("  --export aborted: fix the --check violations above first.")
            sys.exit(1)
        export_app_resources(folders)
        return
    if "--fixtures" in flags:
        write_fixtures(folders)
        return
    if "--orbit" in flags:
        for folder in folders:
            render_orbit(folder, figure=figure)
        return
    for folder in folders:
        render_exercise(folder, figure=figure, flip="--flip" in flags,
                        strict="--strict" in flags)
    if sheet:
        contact_sheet(folders, figure=figure, out=None if sheet is True else sheet)
    if "--demo" in flags:
        demo_sheet(folders[0] if names else LIB / "Bird Dog")


if __name__ == "__main__":
    main()
