"""Draw Bolt — the pet — as per-state PNG frame sequences.

Produces the `assets/sprites/<state>/frame_NN.png` convention that
`bolt_pet/ui/sprite.py` loads (see `assets/sprites/README.md`). The art is
generated rather than sourced so it stays editable: tweak a colour or a pose
parameter here and re-run, instead of hand-editing 24 PNGs.

    python scripts/generate_bolt_sprites.py                 # write into the real asset dir
    python scripts/generate_bolt_sprites.py --out /tmp/prev # preview somewhere else

Everything is drawn in normalised 0..1 coordinates on a square canvas and
super-sampled `SS`x before being downscaled, because PIL's draw primitives
have no antialiasing of their own.
"""

from __future__ import annotations

import argparse
import math
from pathlib import Path

from PIL import Image, ImageDraw

SS = 4  # supersampling factor
OUT = 320  # final frame size (2x the default PET_SIZE of 160)
S = OUT * SS

# --- palette ---------------------------------------------------------------
# A cream shepherd-ish pup with a slate cap, amber eyes and a lightning blaze.
C_OUTLINE = (34, 42, 58, 255)
C_FUR = (246, 244, 238, 255)
C_FUR_SHADE = (214, 210, 200, 255)
C_DARK = (78, 92, 122, 255)
C_DARK2 = (58, 70, 96, 255)
C_INNER_EAR = (226, 154, 158, 255)
C_BROW = (206, 166, 118, 255)
# The far side of the walking pose. Distinctly darker than C_FUR_SHADE, which
# is too close to the cream to read as "behind the dog" at 160px.
C_FUR_FAR = (168, 176, 192, 255)
C_NOSE = (40, 48, 66, 255)
C_IRIS = (196, 128, 50, 255)
C_PUPIL = (30, 36, 50, 255)
C_WHITE = (255, 255, 255, 255)
C_BOLT = (255, 206, 61, 255)
C_COLLAR = (222, 84, 46, 255)
C_TAG = (255, 198, 68, 255)
C_TONGUE = (230, 116, 128, 255)
C_GLOW = (92, 214, 244, 255)

# --- layout constants (normalised) -----------------------------------------
HEAD_CX, HEAD_CY = 0.50, 0.375
HEAD_W, HEAD_H = 0.50, 0.44
NECK_Y = 0.565  # head layer rotates about here so tilts pivot at the neck
EAR_PIVOT = 0.335, 0.275

OW = 0.0105  # outline width, normalised


def px(v: float) -> float:
    return v * S


def _w(width: float) -> int:
    return max(1, int(round(px(width))))


def ell(d, cx, cy, w, h, fill, outline=C_OUTLINE, ow=OW):
    d.ellipse(
        [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)],
        fill=fill,
        outline=outline,
        width=_w(ow) if outline else 0,
    )


def rrect(d, cx, cy, w, h, r, fill, outline=C_OUTLINE, ow=OW):
    d.rounded_rectangle(
        [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)],
        radius=px(r),
        fill=fill,
        outline=outline,
        width=_w(ow) if outline else 0,
    )


def poly(d, pts, fill, outline=C_OUTLINE, ow=OW):
    d.polygon(
        [(px(x), px(y)) for x, y in pts],
        fill=fill,
        outline=outline,
        width=_w(ow) if outline else 0,
    )


def rotate_pts(pts, pivot, deg):
    a = math.radians(deg)
    ca, sa = math.cos(a), math.sin(a)
    ox, oy = pivot
    out = []
    for x, y in pts:
        dx, dy = x - ox, y - oy
        out.append((ox + dx * ca - dy * sa, oy + dx * sa + dy * ca))
    return out


def lerp(a, b, t):
    return a + (b - a) * t


