# -*- coding: utf-8 -*-
"""Build a fully self-contained doodle animation HTML.

Usage:
    py -3.14 scripts/build_html.py <plan.json> <timing.json> <rig_spec.json> <voice.mp3> <out.html>
"""
import base64
import json
import sys
from pathlib import Path

TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>doodle</title>
<style>
  html, body { margin: 0; height: 100%; background: #2A2722; overflow: hidden; }
  #stage {
    position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
    width: min(100vw, 177.7778vh); height: min(56.25vw, 100vh);
    background: #FDFAF5; overflow: hidden;
    font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
  }
  #world { position: absolute; inset: 0; }
  #bg { position: absolute; inset: 0; width: 100%; height: 100%; }
  #char { position: absolute; height: 50%; top: 36.33%; left: 50%; transform: translateX(-50%); }
  #subtitle {
    position: absolute; left: 50%; bottom: 4.5%; transform: translateX(-50%);
    max-width: 88%; padding: 0.5em 1.3em;
    background: rgba(253, 250, 245, 0.88);
    border: 3px solid rgba(26, 26, 26, 0.8); border-radius: 16px;
    box-shadow: 0.18em 0.18em 0 rgba(26, 26, 26, 0.12);
    color: #1A1A1A; font-size: min(3.1vw, 5.5vh); font-weight: 600;
    white-space: nowrap; z-index: 5;
  }
  #subtitle:empty { visibility: hidden; }
  #keyword {
    position: absolute; left: 58%; top: 18%;
    color: #D4231C; font-size: min(7.6vw, 13.5vh); font-weight: 900;
    z-index: 6; visibility: hidden; transform-origin: 50% 50%;
    text-shadow: 0.045em 0.045em 0 rgba(26, 26, 26, 0.18);
    white-space: nowrap;
  }
  #playbtn {
    position: absolute; inset: 0; z-index: 10; cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    background: rgba(253, 250, 245, 0.55);
    color: #1A1A1A; font-size: min(4vw, 7vh); font-weight: 700;
  }
  #playbtn span {
    border: 4px solid #1A1A1A; border-radius: 20px; padding: 0.35em 1.1em;
    background: #FDFAF5; box-shadow: 0.15em 0.15em 0 rgba(26, 26, 26, 0.25);
  }
  .limb { stroke: #1A1A1A; stroke-width: 18; stroke-linecap: round; fill: none; }
  .feat { stroke: #1A1A1A; stroke-linecap: round; fill: none; }
</style>
</head>
<body>
<div id="stage">
  <div id="world">
    <svg id="bg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
      <!-- ground line, y=842 (78% of 1080) -->
      <path d="M 70 842 Q 480 834 960 841 T 1850 842" stroke="#1A1A1A" stroke-width="7" fill="none" stroke-linecap="round"/>
      <!-- prop: hidden winding path (two curved dashed lines) -->
      <g id="prop_path" style="display:none">
        <path d="M 1130 846 C 1230 762 1290 700 1362 646" stroke="#1A1A1A" stroke-width="9" fill="none" stroke-linecap="round" stroke-dasharray="26 20"/>
        <path d="M 1310 846 C 1396 766 1452 712 1522 660" stroke="#1A1A1A" stroke-width="9" fill="none" stroke-linecap="round" stroke-dasharray="26 20"/>
      </g>
      <!-- prop: little school house + flagpole -->
      <g id="prop_school" style="display:none">
        <rect x="1430" y="715" width="210" height="127" fill="#FDFAF5" stroke="#1A1A1A" stroke-width="9"/>
        <path d="M 1408 715 L 1535 628 L 1662 715 Z" fill="#FDFAF5" stroke="#1A1A1A" stroke-width="9" stroke-linejoin="round"/>
        <rect x="1505" y="772" width="60" height="70" fill="none" stroke="#1A1A1A" stroke-width="8"/>
        <line x1="1692" y1="842" x2="1692" y2="598" stroke="#1A1A1A" stroke-width="8" stroke-linecap="round"/>
        <path d="M 1692 598 L 1764 620 L 1692 642 Z" fill="#D4231C"/>
      </g>
    </svg>
    <svg id="char" viewBox="-200 -300 400 600">
      <g id="g_root">
        <g id="g_body">
          <!-- torso -->
          <line class="limb" x1="0" y1="-145" x2="0" y2="30"/>
          <!-- head (pose rotation) > headSway (micro sway) > circle + faces -->
          <g id="g_head">
            <g id="g_headSway">
              <circle cx="0" cy="-200" r="55" fill="#FDFAF5" stroke="#1A1A1A" stroke-width="18"/>
              <g id="face_calm">
                <circle cx="-18" cy="-212" r="5.5" fill="#1A1A1A"/>
                <circle cx="18" cy="-212" r="5.5" fill="#1A1A1A"/>
                <path class="feat" d="M -20 -182 Q 0 -166 20 -182" stroke-width="7"/>
              </g>
              <g id="face_sad" style="display:none">
                <path class="feat" d="M -28 -224 L -10 -230" stroke-width="6"/>
                <path class="feat" d="M 28 -224 L 10 -230" stroke-width="6"/>
                <circle cx="-18" cy="-212" r="5.5" fill="#1A1A1A"/>
                <circle cx="18" cy="-212" r="5.5" fill="#1A1A1A"/>
                <path class="feat" d="M -16 -170 Q 0 -186 16 -170" stroke-width="7"/>
              </g>
              <g id="face_angry" style="display:none">
                <path class="feat" d="M -29 -236 L -9 -224" stroke-width="6"/>
                <path class="feat" d="M 29 -236 L 9 -224" stroke-width="6"/>
                <circle cx="-18" cy="-211" r="5.5" fill="#1A1A1A"/>
                <circle cx="18" cy="-211" r="5.5" fill="#1A1A1A"/>
                <path class="feat" d="M -16 -170 Q 0 -186 16 -170" stroke-width="7"/>
              </g>
              <g id="face_surprised" style="display:none">
                <path class="feat" d="M -28 -233 Q -18 -240 -8 -234" stroke-width="6"/>
                <path class="feat" d="M 28 -233 Q 18 -240 8 -234" stroke-width="6"/>
                <circle class="feat" cx="-18" cy="-212" r="8.5" stroke-width="6"/>
                <circle class="feat" cx="18" cy="-212" r="8.5" stroke-width="6"/>
                <ellipse class="feat" cx="0" cy="-175" rx="9" ry="13" stroke-width="6"/>
              </g>
            </g>
          </g>
          <!-- left arm: shoulder pivot (0,-100), elbow pivot (-40,-60) -->
          <g id="g_armL">
            <line class="limb" x1="0" y1="-100" x2="-40" y2="-60"/>
            <g id="g_foreL">
              <line class="limb" x1="-40" y1="-60" x2="-72" y2="-15"/>
            </g>
          </g>
          <!-- right arm -->
          <g id="g_armR">
            <line class="limb" x1="0" y1="-100" x2="40" y2="-60"/>
            <g id="g_foreR">
              <line class="limb" x1="40" y1="-60" x2="72" y2="-15"/>
            </g>
          </g>
          <!-- left leg: hip pivot (0,30), knee pivot (-22,115) -->
          <g id="g_legL">
            <line class="limb" x1="0" y1="30" x2="-22" y2="115"/>
            <g id="g_shinL">
              <line class="limb" x1="-22" y1="115" x2="-45" y2="200"/>
            </g>
          </g>
          <!-- right leg -->
          <g id="g_legR">
            <line class="limb" x1="0" y1="30" x2="22" y2="115"/>
            <g id="g_shinR">
              <line class="limb" x1="22" y1="115" x2="45" y2="200"/>
            </g>
          </g>
        </g>
      </g>
    </svg>
  </div>
  <div id="keyword"></div>
  <div id="subtitle"></div>
  <div id="playbtn"><span>&#9654; 播放</span></div>
</div>
<audio id="narration" preload="auto" src="data:audio/mpeg;base64,__AUDIO_B64__"></audio>
<script>__GSAP__</script>
<script>
"use strict";
const PLAN = __PLAN__;
const TIMING = __TIMING__;
const RIG = __RIG__;
const DURATION = TIMING.audio_duration + 1;
const SCENES = PLAN.scenes;

/* joint name (matches pose_presets keys) -> element + svgOrigin pivot (viewBox coords) */
const JOINTS = {
  body:          { sel: "#g_body",  origin: "0 30"     },
  head:          { sel: "#g_head",  origin: "0 -145"   },
  armL_shoulder: { sel: "#g_armL",  origin: "0 -100"   },
  armL_elbow:    { sel: "#g_foreL", origin: "-40 -60"  },
  armR_shoulder: { sel: "#g_armR",  origin: "0 -100"   },
  armR_elbow:    { sel: "#g_foreR", origin: "40 -60"   },
  legL_hip:      { sel: "#g_legL",  origin: "0 30"     },
  legL_knee:     { sel: "#g_shinL", origin: "-22 115"  },
  legR_hip:      { sel: "#g_legR",  origin: "0 30"     },
  legR_knee:     { sel: "#g_shinR", origin: "22 115"   }
};

const FACES = ["calm", "sad", "angry", "surprised"];
function setFace(name) {
  FACES.forEach(f => {
    document.getElementById("face_" + f).style.display = (f === name) ? "" : "none";
  });
}
function setProp(p) {
  document.getElementById("prop_path").style.display = (p === "path") ? "" : "none";
  document.getElementById("prop_school").style.display = (p === "school") ? "" : "none";
}
function setSubtitle(text) {
  document.getElementById("subtitle").textContent = text || "";
}
function setKeywordText(text) {
  document.getElementById("keyword").textContent = text;
}

let TL = null;

function buildTimeline() {
  if (TL) return TL;
  TL = gsap.timeline({ paused: true });

  /* init pivots + neutral pose */
  for (const k in JOINTS) {
    gsap.set(JOINTS[k].sel, { svgOrigin: JOINTS[k].origin, rotation: 0 });
  }
  gsap.set("#g_headSway", { svgOrigin: "0 -145", rotation: 0 });
  gsap.set("#world", { transformOrigin: "50% 58%", scale: 1 });
  gsap.set("#keyword", { scale: 0, autoAlpha: 0 });
  setFace("calm");
  setProp(null);
  setSubtitle("");

  /* breathing: body scaleY 1 <-> 1.012, 2s yoyo, laid across the whole TL */
  for (let t = 0; t < DURATION; t += 2) {
    TL.to("#g_body", { scaleY: 1.012, duration: 1, ease: "sine.inOut" }, t);
    TL.to("#g_body", { scaleY: 1,     duration: 1, ease: "sine.inOut" }, t + 1);
  }
  /* head micro sway +-2 deg (separate inner group so it never fights pose head rotation) */
  for (let t = 0; t < DURATION; t += 1.7) {
    TL.to("#g_headSway", { rotation: 2,  duration: 0.85, ease: "sine.inOut" }, t);
    TL.to("#g_headSway", { rotation: -2, duration: 0.85, ease: "sine.inOut" }, t + 0.85);
  }

  SCENES.forEach((sc, idx) => {
    const pose = RIG.pose_presets[sc.pose] || RIG.pose_presets.idle;
    const t0 = Math.max(0, sc.start - 0.2);

    /* anticipation (8 deg counter-move, 0.2s) then settle into pose (0.35s back.out) */
    for (const j in JOINTS) {
      const target = (pose[j] !== undefined) ? pose[j] : 0;
      const antic = target - 8 * (target >= 0 ? 1 : -1);
      TL.to(JOINTS[j].sel, { rotation: antic,  duration: 0.2,  ease: "power1.out" }, t0);
      TL.to(JOINTS[j].sel, { rotation: target, duration: 0.35, ease: "back.out(1.7)" }, t0 + 0.2);
    }

    /* face + prop flips just before scene start, subtitle right at start */
    TL.call(setFace, [sc.face], Math.max(0.001, sc.start - 0.05));
    TL.call(setProp, [sc.prop], Math.max(0.001, sc.start - 0.05));
    TL.call(setSubtitle, [sc.subtitle], Math.max(0.001, sc.start));

    /* walk cycle: hips +-22 deg alternating every 0.4s, knees follow, arms counter-phase */
    if (sc.pose === "walk") {
      const A = { legL_hip: 22,  legL_knee: 15, legR_hip: -22, legR_knee: 10,
                  armL_shoulder: -25, armL_elbow: -15, armR_shoulder: 25, armR_elbow: 15 };
      const B = { legL_hip: -22, legL_knee: 10, legR_hip: 22,  legR_knee: 15,
                  armL_shoulder: 25, armL_elbow: 15, armR_shoulder: -25, armR_elbow: -15 };
      let toB = true;            /* pose preset == phase A, so first swing goes to B */
      let t = t0 + 0.55;         /* after the settle tween ends */
      while (t + 0.4 <= sc.end) {
        const P = toB ? B : A;
        for (const j in P) {
          TL.to(JOINTS[j].sel, { rotation: P[j], duration: 0.4, ease: "sine.inOut" }, t);
        }
        toB = !toB;
        t += 0.4;
      }
    }

    /* last scene (cheer): little hops, whole figure bounces */
    if (idx === SCENES.length - 1 && sc.pose === "cheer") {
      let t = sc.start + 0.55;
      while (t + 0.5 <= sc.end) {
        TL.to("#g_root", { y: -26, duration: 0.25, ease: "power1.out" }, t);
        TL.to("#g_root", { y: 0,   duration: 0.25, ease: "power1.in"  }, t + 0.25);
        t += 0.5;
      }
    }

    /* keyword pop: scale 0 -> overshoot -> 1 (back.out), fade after t+1.2 */
    (sc.keywords || []).forEach(kw => {
      TL.call(setKeywordText, [kw.text], Math.max(0.001, kw.t - 0.06));
      TL.fromTo("#keyword",
        { scale: 0, autoAlpha: 0 },
        { scale: 1, autoAlpha: 1, duration: 0.35, ease: "back.out(2.5)" },
        Math.max(0.001, kw.t - 0.05));
      TL.to("#keyword", { autoAlpha: 0, duration: 0.3 }, kw.t + 1.2);
    });

    /* slow camera push: +1.5% per scene, last scene eases back to 1 */
    const zoomTarget = (idx === SCENES.length - 1) ? 1.0 : 1 + 0.015 * (idx + 1);
    TL.to("#world", { scale: zoomTarget, duration: sc.end - sc.start, ease: "none" }, sc.start);
  });

  /* pad the TL out to DURATION so seeks clamp correctly */
  TL.set("#stage", { autoAlpha: 1 }, DURATION);
  return TL;
}

/* ---------- runtime playback with audio-drift correction ---------- */
const audio = document.getElementById("narration");
const playbtn = document.getElementById("playbtn");
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

playbtn.addEventListener("click", () => {
  playbtn.style.display = "none";
  buildTimeline();
  audio.play();
});
audio.addEventListener("playing", () => { if (TL) TL.play(); });
audio.addEventListener("ended", () => { if (TL) { TL.timeScale(1); } });

function tick() {
  if (TL && !audio.paused && !audio.ended) {
    const drift = audio.currentTime - TL.time();
    if (Math.abs(drift) > 1.2) {
      TL.time(audio.currentTime);
      TL.timeScale(1);
    } else {
      TL.timeScale(clamp(1 + drift * 0.6, 0.6, 1.6));
    }
    /* subtitle follows the audio clock directly */
    const sc = SCENES.find(s => audio.currentTime >= s.start && audio.currentTime <= s.end);
    if (sc) setSubtitle(sc.subtitle);
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

/* ---------- headless-render hooks ---------- */
window.__buildOnly = () => {
  playbtn.style.display = "none";   /* no overlay in headless render */
  buildTimeline();
  TL.pause();
  return DURATION;
};
window.__seek = (t) => { TL.time(t, false); };
</script>
</body>
</html>
"""


def main() -> None:
    if len(sys.argv) != 6:
        print(__doc__)
        sys.exit(1)
    plan_p, timing_p, rig_p, voice_p, out_p = (Path(a) for a in sys.argv[1:6])

    plan = json.loads(plan_p.read_text(encoding="utf-8"))
    timing = json.loads(timing_p.read_text(encoding="utf-8"))
    rig = json.loads(rig_p.read_text(encoding="utf-8"))
    gsap_root = Path(__file__).resolve().parent.parent / "assets" / "gsap.min.js"
    gsap_txt = gsap_root.read_text(encoding="utf-8")
    if "</script>" in gsap_txt:  # safety for inline embedding
        gsap_txt = gsap_txt.replace("</script>", "<\\/script>")
    audio_b64 = base64.b64encode(voice_p.read_bytes()).decode("ascii")

    def js(obj) -> str:
        return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")

    html = (TEMPLATE
            .replace("__PLAN__", js(plan))
            .replace("__TIMING__", js(timing))
            .replace("__RIG__", js(rig))
            .replace("__AUDIO_B64__", audio_b64)
            .replace("__GSAP__", gsap_txt))

    out_p.parent.mkdir(parents=True, exist_ok=True)
    out_p.write_text(html, encoding="utf-8")
    print(f"OK wrote {out_p} ({out_p.stat().st_size} bytes), "
          f"DURATION={timing['audio_duration'] + 1:.3f}s, scenes={len(plan['scenes'])}")


if __name__ == "__main__":
    main()
