/* Weather Squatch — SquatchStage
   Owns the transparent WebGL canvas that composites IN FRONT of the 2D parallax.
   Loads the rigged GLB, runs the AnimationMixer, smooths the mouthOpen morph,
   and layers subtle procedural breathing on top of whatever clip is playing.
   Plain class (no React) — driven imperatively by the app state machine. */

class SquatchStage {
  constructor(canvas) {
    const THREE = window.THREE;
    this.THREE = THREE;
    this.canvas = canvas;

    this.renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: false });
    this.renderer.setClearAlpha(0);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.outputColorSpace = THREE.SRGBColorSpace;
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.15;

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);

    // Warm morning-meadow lighting.
    const hemi = new THREE.HemisphereLight(0xfff3df, 0x6f8f59, 1.05);
    this.scene.add(hemi);
    const key = new THREE.DirectionalLight(0xfff0d6, 1.55);
    key.position.set(-3, 5.5, 4);
    this.scene.add(key);
    const fill = new THREE.DirectionalLight(0xcfe0ff, 0.35);
    fill.position.set(4, 2, 2);
    this.scene.add(fill);
    const rim = new THREE.DirectionalLight(0xffe9c2, 0.5);
    rim.position.set(1, 3, -4);
    this.scene.add(rim);

    this.rig = new THREE.Group();       // breathing/sway parent
    this.scene.add(this.rig);

    this.clock = new THREE.Clock();
    this.mixer = null;
    this.actions = {};
    this.current = null;                // current base (looping) action
    this.morphMesh = null;
    this.morphIndex = -1;
    this.mouth = 0;
    this.mouthTarget = 0;
    this.breathing = false;
    this.running = false;
    this._onFinish = null;
    this._raf = null;
  }

  async load(url, fallbackUrl) {
    const THREE = this.THREE;
    const loader = new window.GLTFLoader();
    if (window.MeshoptDecoder) loader.setMeshoptDecoder(window.MeshoptDecoder);
    const tryLoad = (u) => new Promise((res, rej) => loader.load(u, res, undefined, rej));
    let gltf;
    try { gltf = await tryLoad(url); }
    catch (e) { if (fallbackUrl && fallbackUrl !== url) gltf = await tryLoad(fallbackUrl); else throw e; }

    this.gltf = gltf;
    this.model = gltf.scene;
    this.rig.add(this.model);

    // Find the skinned mesh carrying the mouthOpen morph (char1).
    this.model.traverse((o) => {
      if (o.isMesh) {
        o.frustumCulled = false;
        if (o.morphTargetDictionary && "mouthOpen" in o.morphTargetDictionary) {
          this.morphMesh = o;
          this.morphIndex = o.morphTargetDictionary["mouthOpen"];
        }
      }
    });

    // Mixer + actions for all 8 clips.
    this.mixer = new THREE.AnimationMixer(this.model);
    this._makeInPlace(gltf);   // lock root horizontal drift so he stays centered
    Object.keys(window.ROLE_INDEX).forEach((role) => {
      const clip = window.getClip(gltf, role);
      if (clip) this.actions[role] = this.mixer.clipAction(clip);
    });
    this.mixer.addEventListener("finished", () => { if (this._onFinish) { const f = this._onFinish; this._onFinish = null; f(); } });

    // Prime the skeleton so bone matrices are valid before we measure/frame.
    const prime = this.actions.idle || Object.values(this.actions)[0];
    if (prime) { prime.reset().play(); this.mixer.update(0); prime.stop(); prime.setEffectiveWeight(0); }
    this.model.updateMatrixWorld(true);

    this._frameCamera();
    return this;
  }

  // These clips are authored with large root (Hips) translation — the character
  // walks through space. We composite him stationary, so flatten the horizontal
  // (X/Z) component of every Hips.position track to its bind value. Vertical (Y)
  // is preserved so wake/arise rising and the spin-jump still read.
  _makeInPlace(gltf) {
    const hips = this.model.getObjectByName("Hips");
    const bx = hips ? hips.position.x : 0;
    const bz = hips ? hips.position.z : 0;
    (gltf.animations || []).forEach((clip) => {
      clip.tracks.forEach((track) => {
        if (track.name.endsWith("Hips.position") && track.values && track.values.length % 3 === 0) {
          const v = track.values;
          for (let i = 0; i < v.length; i += 3) { v[i] = bx; v[i + 2] = bz; }
        }
      });
    });
  }

  _frameCamera() {
    const THREE = this.THREE;
    this.model.updateMatrixWorld(true);

    // Measure from the skinned mesh's GEOMETRY bounding box (bind pose) — this is
    // stable and independent of the skeleton, which may not have valid matrices
    // the instant we frame (setFromObject on a SkinnedMesh can return an empty
    // box and produce a NaN camera). Fall back to setFromObject, then to defaults.
    let box = new THREE.Box3();
    const mesh = this.morphMesh;
    if (mesh) {
      // SkinnedMesh.computeBoundingBox accounts for the currently-posed skeleton.
      if (mesh.isSkinnedMesh && typeof mesh.computeBoundingBox === "function") {
        try { mesh.computeBoundingBox(); } catch (e) {}
        if (mesh.boundingBox && !mesh.boundingBox.isEmpty()) box.copy(mesh.boundingBox).applyMatrix4(mesh.matrixWorld);
      }
      if (box.isEmpty() && mesh.geometry) {
        if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
        if (mesh.geometry.boundingBox) box.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld);
      }
    }
    if (box.isEmpty() || !isFinite(box.min.x) || !isFinite(box.max.y)) {
      box = new THREE.Box3().setFromObject(this.model);
    }

    let size = box.getSize(new THREE.Vector3());
    let center = box.getCenter(new THREE.Vector3());
    let minY = box.min.y;
    if (!isFinite(size.y) || size.y <= 0 || !isFinite(center.x)) {
      // Last-resort defaults (~2-unit-tall figure centered at origin).
      size = new THREE.Vector3(1.4, 2.0, 1.0);
      center = new THREE.Vector3(0, 1.0, 0);
      minY = 0;
    }

    // Re-seat model so its feet sit at y=0 and it's centered on x/z.
    this.model.position.x -= center.x;
    this.model.position.z -= center.z;
    this.model.position.y -= minY;
    this.model.updateMatrixWorld(true);

    this.modelHeight = size.y;
    this.baseRigY = 0;

    const fov = THREE.MathUtils.degToRad(this.camera.fov);
    // Frame the standing figure so it fills ~62% of the vertical frame, full body.
    const fitH = size.y / 0.62;
    const fitW = (size.x * 1.15) / (this.camera.aspect || 1.6);
    const fit = Math.max(fitH, fitW);
    let dist = (fit / 2) / Math.tan(fov / 2);
    let camY = size.y * 0.46;
    if (!isFinite(dist) || dist <= 0) dist = 4.5;
    if (!isFinite(camY)) camY = 1.0;
    this.camTargetY = camY;
    this.camDist = dist;
    this.camera.position.set(0, camY, dist);
    this.camera.lookAt(0, camY, 0);
    this.camera.updateProjectionMatrix();
  }

  // ── Posing / clips ─────────────────────────────────────────────────────────
  // Down-on-the-ground pose = first frame of the get-up clip (he's crouched low).
  poseAsleep() {
    const a = this.actions.getUp;
    if (!a) return;
    this._stopAll();
    a.reset(); a.play(); a.paused = true; a.time = 0; a.setEffectiveWeight(1);
    this.current = a;
    this.mixer.update(0);
  }

  _stopAll() {
    Object.values(this.actions).forEach((a) => { a.stop(); a.setEffectiveWeight(0); });
  }

  // Crossfade to a looping ambient clip. pingPong avoids a hard snap at the
  // loop boundary for gesture clips whose start/end poses differ.
  loop(key, duration = 0.45, pingPong = false) {
    const a = this.actions[key];
    if (!a || a === this.current) return;
    const THREE = this.THREE;
    a.reset();
    a.setLoop(pingPong ? THREE.LoopPingPong : THREE.LoopRepeat, Infinity);
    a.clampWhenFinished = false;
    a.enabled = true;
    a.setEffectiveTimeScale(1);
    a.setEffectiveWeight(1);
    a.fadeIn(duration).play();
    if (this.current && this.current !== a) this.current.fadeOut(duration);
    this.current = a;
    this._onFinish = null;
  }

  // Rise from the held ground pose. poseAsleep() already parks the getUp action
  // at frame 0 (weight 1, paused) — so we resume it at FULL weight rather than
  // fading in, which avoids a blend-toward-bind twitch that reads as a "spring".
  // timeScale < 1 stretches the 1.8s clip so the rise is unhurried.
  riseFromGround({ timeScale = 0.6, onDone = null } = {}) {
    const THREE = this.THREE;
    const a = this.actions.getUp;
    if (!a) { if (onDone) onDone(); return; }
    a.reset();
    a.setLoop(THREE.LoopOnce, 1);
    a.clampWhenFinished = true;
    a.enabled = true;
    a.setEffectiveTimeScale(timeScale);
    a.setEffectiveWeight(1);   // full weight from the first frame — no fade-in
    a.paused = false;
    a.play();
    this.current = a;
    this._onFinish = onDone;
  }

  // Play a one-shot clip, optionally chaining a callback when it finishes.
  once(key, { fadeIn = 0.3, timeScale = 1, onDone = null } = {}) {
    const a = this.actions[key];
    const THREE = this.THREE;
    if (!a) { if (onDone) onDone(); return; }
    a.reset();
    a.setLoop(THREE.LoopOnce, 1);
    a.clampWhenFinished = true;
    a.enabled = true;
    a.setEffectiveTimeScale(timeScale);
    a.setEffectiveWeight(1);
    a.fadeIn(fadeIn).play();
    if (this.current && this.current !== a) this.current.fadeOut(fadeIn);
    this.current = a;
    this._onFinish = onDone;
  }

  // Play a sequence of one-shot clips, then call onComplete.
  sequence(keys, onComplete) {
    const run = (i) => {
      if (i >= keys.length) { if (onComplete) onComplete(); return; }
      this.once(keys[i], { onDone: () => run(i + 1) });
    };
    run(0);
  }

  // ── Mouth + breathing ────────────────────────────────────────────────────
  setMouthTarget(v) { this.mouthTarget = Math.max(0, Math.min(1, v)); }
  setBreathing(on) { this.breathing = on; }

  // ── Loop ───────────────────────────────────────────────────────────────────
  start() {
    if (this.running) return;
    this.running = true;
    this.clock.start();
    const tick = () => {
      if (!this.running) return;
      const dt = Math.min(this.clock.getDelta(), 0.05);
      const t = this.clock.elapsedTime;
      if (this.mixer) this.mixer.update(dt);

      // Smooth the mouth morph so it doesn't jitter.
      this.mouth += (this.mouthTarget - this.mouth) * Math.min(1, dt * 18);
      if (this.morphMesh && this.morphIndex >= 0) {
        this.morphMesh.morphTargetInfluences[this.morphIndex] = this.mouth;
      }

      // Subtle procedural breathing / sway layered over the clip.
      if (this.breathing) {
        const amt = this.breathAmt != null ? this.breathAmt : 1;
        this.rig.position.y = this.baseRigY + Math.sin(t * 1.6) * 0.012 * amt;
        this.rig.rotation.z = Math.sin(t * 0.8) * 0.006 * amt;
        this.rig.rotation.y = Math.sin(t * 0.5) * 0.02 * amt;
      } else {
        this.rig.position.y += (this.baseRigY - this.rig.position.y) * 0.1;
        this.rig.rotation.z += (0 - this.rig.rotation.z) * 0.1;
        this.rig.rotation.y += (0 - this.rig.rotation.y) * 0.1;
      }

      this.renderer.render(this.scene, this.camera);
      this._raf = requestAnimationFrame(tick);
    };
    this._raf = requestAnimationFrame(tick);
  }

  stop() { this.running = false; if (this._raf) cancelAnimationFrame(this._raf); }

  resize(w, h) {
    if (!w || !h) return;
    this.renderer.setSize(w, h, false);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
  }

  setBreathAmt(a) { this.breathAmt = a; }
}

window.SquatchStage = SquatchStage;