def bolt_shape(cx, cy, w, h):
    """A lightning bolt polygon in a (w x h) box centred on (cx, cy)."""
    unit = [
        (0.62, 0.00),
        (0.10, 0.56),
        (0.44, 0.56),
        (0.28, 1.00),
        (0.90, 0.40),
        (0.55, 0.40),
        (0.80, 0.00),
    ]
    return [(cx + (u - 0.5) * w, cy + (v - 0.5) * h) for u, v in unit]


# --- body ------------------------------------------------------------------
def _tail_points(p, steps=26):
    """Quadratic-bezier spine of the tail as (x, y, radius) samples.

    Shared by the fill and outline passes so a wag can't move one and not the
    other. The base sits deep inside the haunch, which is drawn over it, so
    the tail reads as growing out of the body rather than floating beside it.
    """
    wag = p["tail"]
    base = (0.620, 0.845)
    ctrl = (0.955, 0.870 - 0.025 * wag)
    end = (0.905, 0.605 - 0.065 * wag)
    pts = []
    for i in range(steps + 1):
        t = i / steps
        x = (1 - t) ** 2 * base[0] + 2 * (1 - t) * t * ctrl[0] + t**2 * end[0]
        y = (1 - t) ** 2 * base[1] + 2 * (1 - t) * t * ctrl[1] + t**2 * end[1]
        pts.append((x, y, lerp(0.080, 0.042, t)))
    return pts


def draw_tapered(d, pts, color_at):
    """Draw a tapered limb from (x, y, radius) samples.

    Two passes: circles along the spine for the fill, then the two silhouette
    edges, so it reads as one solid shape instead of a string of beads.
    *color_at* takes 0..1 along the length, which is how the tail gets its
    cream tip.
    """
    last = len(pts) - 1
    for i, (x, y, r) in enumerate(pts):
        ell(d, x, y, r * 2, r * 2, color_at(i / last), outline=None)
    for side in (1, -1):
        edge = []
        for i, (x, y, r) in enumerate(pts):
            j = min(i + 1, last)
            k = max(i - 1, 0)
            tx, ty = pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]
            n = math.hypot(tx, ty) or 1e-6
            nx, ny = -ty / n, tx / n
            edge.append((x + nx * r * side, y + ny * r * side))
        d.line([(px(x), px(y)) for x, y in edge], fill=C_OUTLINE, width=_w(OW), joint="curve")
    x, y, r = pts[last]
    ell(d, x, y, r * 2, r * 2, color_at(1.0))


def draw_tail(d, p):
    # Only the last stretch is the cream tip. The haunch hides the first ~half
    # of the tail, so a generous tip leaves the visible part looking like a
    # pale blob floating next to the dog rather than its tail.
    draw_tapered(d, _tail_points(p), lambda t: C_DARK if t < 0.84 else C_FUR)


def draw_body(d, p):
    br = p["breathe"]
    # haunches (sitting)
    ell(d, 0.285, 0.795, 0.235, 0.275, C_DARK)
    ell(d, 0.715, 0.795, 0.235, 0.275, C_DARK)
    # torso
    ell(d, 0.50, 0.745 - 0.004 * br, 0.455 + 0.012 * br, 0.395 + 0.014 * br, C_DARK)
    # front legs
    for cx in (0.415, 0.585):
        rrect(d, cx, 0.845, 0.125, 0.215, 0.062, C_FUR)
        ell(d, cx, 0.925, 0.155, 0.095, C_FUR)
    # chest / belly blaze
    ell(d, 0.50, 0.735 - 0.004 * br, 0.275 + 0.008 * br, 0.315 + 0.012 * br, C_FUR)
    # toes
    for cx in (0.415, 0.585):
        for off in (-0.035, 0.0, 0.035):
            d.arc(
                [px(cx + off - 0.017), px(0.902), px(cx + off + 0.017), px(0.945)],
                start=250,
                end=290,
                fill=C_FUR_SHADE,
                width=_w(0.007),
            )


