"""build_ae.py — translate plan+timing+rig_spec into After Effects ExtendScript (.jsx).

Usage:
    py -3.14 scripts/build_ae.py <plan.json> <timing.json> <rig_spec.json> <voice.mp3> <out.jsx>

The generated .jsx is pure ES3 (var, function, string concatenation only).
All data is inlined as ES3 literals — no JSON.parse at runtime.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any


# ---------------------------------------------------------------------------
# Coordinate helpers
# ---------------------------------------------------------------------------
K = 0.9  # scale factor: SVG units → AE pixels


def svg_to_ae(svg_x: float, svg_y: float) -> tuple[float, float]:
    """Convert SVG viewBox coords to AE composition coords.

    Mapping: comp_x = 960 + svg_x*K,  comp_y = 620 + svg_y*K
    This puts the figure centre at (960, 620) with feet near y≈800.
    """
    return (960.0 + svg_x * K, 620.0 + svg_y * K)


def r(v: float) -> str:
    """Round a float to at most 4 decimal places and return as JS number string."""
    rounded = round(v, 4)
    if rounded == int(rounded):
        return str(int(rounded))
    return str(rounded)


def js_color(hex_color: str) -> str:
    """Convert '#RRGGBB' to a JS array string '[R, G, B, 1]' (0–1 range)."""
    h = hex_color.lstrip("#")
    rv = int(h[0:2], 16) / 255.0
    gv = int(h[2:4], 16) / 255.0
    bv = int(h[4:6], 16) / 255.0
    return f"[{r(rv)}, {r(gv)}, {r(bv)}, 1]"


def js_color3(hex_color: str) -> str:
    """Convert '#RRGGBB' to a 3-value JS array '[R, G, B]'.

    TextDocument.fillColor/strokeColor reject 4-value arrays ("颜色数组没有
    3 个值"), unlike shape Color properties which accept RGBA.
    """
    h = hex_color.lstrip("#")
    rv = int(h[0:2], 16) / 255.0
    gv = int(h[2:4], 16) / 255.0
    bv = int(h[4:6], 16) / 255.0
    return f"[{r(rv)}, {r(gv)}, {r(bv)}]"


# ---------------------------------------------------------------------------
# JS code-generation helpers (all produce ES3 strings)
# ---------------------------------------------------------------------------

def js_str(s: str) -> str:
    """Escape a Python string as a JS double-quoted string literal."""
    escaped = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
    return f'"{escaped}"'


def add_kf(lines: list[str], prop_expr: str, time: float, value: str) -> None:
    """Emit a single setValueAtTime call."""
    lines.append(f"    {prop_expr}.setValueAtTime({r(time)}, {value});")


def ease_kf(lines: list[str], prop_expr: str, time: float, value: str) -> None:
    """Emit setValueAtTime and then set easyEase on that keyframe."""
    lines.append(f"    {prop_expr}.setValueAtTime({r(time)}, {value});")
    lines.append(
        f"    {prop_expr}.setTemporalEaseAtKey("
        f"{prop_expr}.nearestKeyIndex({r(time)}), "
        f"[new KeyframeEase(0.5, 33)], [new KeyframeEase(0.5, 33)]);"
    )


# ---------------------------------------------------------------------------
# Main builder
# ---------------------------------------------------------------------------

def build(
    plan: dict[str, Any],
    timing: dict[str, Any],
    rig: dict[str, Any],
    voice_path: str,
    out_path: str,
) -> list[str]:
    """Return lines of the generated JSX file."""

    scenes = plan["scenes"]
    audio_duration = float(timing["audio_duration"])
    comp_duration = audio_duration + 1.0
    joints = rig["joints"]
    palette = rig["palette"]
    pose_presets = rig["pose_presets"]

    ink = palette["ink"]       # #1A1A1A
    paper = palette["paper"]   # #FDFAF5
    red = palette["red"]       # #D4231C

    lines: list[str] = []
    L = lines.append  # shorthand

    # -----------------------------------------------------------------------
    # Header
    # -----------------------------------------------------------------------
    L('// Doodle Build — generated by build_ae.py')
    L('// ES3 compatible: var, function, string concat only')
    L('// Run via: File > Scripts > Run Script File in AE 2024+')
    L('')
    L('app.beginUndoGroup("Doodle Build");')
    L('')
    L('var proj = app.project;')
    L('')

    # -----------------------------------------------------------------------
    # Import audio
    # -----------------------------------------------------------------------
    voice_js = js_str(voice_path.replace("\\", "/"))
    L('// --- Import voice audio ---')
    L(f'var audioFile = new File({voice_js});')
    L('var audioIO = new ImportOptions(audioFile);')
    L('audioIO.importAs = ImportAsType.FOOTAGE;')
    L('var audioItem = proj.importFile(audioIO);')
    L('')

    # -----------------------------------------------------------------------
    # New composition
    # -----------------------------------------------------------------------
    L('// --- Create composition ---')
    L(f'var comp = proj.items.addComp("Doodle", 1920, 1080, 1, {r(comp_duration)}, 30);')
    L('comp.pixelAspect = 1;')
    L('')

    # Add audio layer
    L('// --- Add audio to comp ---')
    L('var audioLayer = comp.layers.add(audioItem);')
    L('audioLayer.startTime = 0;')
    L('')

    # Paper background — comp bg alone is black in renders without it.
    L('// --- Paper background solid ---')
    L(f'comp.bgColor = {js_color3(paper)};')
    L(f'var bgSolid = comp.layers.addSolid({js_color3(paper)}, "PaperBG", 1920, 1080, 1, {r(comp_duration)});')
    L('bgSolid.moveToEnd();')
    L('')

    # -----------------------------------------------------------------------
    # Ground line
    # -----------------------------------------------------------------------
    ground_ae_y = 620.0 + rig.get("ground_y", 200) * K  # ≈800
    L('// --- Ground line ---')
    L('var groundLayer = comp.layers.addShape();')
    L('groundLayer.name = "Ground";')
    L('var groundGrp = groundLayer.property("Contents").addProperty("ADBE Vector Group");')
    L('groundGrp.name = "GroundLine";')
    L('var groundPath = groundGrp.property("Contents").addProperty("ADBE Vector Shape - Group");')
    L('var groundShape = new Shape();')
    L('groundShape.vertices = [[-960, 0], [960, 0]];')
    L('groundShape.inTangents = [[0,0],[0,0]];')
    L('groundShape.outTangents = [[0,0],[0,0]];')
    L('groundShape.closed = false;')
    L('groundPath.property("Path").setValue(groundShape);')
    L('var groundStroke = groundGrp.property("Contents").addProperty("ADBE Vector Graphic - Stroke");')
    L(f'groundStroke.property("Color").setValue({js_color(ink)});')
    L('groundStroke.property("Stroke Width").setValue(4);')
    L(f'groundLayer.property("Transform").property("Position").setValue([960, {r(ground_ae_y)}]);')
    L('')

    # -----------------------------------------------------------------------
    # Rig layers — build each limb as a shape layer
    # -----------------------------------------------------------------------
    # We define a helper function inline in Python that emits a shape layer
    # for a two-point straight line (limb segment).

    def emit_line_layer(
        name: str,
        from_svg: tuple[float, float],
        to_svg: tuple[float, float],
        pivot_svg: tuple[float, float],
        color: str = ink,
        stroke_width: int = 18,
    ) -> None:
        """Emit JS to create a shape layer for a limb segment."""
        # AE positions
        pivot_ae = svg_to_ae(*pivot_svg)
        from_ae = svg_to_ae(*from_svg)
        to_ae = svg_to_ae(*to_svg)
        # Path vertices relative to pivot
        v0 = (from_ae[0] - pivot_ae[0], from_ae[1] - pivot_ae[1])
        v1 = (to_ae[0] - pivot_ae[0], to_ae[1] - pivot_ae[1])
        var_name = name.replace(" ", "_").replace("-", "_")
        L(f'// --- Layer: {name} ---')
        L(f'var {var_name} = comp.layers.addShape();')
        L(f'{var_name}.name = {js_str(name)};')
        L(f'var {var_name}_grp = {var_name}.property("Contents").addProperty("ADBE Vector Group");')
        L(f'{var_name}_grp.name = {js_str(name + "_grp")};')
        L(f'var {var_name}_path = {var_name}_grp.property("Contents").addProperty("ADBE Vector Shape - Group");')
        L(f'var {var_name}_shape = new Shape();')
        L(f'{var_name}_shape.vertices = [[{r(v0[0])},{r(v0[1])}],[{r(v1[0])},{r(v1[1])}]];')
        L(f'{var_name}_shape.inTangents = [[0,0],[0,0]];')
        L(f'{var_name}_shape.outTangents = [[0,0],[0,0]];')
        L(f'{var_name}_shape.closed = false;')
        L(f'{var_name}_path.property("Path").setValue({var_name}_shape);')
        L(f'var {var_name}_stroke = {var_name}_grp.property("Contents").addProperty("ADBE Vector Graphic - Stroke");')
        L(f'{var_name}_stroke.property("Color").setValue({js_color(color)});')
        L(f'{var_name}_stroke.property("Stroke Width").setValue({stroke_width});')
        L(f'{var_name}_stroke.property("Line Cap").setValue(2);')  # 2 = round
        # Path vertices are pivot-relative, so the pivot is the layer-space
        # origin: keep Anchor Point at [0,0] (rotation pivots there) and place
        # the layer via Position only. Setting Anchor to comp coords while the
        # content sits at the origin renders the rig at the comp's top-left.
        L(f'{var_name}.property("Transform").property("Position").setValue([{r(pivot_ae[0])},{r(pivot_ae[1])}]);')
        L('')

    def emit_circle_layer(
        name: str,
        cx_svg: float,
        cy_svg: float,
        radius: float,
        fill_color: str,
        stroke_color: str,
        stroke_width: int = 18,
    ) -> None:
        """Emit JS for a circle shape layer (head)."""
        center_ae = svg_to_ae(cx_svg, cy_svg)
        var_name = name.replace(" ", "_")
        L(f'// --- Layer: {name} ---')
        L(f'var {var_name} = comp.layers.addShape();')
        L(f'{var_name}.name = {js_str(name)};')
        L(f'var {var_name}_grp = {var_name}.property("Contents").addProperty("ADBE Vector Group");')
        L(f'{var_name}_grp.name = {js_str(name + "_grp")};')
        L(f'var {var_name}_ellipse = {var_name}_grp.property("Contents").addProperty("ADBE Vector Shape - Ellipse");')
        L(f'{var_name}_ellipse.property("Size").setValue([{r(radius * 2)},{r(radius * 2)}]);')
        L(f'{var_name}_ellipse.property("Position").setValue([0,0]);')
        L(f'var {var_name}_fill = {var_name}_grp.property("Contents").addProperty("ADBE Vector Graphic - Fill");')
        L(f'{var_name}_fill.property("Color").setValue({js_color(fill_color)});')
        L(f'var {var_name}_stroke = {var_name}_grp.property("Contents").addProperty("ADBE Vector Graphic - Stroke");')
        L(f'{var_name}_stroke.property("Color").setValue({js_color(stroke_color)});')
        L(f'{var_name}_stroke.property("Stroke Width").setValue({stroke_width});')
        # Ellipse sits at the layer-space origin; anchor stays [0,0].
        L(f'{var_name}.property("Transform").property("Position").setValue([{r(center_ae[0])},{r(center_ae[1])}]);')
        L('')

    # Build each rig segment
    L('// ======================================================')
    L('// RIG LAYERS')
    L('// ======================================================')
    L('')

    # body
    body_j = joints["body"]
    emit_line_layer(
        "body",
        tuple(body_j["from"]),
        tuple(body_j["to"]),
        tuple(body_j["from"]),  # pivot = shoulder/neck point
    )

    # armL upper
    armL = joints["armL"]
    emit_line_layer(
        "armL_upper",
        tuple(armL["upper"]["from"]),
        tuple(armL["upper"]["to"]),
        tuple(armL["pivot_shoulder"]),
    )
    # armL fore
    emit_line_layer(
        "armL_fore",
        tuple(armL["fore"]["from"]),
        tuple(armL["fore"]["to"]),
        tuple(armL["pivot_elbow"]),
    )

    # armR upper
    armR = joints["armR"]
    emit_line_layer(
        "armR_upper",
        tuple(armR["upper"]["from"]),
        tuple(armR["upper"]["to"]),
        tuple(armR["pivot_shoulder"]),
    )
    # armR fore
    emit_line_layer(
        "armR_fore",
        tuple(armR["fore"]["from"]),
        tuple(armR["fore"]["to"]),
        tuple(armR["pivot_elbow"]),
    )

    # legL thigh
    legL = joints["legL"]
    emit_line_layer(
        "legL_thigh",
        tuple(legL["thigh"]["from"]),
        tuple(legL["thigh"]["to"]),
        tuple(legL["pivot_hip"]),
    )
    # legL shin
    emit_line_layer(
        "legL_shin",
        tuple(legL["shin"]["from"]),
        tuple(legL["shin"]["to"]),
        tuple(legL["pivot_knee"]),
    )

    # legR thigh
    legR = joints["legR"]
    emit_line_layer(
        "legR_thigh",
        tuple(legR["thigh"]["from"]),
        tuple(legR["thigh"]["to"]),
        tuple(legR["pivot_hip"]),
    )
    # legR shin
    emit_line_layer(
        "legR_shin",
        tuple(legR["shin"]["from"]),
        tuple(legR["shin"]["to"]),
        tuple(legR["pivot_knee"]),
    )

    # head (circle)
    head_j = joints["head"]
    emit_circle_layer(
        "head",
        float(head_j["cx"]),
        float(head_j["cy"]),
        float(head_j["r"]),
        paper,
        ink,
    )

    # -----------------------------------------------------------------------
    # Parent chain
    # -----------------------------------------------------------------------
    # neck pivot for body: body layer is the trunk from [0,-145] to [0,30]
    # pivot is at the neck (from point).
    # We need to reference all layers by their JS variable names.
    # Parent chain:
    #   armL_fore  -> armL_upper
    #   armL_upper -> body
    #   armR_fore  -> armR_upper
    #   armR_upper -> body
    #   legL_thigh -> body
    #   legL_shin  -> legL_thigh
    #   legR_thigh -> body
    #   legR_shin  -> legR_thigh
    #   head       -> body
    # With every anchor at [0,0], a layer's space origin IS its pivot, so a
    # child's position in parent space is simply (child_pivot - parent_pivot)
    # scaled. Set it explicitly AFTER parenting — scripting parent assignment
    # compensation differs across AE versions, so don't rely on it.
    body_pivot = tuple(body_j["from"])

    def emit_parent(child_var: str, parent_var: str,
                    child_pivot_svg: tuple[float, float],
                    parent_pivot_svg: tuple[float, float]) -> None:
        dx = (child_pivot_svg[0] - parent_pivot_svg[0]) * K
        dy = (child_pivot_svg[1] - parent_pivot_svg[1]) * K
        L(f'{child_var}.parent = {parent_var};')
        L(f'{child_var}.property("Transform").property("Position").setValue([{r(dx)},{r(dy)}]);')

    L('// --- Parent chain (FK) ---')
    emit_parent("armL_fore", "armL_upper", tuple(armL["pivot_elbow"]), tuple(armL["pivot_shoulder"]))
    emit_parent("armL_upper", "body", tuple(armL["pivot_shoulder"]), body_pivot)
    emit_parent("armR_fore", "armR_upper", tuple(armR["pivot_elbow"]), tuple(armR["pivot_shoulder"]))
    emit_parent("armR_upper", "body", tuple(armR["pivot_shoulder"]), body_pivot)
    emit_parent("legL_shin", "legL_thigh", tuple(legL["pivot_knee"]), tuple(legL["pivot_hip"]))
    emit_parent("legL_thigh", "body", tuple(legL["pivot_hip"]), body_pivot)
    emit_parent("legR_shin", "legR_thigh", tuple(legR["pivot_knee"]), tuple(legR["pivot_hip"]))
    emit_parent("legR_thigh", "body", tuple(legR["pivot_hip"]), body_pivot)
    emit_parent("head", "body", (float(head_j["cx"]), float(head_j["cy"])), body_pivot)
    L('')

    # -----------------------------------------------------------------------
    # Camera control null
    # -----------------------------------------------------------------------
    L('// --- CamCtrl null for slow push-in ---')
    L('var camCtrl = comp.layers.addNull(comp.duration);')
    L('camCtrl.name = "CamCtrl";')
    L('camCtrl.property("Transform").property("Anchor Point").setValue([0,0]);')
    L('camCtrl.property("Transform").property("Position").setValue([960,540]);')
    # Only the rig ROOT and the ground follow the camera — re-parenting every
    # limb here would overwrite the FK chain built above.
    body_pivot_ae = svg_to_ae(*body_pivot)
    L('body.parent = camCtrl;')
    L(f'body.property("Transform").property("Position").setValue([{r(body_pivot_ae[0] - 960)},{r(body_pivot_ae[1] - 540)}]);')
    L('groundLayer.parent = camCtrl;')
    L(f'groundLayer.property("Transform").property("Position").setValue([0,{r(ground_ae_y - 540)}]);')
    L('')
    # Scale keyframes: 100→106 over full comp
    L('var camScale = camCtrl.property("Transform").property("Scale");')
    L(f'camScale.setValueAtTime(0, [100, 100]);')
    L(f'camScale.setValueAtTime({r(comp_duration)}, [106, 106]);')
    L('')

    # -----------------------------------------------------------------------
    # Rotation helper — get property expression for each limb variable
    # -----------------------------------------------------------------------
    # Map pose_preset field names → JS variable names + property path
    # rotation_map: key → JS variable name for the layer
    rotation_map = {
        "armL_shoulder": "armL_upper",
        "armL_elbow":    "armL_fore",
        "armR_shoulder": "armR_upper",
        "armR_elbow":    "armR_fore",
        "legL_hip":      "legL_thigh",
        "legL_knee":     "legL_shin",
        "legR_hip":      "legR_thigh",
        "legR_knee":     "legR_shin",
        "body":          "body",
        "head":          "head",
    }

    def rot_prop(field: str) -> str:
        vn = rotation_map[field]
        return f'{vn}.property("Transform").property("Rotation")'

    # -----------------------------------------------------------------------
    # Scene pose keyframes
    # -----------------------------------------------------------------------
    L('// ======================================================')
    L('// SCENE POSE KEYFRAMES')
    L('// ======================================================')
    L('')

    for scene in scenes:
        scene_id = scene["id"]
        scene_start = float(scene["start"])
        scene_end = float(scene["end"])
        pose_name = scene["pose"]
        intent = scene["intent"]

        L(f'// --- Scene {scene_id}: pose={pose_name}, intent={intent}, t={r(scene_start)} ---')

        if intent == "walk":
            # Walk: yoyo ±22° hip, ∓25° shoulder over 0.4s half-period
            half = 0.4
            t = scene_start
            step = 0
            while t < scene_end:
                sign = 1 if (step % 2 == 0) else -1
                l_hip = 22 * sign
                r_hip = -22 * sign
                l_shoulder = -25 * sign
                r_shoulder = 25 * sign
                # legL_hip
                add_kf(lines, rot_prop("legL_hip"), t, str(l_hip))
                add_kf(lines, rot_prop("legR_hip"), t, str(r_hip))
                add_kf(lines, rot_prop("armL_shoulder"), t, str(l_shoulder))
                add_kf(lines, rot_prop("armR_shoulder"), t, str(r_shoulder))
                # knee follows hip with half amplitude
                add_kf(lines, rot_prop("legL_knee"), t, str(int(15 * sign)))
                add_kf(lines, rot_prop("legR_knee"), t, str(int(-10 * sign)))
                t = round(t + half, 6)
                step += 1
            # body/head slight tilt
            add_kf(lines, rot_prop("body"), scene_start, "2")
            add_kf(lines, rot_prop("head"), scene_start, "2")
        else:
            # Non-walk: standard anticipation + overshoot + settle
            if pose_name not in pose_presets:
                pose_name = "idle"
            pose = pose_presets[pose_name]

            for field, target_val in pose.items():
                if field.startswith("_"):
                    continue
                target = float(target_val)
                prop = rot_prop(field)
                t_anticipate = round(scene_start - 0.2, 6)
                t_overshoot  = round(scene_start + 0.35, 6)
                t_settle     = round(scene_start + 0.6, 6)
                # anticipate: opposite 8°
                anticipate_val = round(-8.0 if target >= 0 else 8.0, 4)
                # overshoot: target + 10%
                overshoot_val = round(target * 1.1, 4)
                # Clamp anticipate time to >=0
                if t_anticipate < 0:
                    t_anticipate = 0.0
                add_kf(lines, prop, t_anticipate, r(anticipate_val))
                add_kf(lines, prop, scene_start, r(target))
                add_kf(lines, prop, t_overshoot, r(overshoot_val))
                add_kf(lines, prop, t_settle, r(target))

        L('')

    # -----------------------------------------------------------------------
    # Keywords
    # -----------------------------------------------------------------------
    L('// ======================================================')
    L('// KEYWORD TEXT LAYERS')
    L('// ======================================================')
    L('')

    kw_index = 0
    for scene in scenes:
        for kw in scene.get("keywords", []):
            kw_text = kw["text"]
            kw_t = float(kw["t"])
            var_name = f"kw_{kw_index}"
            kw_index += 1

            L(f'// --- Keyword: {kw_text} @ t={r(kw_t)} ---')
            L(f'var {var_name} = comp.layers.addText({js_str(kw_text)});')
            L(f'{var_name}.name = {js_str("kw_" + kw_text)};')

            # Text style
            L(f'var {var_name}_doc = {var_name}.property("Source Text").value;')
            L(f'{var_name}_doc.fontSize = 80;')
            L(f'{var_name}_doc.fillColor = {js_color3(red)};')
            L(f'{var_name}_doc.font = "Arial";')
            L(f'{var_name}_doc.justification = ParagraphJustification.CENTER_JUSTIFY;')
            L(f'{var_name}.property("Source Text").setValue({var_name}_doc);')

            # Position: upper-right of the figure so the pop never covers
            # the head (figure stands at x≈960).
            ae_x, ae_y = 1320, 360
            L(f'{var_name}.property("Transform").property("Position").setValue([{ae_x},{ae_y}]);')

            # Scale keyframes: [0,0] @ t-0.05 → [120,120] @ t+0.12 → [100,100] @ t+0.25
            t_s0 = round(kw_t - 0.05, 6)
            t_s1 = round(kw_t + 0.12, 6)
            t_s2 = round(kw_t + 0.25, 6)
            scale_prop = f'{var_name}.property("Transform").property("Scale")'
            add_kf(lines, scale_prop, t_s0, "[0,0]")
            add_kf(lines, scale_prop, t_s1, "[120,120]")
            add_kf(lines, scale_prop, t_s2, "[100,100]")

            # Opacity: fade out over 0.3s starting at t+1.2
            t_op0 = round(kw_t + 1.2, 6)
            t_op1 = round(kw_t + 1.5, 6)
            op_prop = f'{var_name}.property("Transform").property("Opacity")'
            add_kf(lines, op_prop, t_op0, "100")
            add_kf(lines, op_prop, t_op1, "0")
            L('')

    # -----------------------------------------------------------------------
    # Subtitles
    # -----------------------------------------------------------------------
    L('// ======================================================')
    L('// SUBTITLE LAYER (hold keyframes)')
    L('// ======================================================')
    L('')
    L('var subtitleLayer = comp.layers.addText("");')
    L('subtitleLayer.name = "Subtitle";')
    L('var subSrcProp = subtitleLayer.property("Source Text");')
    # Position bottom center
    L('subtitleLayer.property("Transform").property("Position").setValue([960, 980]);')
    L('')

    # A standalone `new TextDocument()` is not associated with any layer, so
    # AE rejects style assignments on it ("未与图层关联"). Style the layer's
    # own Source Text value once, then mutate .text per scene — each
    # setValueAtTime snapshots the value (sourceText keyframes are hold).
    L('subSrcProp.setValue(subSrcProp.value);')
    L('var subDoc = subSrcProp.value;')
    L('subDoc.fontSize = 48;')
    L(f'subDoc.fillColor = {js_color3(ink)};')
    L('subDoc.font = "Arial";')
    L('subDoc.justification = ParagraphJustification.CENTER_JUSTIFY;')
    L('')
    for scene in scenes:
        subtitle_text = scene.get("subtitle", "")
        scene_start = float(scene["start"])
        L(f'subDoc.text = {js_str(subtitle_text)};')
        L(f'subSrcProp.setValueAtTime({r(scene_start)}, subDoc);')
        L('')

    # -----------------------------------------------------------------------
    # Footer
    # -----------------------------------------------------------------------
    # No alert() here: it would block unattended runs (aerender / AfterFX -r).
    L('app.endUndoGroup();')
    L('$.writeln("Doodle Build complete");')
    L('')

    return lines


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def main() -> None:
    """Parse CLI args, load JSON inputs, build JSX, and write output."""
    if len(sys.argv) != 6:
        print(
            "Usage: py -3.14 scripts/build_ae.py "
            "<plan.json> <timing.json> <rig_spec.json> <voice.mp3> <out.jsx>",
            file=sys.stderr,
        )
        sys.exit(1)

    plan_path, timing_path, rig_path, voice_path, out_path = sys.argv[1:]

    with open(plan_path, encoding="utf-8") as f:
        plan: dict[str, Any] = json.load(f)
    with open(timing_path, encoding="utf-8") as f:
        timing: dict[str, Any] = json.load(f)
    with open(rig_path, encoding="utf-8") as f:
        rig: dict[str, Any] = json.load(f)

    jsx_lines = build(plan, timing, rig, voice_path, out_path)
    jsx_content = "\n".join(jsx_lines) + "\n"

    Path(out_path).write_text(jsx_content, encoding="utf-8")
    print(f"[build_ae] Written {len(jsx_lines)} lines → {out_path}")


if __name__ == "__main__":
    main()
