// GLB/GLTF character loader with auto-rigging for talking/looking + procedural idle
// API match: { group, update(dt), setMouthOpen(amp), setLookAt(v3), triggerGesture(name) }

const CharacterLoader = (() => {

  function findBoneOrMesh(root, regex) {
    let found = null;
    root.traverse((o) => {
      if (found) return;
      if ((o.isBone || o.isMesh) && regex.test(o.name || '')) found = o;
    });
    return found;
  }

  // Find ALL morph targets matching regex (sometimes split across meshes)
  function findMorphTargets(root, regex) {
    const found = [];
    root.traverse((o) => {
      if (!o.isMesh || !o.morphTargetDictionary) return;
      for (const name in o.morphTargetDictionary) {
        if (regex.test(name)) {
          found.push({ mesh: o, index: o.morphTargetDictionary[name], name });
        }
      }
    });
    return found;
  }

  function logDiagnostics(model, animations) {
    const bones = [];
    const morphs = new Set();
    const meshes = [];
    model.traverse((o) => {
      if (o.isBone) bones.push(o.name);
      if (o.isMesh) {
        meshes.push(o.name);
        if (o.morphTargetDictionary) {
          for (const k in o.morphTargetDictionary) morphs.add(k);
        }
      }
    });
    console.group('[CharacterLoader] GLB diagnostics');
    console.log('Animations:', (animations || []).map(a => a.name));
    console.log('Bones:', bones.length, bones.slice(0, 20));
    console.log('Meshes:', meshes.slice(0, 10));
    console.log('Morph targets:', [...morphs].slice(0, 30));
    console.groupEnd();
  }

  async function load({ url, arrayBuffer }) {
    const Loader = window.GLTFLoader || THREE.GLTFLoader;
    const loader = new Loader();
    return new Promise((resolve, reject) => {
      const onDone = (gltf) => {
        try {
          logDiagnostics(gltf.scene || gltf.scenes[0], gltf.animations);
          resolve(wrap(gltf));
        } catch (e) { reject(e); }
      };
      const onErr = (e) => reject(e);
      if (arrayBuffer) {
        loader.parse(arrayBuffer, '', onDone, onErr);
      } else if (url) {
        loader.load(url, onDone, undefined, onErr);
      } else reject(new Error('no source'));
    });
  }

  function wrap(gltf) {
    const group = new THREE.Group();
    // wrapper layer so user-controlled rotation/scale don't fight bounding-box centering
    const inner = new THREE.Group();
    group.add(inner);
    const model = gltf.scene || gltf.scenes[0];

    // Center & scale: feet on ground, ~1.7m tall, centered XZ
    const box = new THREE.Box3().setFromObject(model);
    const size = new THREE.Vector3(); box.getSize(size);
    const targetH = 1.7;
    const baseScale = targetH / Math.max(0.001, size.y);
    model.scale.setScalar(baseScale);
    const box2 = new THREE.Box3().setFromObject(model);
    const c2 = new THREE.Vector3(); box2.getCenter(c2);
    model.position.x -= c2.x;
    model.position.z -= c2.z;
    model.position.y -= box2.min.y;

    model.traverse((o) => {
      if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
      if (o.isMesh && o.material) {
        // Avoid pure black T-pose silhouette in low light: ensure side2/double helps
        if (o.material.transparent === false && o.material.opacity === 1) {
          // no-op; just leaving hook here
        }
      }
    });

    inner.add(model);

    // Auto-detect facing: characters often face -Z (RPM/Mixamo) or +Z (Blender export).
    // Heuristic: find head bone and nose-equivalent, see which way it leans.
    // Simpler: just default to NOT rotated; user can fix with rotationY slider.

    // Animations
    let mixer = null;
    const actions = {};
    if (gltf.animations && gltf.animations.length) {
      mixer = new THREE.AnimationMixer(model);
      for (const clip of gltf.animations) {
        actions[clip.name.toLowerCase()] = mixer.clipAction(clip);
      }
      const idle = actions['idle'] || actions['breathing'] || actions[Object.keys(actions)[0]];
      if (idle) idle.play();
    }
    const hasClipIdle = mixer && Object.keys(actions).length > 0;

    // Bones for procedural idle (when no animation clip exists)
    const headBone = findBoneOrMesh(model, /^(head|neck_top|head_|mixamorig:?head)$/i)
                  || findBoneOrMesh(model, /head/i);
    const neckBone = findBoneOrMesh(model, /^neck$|neck_/i) || findBoneOrMesh(model, /neck/i);
    const spineBone = findBoneOrMesh(model, /^spine_?2$|chest|upperchest/i)
                   || findBoneOrMesh(model, /spine/i);
    const jawBone = findBoneOrMesh(model, /^jaw|jawopen|mouth_open/i);
    const leftArm = findBoneOrMesh(model, /leftarm|left_arm|l_arm|leftupperarm/i);
    const rightArm = findBoneOrMesh(model, /rightarm|right_arm|r_arm|rightupperarm/i);
    const leftShoulder = findBoneOrMesh(model, /leftshoulder|left_shoulder|l_shoulder|leftclavicle/i);
    const rightShoulder = findBoneOrMesh(model, /rightshoulder|right_shoulder|r_shoulder|rightclavicle/i);

    // Fix T-pose if no animation clips: rotate upper-arm bones DOWN ~75° so arms rest
    // along the body. Mixamo/RPM/MakeHuman conventions: rotateZ +/-1.3 rad in local space.
    const hasClips = (gltf.animations && gltf.animations.length > 0);
    if (!hasClips) {
      // Auto-detect axis: try rotateZ first (Mixamo/RPM), check if it moves arm down in world.
      // Simpler heuristic: just rotate, and if the user's character is rigged differently
      // they can tweak via the rotation slider per-character.
      if (leftArm) leftArm.rotateZ(1.25);   // left shoulder Z+ → arm down
      if (rightArm) rightArm.rotateZ(-1.25); // right shoulder Z- → arm down
      // tiny inward rotation so arms hug body
      if (leftArm) leftArm.rotateY(0.1);
      if (rightArm) rightArm.rotateY(-0.1);
    }

    // Cache rest poses (so procedural deltas are additive, not absolute) — AFTER T-pose fix
    const restPose = new Map();
    [headBone, neckBone, spineBone, leftArm, rightArm, leftShoulder, rightShoulder].forEach((b) => {
      if (b) restPose.set(b, { pos: b.position.clone(), quat: b.quaternion.clone() });
    });

    // Morph targets — try every common naming convention
    const mouthMorphs = [
      ...findMorphTargets(model, /^viseme_(aa|AA|E|O|U|PP|FF|TH|DD|kk|CH|SS|nn|RR|sil)$/),
      ...findMorphTargets(model, /^(mouthopen|mouth_open|jawopen|jaw_open|jawOpen)$/i),
      ...findMorphTargets(model, /^aa$|^ah$|^A$/i),
    ];
    // Dedup by mesh+index
    const seen = new Set();
    const uniqueMouth = mouthMorphs.filter(m => {
      const k = m.mesh.uuid + ':' + m.index;
      if (seen.has(k)) return false;
      seen.add(k); return true;
    });

    const blinkMorphsL = findMorphTargets(model, /^(eyeblink_l|eyeblinkleft|blink_l|blinkleft|blink|eyesclosed)$/i);
    const blinkMorphsR = findMorphTargets(model, /^(eyeblink_r|eyeblinkright|blink_r|blinkright)$/i);
    const blinkMorphs = blinkMorphsR.length
      ? [...blinkMorphsL, ...blinkMorphsR]
      : blinkMorphsL;

    let mouthOpen = 0;
    let mouthOpenTarget = 0;
    let lookTarget = new THREE.Vector3(0, 1.6, 5);
    const headBaseQuat = headBone ? headBone.quaternion.clone() : null;
    let breath = Math.random() * Math.PI * 2;
    let blinkTimer = 1.5 + Math.random() * 3;
    let blinkAmt = 0;
    let gestureT = 0;
    let gestureTarget = 0; // arm raise amount

    function setMouthOpen(amp) {
      mouthOpenTarget = Math.max(0, Math.min(1, amp));
    }
    function setLookAt(v3) { lookTarget.copy(v3); }

    function triggerGesture(name) {
      const a = actions[name?.toLowerCase?.()];
      if (a) {
        a.reset();
        a.setLoop(THREE.LoopOnce, 1);
        a.clampWhenFinished = true;
        a.fadeIn(0.2).play();
        setTimeout(() => a.fadeOut(0.4), 1100);
      } else {
        // procedural fallback: small arm raise
        gestureT = 0;
        gestureTarget = 0.5;
      }
    }

    function update(dt) {
      // Lip-sync amplitude smoothing
      mouthOpen += (mouthOpenTarget - mouthOpen) * Math.min(1, dt * 22);

      // Apply mouth — morph if available, else jaw bone
      if (uniqueMouth.length) {
        for (const m of uniqueMouth) {
          if (m.mesh.morphTargetInfluences) {
            m.mesh.morphTargetInfluences[m.index] = mouthOpen;
          }
        }
      } else if (jawBone) {
        const rest = restPose.get(jawBone);
        if (rest) {
          jawBone.quaternion.copy(rest.quat);
          jawBone.rotateX(mouthOpen * 0.5);
        }
      }

      // Procedural idle (only if there's no built-in animation)
      if (!hasClipIdle) {
        breath += dt * 1.2;
        const bScale = 1 + Math.sin(breath) * 0.008;
        inner.scale.setScalar(bScale);

        // gentle chest sway
        if (spineBone) {
          const rest = restPose.get(spineBone);
          if (rest) {
            spineBone.quaternion.copy(rest.quat);
            spineBone.rotateZ(Math.sin(breath * 0.5) * 0.015);
            spineBone.rotateX(Math.sin(breath) * 0.008);
          }
        }
        // arm sway
        const swayArm = (bone, sign) => {
          if (!bone) return;
          const rest = restPose.get(bone);
          if (!rest) return;
          bone.quaternion.copy(rest.quat);
          bone.rotateZ(sign * (0.04 + Math.sin(breath * 0.8 + sign) * 0.02));
        };
        swayArm(leftArm, 1);
        swayArm(rightArm, -1);

        // Gesture: temporary arm raise
        if (gestureT < 1) {
          gestureT = Math.min(1, gestureT + dt * 1.4);
          const v = Math.sin(gestureT * Math.PI) * gestureTarget;
          if (rightShoulder) {
            const rest = restPose.get(rightShoulder);
            if (rest) {
              rightShoulder.quaternion.copy(rest.quat);
              rightShoulder.rotateZ(-v * 1.2);
            }
          }
        }
      }

      // Blink (always procedural when morphs exist)
      if (blinkMorphs.length) {
        blinkTimer -= dt;
        if (blinkTimer <= 0) {
          blinkAmt = Math.min(1, blinkAmt + dt * 14);
          if (blinkAmt >= 1) blinkTimer = 2 + Math.random() * 4;
        } else if (blinkAmt > 0) {
          blinkAmt = Math.max(0, blinkAmt - dt * 10);
        }
        const v = blinkAmt > 0.5 ? (1 - blinkAmt) * 2 : blinkAmt * 2;
        for (const m of blinkMorphs) {
          if (m.mesh.morphTargetInfluences) {
            m.mesh.morphTargetInfluences[m.index] = v;
          }
        }
      }

      // Head look-at — apply on top of base pose
      if (headBone && headBaseQuat) {
        const headWorld = new THREE.Vector3();
        headBone.getWorldPosition(headWorld);
        const dir = new THREE.Vector3().subVectors(lookTarget, headWorld).normalize();
        const m = new THREE.Matrix4().lookAt(new THREE.Vector3(), dir, new THREE.Vector3(0, 1, 0));
        const qWorld = new THREE.Quaternion().setFromRotationMatrix(m);
        const parentQ = new THREE.Quaternion();
        if (headBone.parent) headBone.parent.getWorldQuaternion(parentQ);
        const qLocal = parentQ.invert().multiply(qWorld);
        headBone.quaternion.slerp(qLocal, 0.05);
      }

      if (mixer) mixer.update(dt);
    }

    return {
      group, // outer — user transforms (rotation, scale) live on this from app
      inner, // inner — owns the model; do not touch from app
      update, setMouthOpen, setLookAt, triggerGesture,
      isGLB: true,
      hasClipIdle,
      animationNames: Object.keys(actions),
      mouthMode: uniqueMouth.length ? 'morph' : (jawBone ? 'jaw-bone' : 'none'),
      diagnostics: {
        mouthMorphs: uniqueMouth.map(m => m.name),
        hasJawBone: !!jawBone,
        hasHeadBone: !!headBone,
        animationCount: Object.keys(actions).length,
      },

      // Load an external GLB animation and bind it to THIS character's skeleton.
      // Slots: 'idle' | 'talking' | 'listening' | 'gesture1' | 'gesture2'
      // Returns the action so caller can play/fade.
      async loadAnimationFromBuffer(arrayBuffer, slot) {
        const Loader = window.GLTFLoader || THREE.GLTFLoader;
        const loader = new Loader();
        const animGltf = await new Promise((res, rej) =>
          loader.parse(arrayBuffer, '', res, rej));
        if (!animGltf.animations || !animGltf.animations.length) {
          throw new Error('No animation clips found in file');
        }
        if (!mixer) mixer = new THREE.AnimationMixer(model);
        const clip = animGltf.animations[0];
        // Sanitize: strip 'mixamorig:' prefix from track names so they bind to RPM/clean rigs
        const cleanClip = clip.clone();
        cleanClip.tracks = cleanClip.tracks.map(t => {
          const newName = t.name.replace(/^mixamorig:?/i, '').replace(/^mixamorig\d+:/i, '');
          const tt = t.clone();
          tt.name = newName;
          return tt;
        });
        const action = mixer.clipAction(cleanClip);
        actions[slot] = action;
        return action;
      },

      // State machine: switch between idle/talking/listening
      setState(state) {
        const fadeTime = 0.3;
        const target = actions[state];
        if (!target) return false;
        // fade out everything else
        for (const k in actions) {
          if (k === state) continue;
          const a = actions[k];
          if (a.isRunning() && a.getEffectiveWeight() > 0) {
            a.fadeOut(fadeTime);
          }
        }
        target.reset();
        target.setLoop(THREE.LoopRepeat, Infinity);
        target.fadeIn(fadeTime).play();
        return true;
      },

      hasAnimation(slot) { return !!actions[slot]; },
    };
  }

  return { load };
})();

window.CharacterLoader = CharacterLoader;