def draw_collar(d, p):
    rrect(d, 0.50, 0.585, 0.315, 0.062, 0.031, C_COLLAR)
    tag = C_GLOW if p.get("tag_glow") else C_TAG
    ell(d, 0.50, 0.638, 0.082, 0.082, tag)
    poly(d, bolt_shape(0.50, 0.638, 0.030, 0.052), C_OUTLINE, outline=None)


# --- head ------------------------------------------------------------------
# Ear outline in a *local* frame: origin at the base on the skull, +x points
# outward (away from the muzzle), +y points up. Keeping it side-agnostic here
# and mirroring at draw time avoids sign confusion — an earlier version mixed
# the conventions and the ears flattened into a brim whenever they rotated.
_EAR_LOCAL = [
    (-0.058, -0.038),
    (0.078, -0.038),
    (0.092, 0.140),
    (0.030, 0.248),
    (-0.038, 0.122),
]


def _ear_polygon(side, lean_deg):
    """Mirror + lean the local ear, returning canvas-space points.

    *lean_deg* tips the ear away from vertical: 0 is fully perked, larger
    values relax and eventually droop it out sideways.
    """
    a = math.radians(lean_deg)
    ca, sa = math.cos(a), math.sin(a)
    pivot_x = 0.5 + side * (0.5 - EAR_PIVOT[0])
    pts = []
    for x, y in _EAR_LOCAL:
        rx = x * ca + y * sa
        ry = -x * sa + y * ca
        pts.append((pivot_x + side * rx, EAR_PIVOT[1] - ry))
    return pts


def draw_ears(d, p):
    perk = p["ear"]
    twitch = p.get("ear_twitch", 0.0)
    for side in (-1, 1):
        lean = 18.0 * (1.0 - perk) + 44.0 * max(0.0, -perk)
        if side == 1:
            lean -= twitch * 12.0
        pts = _ear_polygon(side, lean)
        poly(d, pts, C_DARK)
        base_mid = (
            (pts[0][0] + pts[1][0]) / 2,
            (pts[0][1] + pts[1][1]) / 2,
        )
        inner = [(lerp(base_mid[0], x, 0.60), lerp(base_mid[1], y, 0.64)) for x, y in pts]
        poly(d, inner, C_INNER_EAR, outline=None)


def draw_cap(layer, p):
    """Slate cap over the top of the head, clipped to the head silhouette."""
    mask = Image.new("L", (S, S), 0)
    ImageDraw.Draw(mask).ellipse(
        [
            px(HEAD_CX - HEAD_W / 2),
            px(HEAD_CY - HEAD_H / 2),
            px(HEAD_CX + HEAD_W / 2),
            px(HEAD_CY + HEAD_H / 2),
        ],
        fill=255,
    )
    cap = Image.new("RGBA", (S, S), (0, 0, 0, 0))
    dc = ImageDraw.Draw(cap)
    ell(dc, HEAD_CX, 0.245, 0.54, 0.30, C_DARK, outline=None)
    # brow dip between the eyes, so the cap reads as a marking not a helmet
    ell(dc, HEAD_CX, 0.352, 0.155, 0.115, C_FUR, outline=None)
    cap.putalpha(Image.composite(cap.getchannel("A"), Image.new("L", (S, S), 0), mask))
    layer.alpha_composite(cap)


def draw_eyes(d, p):
    blink = p["blink"]
    lx, ly = 0.383, 0.372
    rx, ry = 0.617, 0.372
    dx, dy = p.get("look", (0.0, 0.0))
    for cx, cy in ((lx, ly), (rx, ry)):
        if p.get("cross"):
            for ang in (45, -45):
                a = math.radians(ang)
                hx, hy = 0.042 * math.cos(a), 0.042 * math.sin(a)
                d.line(
                    [px(cx - hx), px(cy - hy), px(cx + hx), px(cy + hy)],
                    fill=C_OUTLINE,
                    width=_w(0.014),
                )
            continue
        if blink > 0.55:
            d.arc(
                [px(cx - 0.052), px(cy - 0.030), px(cx + 0.052), px(cy + 0.040)],
                start=200,
                end=340,
                fill=C_OUTLINE,
                width=_w(0.013),
            )
            continue
        h = lerp(0.118, 0.030, blink)
        ell(d, cx, cy, 0.106, h, C_WHITE)
        if h > 0.06:
            ell(d, cx + dx, cy + dy * 0.6, 0.082, min(h - 0.022, 0.092), C_IRIS, outline=None)
            ell(d, cx + dx, cy + dy * 0.6, 0.046, min(h - 0.045, 0.056), C_PUPIL, outline=None)
            ell(d, cx + dx - 0.020, cy + dy * 0.6 - 0.024, 0.030, 0.026, C_WHITE, outline=None)
    # Tan brow dots on the slate cap (the shepherd/doberman marking) rather
    # than dashes — as lines above the eyes they read as heavy eyelids and
    # make an idle pet look permanently fed up.
    raise_ = p.get("brow", 0.0)
    angry = p.get("brow_angle", 0.0)
    for side, cx in ((-1, lx), (1, rx)):
        by = 0.291 - 0.020 * raise_
        ell(
            d,
            cx + side * 0.006,
            by + side * angry * 0.020,
            0.062,
            0.040,
            C_BROW,
            outline=None,
        )


def draw_muzzle(d, p):
    mouth = p["mouth"]
    ell(d, 0.50, 0.487, 0.285, 0.195, C_FUR)
    # nose
    ell(d, 0.50, 0.440, 0.105, 0.078, C_NOSE, outline=None)
    ell(d, 0.478, 0.428, 0.030, 0.020, (92, 102, 124, 255), outline=None)
    if mouth > 0.02:
        h = 0.030 + 0.085 * mouth
        w = 0.105 + 0.055 * mouth
        ell(d, 0.50, 0.500 + h / 2 - 0.008, w, h, C_NOSE)
        ell(d, 0.50, 0.500 + h * 0.72, w * 0.60, h * 0.52, C_TONGUE, outline=None)
    else:
        # closed muzzle: a short philtrum down from the nose into two
        # downward-bulging curves (PIL arcs run clockwise from 3 o'clock with
        # y down, so 0->180 is the lower half — the smiling side).
        d.line([px(0.50), px(0.470), px(0.50), px(0.508)], fill=C_OUTLINE, width=_w(0.011))
        for side in (-1, 1):
            cx = 0.50 + side * 0.032
            d.arc(
                [px(cx - 0.032), px(0.492), px(cx + 0.032), px(0.536)],
                start=0,
                end=180,
                fill=C_OUTLINE,
                width=_w(0.011),
            )


def draw_head(layer, p):
    d = ImageDraw.Draw(layer)
    draw_ears(d, p)
    ell(d, HEAD_CX, HEAD_CY, HEAD_W, HEAD_H, C_FUR)
    draw_cap(layer, p)
    # blaze
    poly(d, bolt_shape(0.50, 0.243, 0.088, 0.150), C_BOLT, outline=None)
    draw_muzzle(d, p)
    draw_eyes(d, p)


# --- extras ----------------------------------------------------------------
def draw_extras(layer, p):
    d = ImageDraw.Draw(layer)
    kind = p.get("extras")
    if kind == "listen":
        for i in range(3):
            r = 0.045 + i * 0.036
            alpha = int(210 - i * 55)
            phase = p.get("phase", 0)
            if (phase + i) % 3 == 0:
                alpha = min(255, alpha + 45)
            d.arc(
                [px(0.845 - r), px(0.235 - r), px(0.845 + r), px(0.235 + r)],
                start=200,
                end=340,
                fill=C_GLOW[:3] + (alpha,),
                width=_w(0.014),
            )
    elif kind == "think":
        phase = p.get("phase", 0)
        for i in range(3):
            grow = 1.0 if i == phase % 3 else 0.62
            ell(
                layer_d := d,
                0.735 + i * 0.072,
                0.145 - i * 0.030,
                0.040 * grow,
                0.040 * grow,
                C_GLOW,
                outline=C_OUTLINE,
                ow=0.008,
            )
    elif kind == "error":
        poly(d, bolt_shape(0.815, 0.185, 0.070, 0.120), (235, 92, 74, 255))


# --- side view: the walk cycle ---------------------------------------------
# The pose above is a front-facing sit, which is right for standing around but
# slides like a chess piece the moment the pet actually moves. Walking gets its
# own construction: a profile torso, four legs following a paw path, and a head
# side-on. Drawn facing RIGHT — ui/pet_window.py mirrors it when he walks left.

_GROUND = 0.930  # paw centre while a foot is planted
_STRIDE = 0.088  # how far ahead of / behind the pivot a paw reaches
_LIFT = 0.080  # peak height of a paw mid-swing
_STANCE = 0.62  # fraction of the cycle a foot spends on the ground

FRONT_PIVOT = (0.650, 0.620)
HIND_PIVOT = (0.315, 0.640)
WALK_HEAD = (0.780, 0.370, 0.260, 0.250)  # cx, cy, w, h


def paw_position(pivot, phase):
    """Where one paw is at *phase* (0..1) of the cycle.

    Stance is the half that matters: the foot is planted and travels backwards
    under the dog at a constant rate. The window advances this cycle by
    distance travelled rather than by clock, so that backwards travel cancels
    the forward motion and the feet don't skate.
    """
    phase %= 1.0
    if phase < _STANCE:
        t = phase / _STANCE
        return pivot[0] + _STRIDE - 2 * _STRIDE * t, _GROUND
    t = (phase - _STANCE) / (1.0 - _STANCE)
    return (
        pivot[0] - _STRIDE + 2 * _STRIDE * t,
        _GROUND - _LIFT * math.sin(math.pi * t),
    )


def draw_leg(d, pivot, paw, fill, bend=0.032, top=0.052, toe=0.030):
    """A limb from pivot to paw: a bezier through a displaced knee, tapered
    from thigh to ankle.

    Tapering matters more than it sounds — a constant-width limb reads as a
    length of white pipe, and four of them make the dog look like furniture.
    """
    vx, vy = paw[0] - pivot[0], paw[1] - pivot[1]
    length = math.hypot(vx, vy) or 1e-6
    nx, ny = -vy / length, vx / length  # perpendicular; points backwards
    knee = (
        (pivot[0] + paw[0]) / 2 + nx * bend,
        (pivot[1] + paw[1]) / 2 + ny * bend,
    )
    pts = []
    for i in range(13):
        t = i / 12
        x = (1 - t) ** 2 * pivot[0] + 2 * (1 - t) * t * knee[0] + t**2 * paw[0]
        y = (1 - t) ** 2 * pivot[1] + 2 * (1 - t) * t * knee[1] + t**2 * paw[1]
        pts.append((x, y, lerp(top, toe, t)))
    draw_tapered(d, pts, lambda _t: fill)
    ell(d, paw[0], paw[1] + 0.008, 0.098, 0.056, fill)


def draw_walk_tail(d, p, dy):
    """A curled plume over the back.

    Cubic rather than quadratic: a single control point can only bend one way,
    which gives a straight tapered tube — a club with a white ball on the end,
    not a tail. The curl back over the spine is what makes it read.
    """
    wag = p["tail"]
    base = (0.250, 0.575 + dy)
    c1 = (0.075, 0.545 + dy - 0.030 * wag)
    c2 = (0.070, 0.300 + dy - 0.040 * wag)
    end = (0.215, 0.290 + dy - 0.020 * wag)
    pts = []
    for i in range(29):
        t = i / 28
        u = 1 - t
        x = u**3 * base[0] + 3 * u**2 * t * c1[0] + 3 * u * t**2 * c2[0] + t**3 * end[0]
        y = u**3 * base[1] + 3 * u**2 * t * c1[1] + 3 * u * t**2 * c2[1] + t**3 * end[1]
        pts.append((x, y, lerp(0.076, 0.028, t)))
    draw_tapered(d, pts, lambda t: C_DARK if t < 0.90 else C_FUR)


def draw_torso(d, dy):
    """Rump + barrel + chest as one silhouette.

    Drawn in two passes — every shape swollen by the stroke width in the
    outline colour, then every shape again at true size in the fill. Outlining
    each piece individually instead leaves the construction arcs showing
    across the body, which looks like the dog has panel lines.
    """
    shapes = [
        ("ell", 0.300, 0.600 + dy, 0.290, 0.300, 0.0),
        ("rrect", 0.480, 0.585 + dy, 0.520, 0.265, 0.130),
        ("ell", 0.650, 0.590 + dy, 0.250, 0.280, 0.0),
    ]
    grow = 2 * OW
    for colour, pad in ((C_OUTLINE, grow), (C_DARK, 0.0)):
        for shape in shapes:
            kind, cx, cy, w, h, extra = shape
            if kind == "ell":
                ell(d, cx, cy, w + pad, h + pad, colour, outline=None)
            else:
                rrect(d, cx, cy, w + pad, h + pad, extra + pad / 2, colour, outline=None)
    # Belly kept small and low: any bigger and it merges with the cream legs
    # into one white mass with a slate lid.
    ell(d, 0.490, 0.672 + dy, 0.350, 0.098, C_FUR, outline=None)


def draw_walk_head(layer, p, dy):
    d = ImageDraw.Draw(layer)
    cx, cy, w, h = WALK_HEAD[0], WALK_HEAD[1] + dy, WALK_HEAD[2], WALK_HEAD[3]

    # ear first, so the head covers its base
    bounce = p.get("ear_bounce", 0.0)
    ear = [
        (0.690, cy - 0.030),
        (0.700, cy - 0.150 - 0.012 * bounce),
        (0.752, cy - 0.205 - 0.018 * bounce),
        (0.788, cy - 0.090),
    ]
    poly(d, ear, C_DARK)
    inner = [(lerp(0.735, x, 0.58), lerp(cy - 0.040, y, 0.62)) for x, y in ear]
    poly(d, inner, C_INNER_EAR, outline=None)

    # neck into the chest
    d.line(
        [px(0.660), px(cy + 0.190), px(0.735), px(cy + 0.080)],
        fill=C_OUTLINE,
        width=_w(0.215),
        joint="curve",
    )
    d.line(
        [px(0.660), px(cy + 0.190), px(0.735), px(cy + 0.080)],
        fill=C_DARK,
        width=_w(0.190),
        joint="curve",
    )

    ell(d, cx, cy, w, h, C_FUR)

    # slate cap, clipped to the skull
    mask = Image.new("L", (S, S), 0)
    ImageDraw.Draw(mask).ellipse(
        [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], fill=255
    )
    cap = Image.new("RGBA", (S, S), (0, 0, 0, 0))
    dc = ImageDraw.Draw(cap)
    ell(dc, cx - 0.010, cy - 0.070, w * 1.02, h * 0.72, C_DARK, outline=None)
    cap.putalpha(Image.composite(cap.getchannel("A"), Image.new("L", (S, S), 0), mask))
    layer.alpha_composite(cap)

    poly(d, bolt_shape(0.762, cy - 0.088, 0.062, 0.108), C_BOLT, outline=None)

    # muzzle, nose, mouth
    ell(d, 0.880, cy + 0.048, 0.145, 0.108, C_FUR)
    ell(d, 0.950, cy + 0.018, 0.058, 0.048, C_NOSE, outline=None)
    d.arc(
        [px(0.885), px(cy + 0.058), px(0.945), px(cy + 0.100)],
        start=0,
        end=150,
        fill=C_OUTLINE,
        width=_w(0.010),
    )

    # one eye in profile, plus the brow marking
    ell(d, 0.812, cy - 0.020, 0.092, 0.100, C_WHITE)
    ell(d, 0.820, cy - 0.020, 0.062, 0.070, C_IRIS, outline=None)
    ell(d, 0.824, cy - 0.020, 0.036, 0.042, C_PUPIL, outline=None)
    ell(d, 0.812, cy - 0.040, 0.026, 0.022, C_WHITE, outline=None)
    ell(d, 0.795, cy - 0.088, 0.055, 0.034, C_BROW, outline=None)

    # Collar: a band *across* the neck, so it has to run perpendicular to it.
    # Along the neck it just reads as an orange brick stuck to his chest.
    collar = [
        (px(0.648), px(cy + 0.098)),
        (px(0.762), px(cy + 0.196)),
    ]
    d.line(collar, fill=C_OUTLINE, width=_w(0.070), joint="curve")
    d.line(collar, fill=C_COLLAR, width=_w(0.050), joint="curve")
    ell(d, 0.712, cy + 0.196, 0.070, 0.070, C_TAG)
    poly(d, bolt_shape(0.712, cy + 0.196, 0.025, 0.044), C_OUTLINE, outline=None)


def render_walk_frame(p) -> Image.Image:
    base = Image.new("RGBA", (S, S), (0, 0, 0, 0))
    d = ImageDraw.Draw(base)
    phase = p["phase"]
    # Two contacts per cycle, so the body dips twice — the give-away that a
    # walk cycle is weight-bearing rather than a slide.
    dy = -0.011 * abs(math.sin(2 * math.pi * phase))
    head_dy = dy * 0.6 - 0.006 * math.sin(2 * math.pi * phase + 0.7)

    # Diagonal pairs (a trot): each front leg moves with the opposite hind.
    far_front = paw_position(FRONT_PIVOT, phase + 0.5)
    far_hind = paw_position(HIND_PIVOT, phase)
    near_front = paw_position(FRONT_PIVOT, phase)
    near_hind = paw_position(HIND_PIVOT, phase + 0.5)

    draw_walk_tail(d, p, dy)
    # far side first, in the shade colour, so the near legs read as in front
    draw_leg(d, (HIND_PIVOT[0], HIND_PIVOT[1] + dy), far_hind, C_FUR_FAR, bend=0.046)
    draw_leg(d, (FRONT_PIVOT[0], FRONT_PIVOT[1] + dy), far_front, C_FUR_FAR)

    draw_torso(d, dy)

    draw_leg(d, (HIND_PIVOT[0], HIND_PIVOT[1] + dy), near_hind, C_FUR, bend=0.046)
    draw_leg(d, (FRONT_PIVOT[0], FRONT_PIVOT[1] + dy), near_front, C_FUR)

    draw_walk_head(base, p, head_dy)
    return base.resize((OUT, OUT), Image.LANCZOS)


# --- frame assembly --------------------------------------------------------
def default_pose(**over):
    p = dict(
        breathe=0.0,
        tail=0.0,
        ear=0.0,
        ear_twitch=0.0,
        blink=0.0,
        mouth=0.0,
        tilt=0.0,
        head_dy=0.0,
        look=(0.0, 0.0),
        brow=0.0,
        brow_angle=0.0,
        cross=False,
        tag_glow=False,
        extras=None,
        phase=0,
    )
    p.update(over)
    return p


def render_frame(p) -> Image.Image:
    if p.get("pose") == "walk":
        return render_walk_frame(p)
    base = Image.new("RGBA", (S, S), (0, 0, 0, 0))

    body = Image.new("RGBA", (S, S), (0, 0, 0, 0))
    db = ImageDraw.Draw(body)
    draw_tail(db, p)
    draw_body(db, p)
    draw_collar(db, p)
    base.alpha_composite(body)

    head = Image.new("RGBA", (S, S), (0, 0, 0, 0))
    draw_head(head, p)
    if p["tilt"]:
        head = head.rotate(
            p["tilt"], resample=Image.BICUBIC, center=(px(HEAD_CX), px(NECK_Y))
        )
    dy = int(px(p["head_dy"]))
    if dy:
        shifted = Image.new("RGBA", (S, S), (0, 0, 0, 0))
        shifted.alpha_composite(head, (0, dy))
        head = shifted
    base.alpha_composite(head)

    draw_extras(base, p)
    return base.resize((OUT, OUT), Image.LANCZOS)


def frames_for(state: str) -> list[dict]:
    if state == "idle":
        out = []
        for i in range(8):
            t = i / 8
            br = math.sin(t * 2 * math.pi)
            out.append(
                default_pose(
                    breathe=br,
                    head_dy=-0.006 * br,
                    tail=math.sin(t * 4 * math.pi),
                    blink=1.0 if i == 6 else 0.0,
                )
            )
        return out
    if state == "listening":
        out = []
        for i in range(4):
            t = i / 4
            out.append(
                default_pose(
                    ear=1.0,
                    ear_twitch=0.35 * math.sin(t * 2 * math.pi),
                    tilt=-7 + 2.0 * math.sin(t * 2 * math.pi),
                    brow=1.0,
                    tail=0.5 * math.sin(t * 2 * math.pi),
                    head_dy=-0.008,
                    tag_glow=True,
                    extras="listen",
                    phase=i,
                )
            )
        return out
    if state == "thinking":
        out = []
        for i in range(6):
            t = i / 6
            out.append(
                default_pose(
                    ear=0.25,
                    tilt=6.0,
                    look=(0.022, -0.026),
                    brow=0.5,
                    breathe=0.4 * math.sin(t * 2 * math.pi),
                    tail=0.2 * math.sin(t * 2 * math.pi),
                    extras="think",
                    phase=i // 2,
                )
            )
        return out
    if state == "talking":
        out = []
        for i in range(4):
            t = i / 4
            open_ = (math.sin(t * 2 * math.pi) + 1) / 2
            out.append(
                default_pose(
                    mouth=0.25 + 0.75 * open_,
                    ear=0.6,
                    head_dy=-0.010 * open_,
                    breathe=open_,
                    tail=math.sin(t * 2 * math.pi + 1.0),
                    brow=0.35,
                )
            )
        return out
    if state == "walk":
        # 8 frames: two full strides, so the loop lands back on the pose it
        # started from and the cycle is seamless however it's entered.
        out = []
        for i in range(8):
            phase = i / 8
            out.append(
                default_pose(
                    pose="walk",
                    phase=phase,
                    tail=math.sin(2 * math.pi * phase),
                    ear_bounce=math.sin(2 * math.pi * phase + 0.9),
                )
            )
        return out
    if state == "error":
        return [
            default_pose(ear=-1.0, cross=True, brow_angle=1.0, mouth=0.35, tail=-0.6,
                         extras="error"),
            default_pose(ear=-0.85, cross=True, brow_angle=1.0, mouth=0.15, tail=-0.4,
                         head_dy=0.008),
        ]
    raise ValueError(state)


STATES = ["idle", "listening", "thinking", "talking", "error", "walk"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument(
        "--out",
        type=Path,
        default=Path(__file__).resolve().parent.parent / "bolt_pet" / "assets" / "sprites",
    )
    ap.add_argument("--states", nargs="*", default=STATES)
    args = ap.parse_args()

    for state in args.states:
        d = args.out / state
        d.mkdir(parents=True, exist_ok=True)
        for old in d.glob("*.png"):
            old.unlink()
        for i, pose in enumerate(frames_for(state)):
            render_frame(pose).save(d / f"frame_{i:02d}.png")
        print(f"{state}: {len(frames_for(state))} frames -> {d}")


if __name__ == "__main__":
    main()
