import * as THREE from 'three';

const TIMMY_POS = new THREE.Vector3(0, 0, -2);
export const TIMMY_WORLD_POS = TIMMY_POS.clone();
const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);

const agentStates = { alpha: 'idle', beta: 'idle', gamma: 'idle', delta: 'idle' };

function deriveTimmyState() {
  if (agentStates.gamma === 'working') return 'working';
  if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking';
  if (Object.values(agentStates).some(s => s !== 'idle')) return 'active';
  return 'idle';
}

let scene = null;
let timmy = null;
let _audioCtx         = null;
let _lastFrameTime    = 0;
let _cameraShakeStr   = 0; // 0-1, read by getCameraShakeStrength()

// ── Ragdoll state machine ─────────────────────────────────────────────────────
const RD_STAND   = 0; // normal idle
const RD_FALL    = 1; // tipping over (0.55 s)
const RD_DOWN    = 2; // sprawled on floor, struggling (1.9 s)
const RD_RISE    = 3; // clambering back up (1.0 s)
const RD_COUNTER = 4; // counter-slap lunge (0.6 s)

const COUNTER_RETORTS = [
  "OI! HAVE SOME OF THAT!",
  "MESS WITH THE WIZARD EH?!",
  "TAKE THAT, YOU RUFFIAN!",
  "DON'T TOUCH THE BEARD!!",
  "HAVE SOME LIGHTNING, KNAVE!",
  "YOU'LL REGRET THAT, MORTAL!",
];

// Residual mini-spring for slight trembling post-fall (STAND only)
const SPRING_STIFFNESS = 7.0;
const SPRING_DAMPING   = 0.80;
const MAX_TILT_RAD     = 0.12;
const SLAP_IMPULSE     = 0.18;

// ── Face emotion targets per internal state ───────────────────────────────────
// lidScale:    0 = fully closed, 1 = wide open
// pupilScale:  scale factor applied to pupil meshes (dilation)
// smileAmount: -1 = frown, 0 = neutral, +1 = big smile
const FACE_TARGETS = {
  idle:     { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, // contemplative — half-lid, neutral
  active:   { lidScale: 0.92, pupilScale: 1.25, smileAmount: 0.38 }, // curious — wide open + dilated, smile
  thinking: { lidScale: 0.30, pupilScale: 0.72, smileAmount: 0.00 }, // focused — narrow squint + constrict, flat mouth
  working:  { lidScale: 0.75, pupilScale: 1.05, smileAmount: 0.18 }, // attentive — alert/open eyes, slight grin
};

// Canonical mood aliases so setFaceEmotion() accepts both internal and task-spec names
const MOOD_ALIASES = {
  contemplative: 'idle',
  curious:       'active',
  focused:       'thinking',
  attentive:     'working',
  idle:          'idle',
  active:        'active',
  thinking:      'thinking',
  working:       'working',
};

// ── Mouth arc geometry — precomputed cache (no runtime allocation) ────────────
// 21 steps from smileAmount -1.0 → +1.0 (step 0.1). All geometries built once
// at module init; updateAgents() just swaps references → zero GC pressure.

const _MOUTH_GEO_STEPS = 21;
const _MOUTH_GEO_MIN   = -1.0;
const _MOUTH_GEO_MAX   =  1.0;

function _buildMouthGeo(smileAmount) {
  const ctrlY = -smileAmount * 0.065; // control point: down for smile, up for frown
  const curve = new THREE.QuadraticBezierCurve3(
    new THREE.Vector3(-0.115,  0.000, 0.000),
    new THREE.Vector3( 0.000, ctrlY,  0.020),
    new THREE.Vector3( 0.115,  0.000, 0.000)
  );
  return new THREE.TubeGeometry(curve, 12, 0.013, 5, false);
}

const _MOUTH_GEO_CACHE = Array.from({ length: _MOUTH_GEO_STEPS }, (_, i) => {
  const t = i / (_MOUTH_GEO_STEPS - 1);
  return _buildMouthGeo(_MOUTH_GEO_MIN + t * (_MOUTH_GEO_MAX - _MOUTH_GEO_MIN));
});

function _pickMouthGeo(smileAmount) {
  const clamped = Math.max(_MOUTH_GEO_MIN, Math.min(_MOUTH_GEO_MAX, smileAmount));
  const idx = Math.round((clamped - _MOUTH_GEO_MIN) / (_MOUTH_GEO_MAX - _MOUTH_GEO_MIN) * (_MOUTH_GEO_STEPS - 1));
  return _MOUTH_GEO_CACHE[idx];
}

// ── Build Timmy ───────────────────────────────────────────────────────────────

export function initAgents(sceneRef) {
  scene = sceneRef;
  timmy = buildTimmy(scene);
}

function buildTimmy(sc) {
  const group = new THREE.Group();
  group.position.copy(TIMMY_POS);

  // ── Robe — royal purple with subtle inner glow ────────────────────────────
  const robeMat = new THREE.MeshStandardMaterial({
    color: 0x5c14b0,         // vivid royal purple
    emissive: 0x2a0060,
    emissiveIntensity: 0.12,
    roughness: 0.72,
    metalness: 0.05,
  });
  const robe = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.72, 2.2, 8), robeMat);
  robe.position.y = 1.1;
  robe.castShadow = true;
  group.add(robe);

  // ── Celestial decorations on robe (tiny gold stars/symbols) ──────────────
  const celestialMat = new THREE.MeshStandardMaterial({
    color: 0xffd060,
    emissive: 0xffaa00,
    emissiveIntensity: 0.9,
    roughness: 0.3,
    metalness: 0.6,
  });
  // Scattered octahedra on robe surface — chest, shoulders, lower body
  const celestialPositions = [
    [  0.00, 1.80, 0.30 ],  // chest centre (moon clasp)
    [ -0.22, 1.60, 0.26 ],  // chest left
    [  0.22, 1.60, 0.26 ],  // chest right
    [ -0.28, 1.30, 0.22 ],  // mid left
    [  0.28, 1.30, 0.22 ],  // mid right
    [  0.00, 0.95, 0.32 ],  // lower centre
    [ -0.18, 0.70, 0.30 ],  // lower left
    [  0.18, 0.70, 0.30 ],  // lower right
  ];
  celestialPositions.forEach(([x, y, z]) => {
    const sz = y > 1.5 ? 0.038 : 0.026; // chest stars larger
    const cs = new THREE.Mesh(new THREE.OctahedronGeometry(sz, 0), celestialMat);
    cs.position.set(x, y, z);
    cs.rotation.y = Math.random() * Math.PI;
    group.add(cs);
  });

  // Moon crescent on chest — a torus segment (big gold torus, small tube)
  const moonMat = new THREE.MeshStandardMaterial({
    color: 0xffd700,
    emissive: 0xffcc00,
    emissiveIntensity: 0.8,
    roughness: 0.25,
    metalness: 0.7,
  });
  const moon = new THREE.Mesh(new THREE.TorusGeometry(0.065, 0.016, 5, 14, Math.PI * 1.3), moonMat);
  moon.position.set(-0.06, 1.82, 0.32);
  moon.rotation.x = -0.3;
  moon.rotation.z = -0.5;
  group.add(moon);

  // ── Head ─────────────────────────────────────────────────────────────────
  // Slightly older, weathered skin tone
  const headMat = new THREE.MeshStandardMaterial({ color: 0xd8a878, roughness: 0.72 });
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 16, 16), headMat);
  head.position.y = 2.6;
  head.castShadow = true;
  group.add(head);

  // ── Face (ALL parented to head — follow head tilt naturally) ─────────────

  const scleraMat = new THREE.MeshStandardMaterial({
    color: 0xf5f2e8,
    emissive: 0x777777,
    emissiveIntensity: 0.10,
    roughness: 0.55,
  });
  const scleraGeo = new THREE.SphereGeometry(0.079, 10, 10);

  const eyeL = new THREE.Mesh(scleraGeo, scleraMat);
  eyeL.position.set(-0.14, 0.05, 0.31);
  head.add(eyeL);

  const eyeR = new THREE.Mesh(scleraGeo, scleraMat.clone());
  eyeR.position.set(0.14, 0.05, 0.31);
  head.add(eyeR);

  const pupilMat = new THREE.MeshBasicMaterial({ color: 0x07070f });
  const pupilGeo = new THREE.SphereGeometry(0.037, 8, 8);

  const pupilL = new THREE.Mesh(pupilGeo, pupilMat);
  pupilL.position.set(0, 0, 0.057);
  eyeL.add(pupilL);

  const pupilR = new THREE.Mesh(pupilGeo, pupilMat.clone());
  pupilR.position.set(0, 0, 0.057);
  eyeR.add(pupilR);

  // Mouth arc
  const mouthMat = new THREE.MeshStandardMaterial({ color: 0x8a4a28, roughness: 0.7, metalness: 0.0 });
  const mouth = new THREE.Mesh(_pickMouthGeo(0.08), mouthMat);
  mouth.position.set(0, -0.18, 0.30);
  head.add(mouth);

  // ── Beard — long grey wizard power beard ─────────────────────────────────
  const beardMat = new THREE.MeshStandardMaterial({
    color: 0xaaa8a0,
    emissive: 0x666660,
    emissiveIntensity: 0.06,
    roughness: 0.90,
  });
  // Wide top where beard meets chin
  const beardTop = new THREE.Mesh(new THREE.CylinderGeometry(0.20, 0.15, 0.22, 7), beardMat);
  beardTop.position.set(0, -0.30, 0.14);
  beardTop.rotation.x = 0.18;
  head.add(beardTop);
  // Long tapered body of the beard flowing downward
  const beardBody = new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.90, 7), beardMat.clone());
  beardBody.position.set(0, -0.72, 0.10);
  beardBody.rotation.x = 0.12;
  head.add(beardBody);

  // ── Hair — silver-white wisps at sides ───────────────────────────────────
  const hairMat = new THREE.MeshStandardMaterial({
    color: 0xc8c4bc,
    emissive: 0x888880,
    emissiveIntensity: 0.06,
    roughness: 0.90,
  });
  // Side hair puffs
  [[-0.34, 0.04, 0.06], [0.34, 0.04, 0.06], [-0.30, -0.10, -0.08], [0.30, -0.10, -0.08]].forEach(([x, y, z]) => {
    const h = new THREE.Mesh(new THREE.SphereGeometry(0.14, 7, 7), hairMat);
    h.position.set(x, y, z);
    h.scale.set(1, 0.7, 0.9);
    head.add(h);
  });
  // Back of head hair mass
  const hairBack = new THREE.Mesh(new THREE.SphereGeometry(0.30, 8, 8), hairMat.clone());
  hairBack.position.set(0, 0.0, -0.22);
  hairBack.scale.set(1, 0.8, 0.7);
  head.add(hairBack);

  // ── Hat — deep royal purple, taller cone ─────────────────────────────────
  const hatMat = new THREE.MeshStandardMaterial({
    color: 0x3a0880,
    emissive: 0x18044a,
    emissiveIntensity: 0.10,
    roughness: 0.78,
  });
  const brim = new THREE.Mesh(new THREE.TorusGeometry(0.47, 0.07, 6, 24), hatMat);
  brim.position.y = 2.94;
  brim.rotation.x = Math.PI / 2;
  group.add(brim);

  const hat = new THREE.Mesh(new THREE.ConeGeometry(0.33, 0.82, 8), hatMat.clone());
  hat.position.y = 3.35;
  group.add(hat);

  // Hat band — thin gold torus just above brim
  const hatBandMat = new THREE.MeshStandardMaterial({
    color: 0xffd700, emissive: 0xffaa00, emissiveIntensity: 0.7,
    roughness: 0.3, metalness: 0.6,
  });
  const hatBand = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.022, 5, 20), hatBandMat);
  hatBand.position.y = 3.02;
  hatBand.rotation.x = Math.PI / 2;
  group.add(hatBand);

  // Star on hat tip
  const starMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffcc00, emissiveIntensity: 1.2 });
  const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.08, 0), starMat);
  star.position.y = 3.80;
  group.add(star);

  // ── Belt — wide gold with central gemstone ────────────────────────────────
  const beltMat = new THREE.MeshStandardMaterial({
    color: 0xffd700,
    emissive: 0xcc8800,
    emissiveIntensity: 0.5,
    roughness: 0.28,
    metalness: 0.65,
  });
  const belt = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.055, 6, 24), beltMat);
  belt.position.y = 0.72;
  belt.rotation.x = Math.PI / 2;
  group.add(belt);

  // Belt gemstone clasp — glowing teal
  const claspMat = new THREE.MeshStandardMaterial({
    color: 0x22ddcc,
    emissive: 0x008888,
    emissiveIntensity: 1.2,
    roughness: 0.1,
    metalness: 0.0,
  });
  const clasp = new THREE.Mesh(new THREE.OctahedronGeometry(0.055, 0), claspMat);
  clasp.position.set(0, 0.72, 0.52);
  group.add(clasp);

  // ── Magic energy hand — right side, glowing orange ───────────────────────
  const magicMat = new THREE.MeshStandardMaterial({
    color: 0xff8800,
    emissive: 0xff5500,
    emissiveIntensity: 2.2,
    roughness: 0.1,
    metalness: 0.0,
  });
  const magicOrb = new THREE.Mesh(new THREE.SphereGeometry(0.10, 10, 10), magicMat);
  magicOrb.position.set(0.55, 1.55, 0.30); // right-hand side, chest height
  group.add(magicOrb);

  const magicLight = new THREE.PointLight(0xff6600, 1.4, 3.5);
  magicOrb.add(magicLight);

  // Trailing sparks — two tiny satellite orbs
  [[0.12, 0.10, 0.06], [-0.08, 0.14, 0.05]].forEach(([dx, dy, dz]) => {
    const spark = new THREE.Mesh(new THREE.SphereGeometry(0.033, 6, 6), magicMat.clone());
    spark.position.set(0.55 + dx, 1.55 + dy, 0.30 + dz);
    group.add(spark);
  });

  sc.add(group);

  // ── Crystal ball ─────────────────────────────────────────────────────────

  const crystalGroup = new THREE.Group();
  crystalGroup.position.copy(CRYSTAL_POS);

  const cbMat = new THREE.MeshPhysicalMaterial({
    color: 0x88ddff,
    emissive: 0x004466,
    emissiveIntensity: 0.5,
    roughness: 0.0,
    metalness: 0.0,
    transmission: 0.65,
    transparent: true,
    opacity: 0.88,
  });
  const cb = new THREE.Mesh(new THREE.SphereGeometry(0.34, 32, 32), cbMat);
  crystalGroup.add(cb);

  const pedMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 0.85 });
  const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.19, 0.24, 8), pedMat);
  ped.position.y = -0.3;
  crystalGroup.add(ped);

  const crystalLight = new THREE.PointLight(0x44bbff, 0.8, 5);
  crystalGroup.add(crystalLight);

  sc.add(crystalGroup);

  // ── Pip familiar ─────────────────────────────────────────────────────────

  const pipMat = new THREE.MeshStandardMaterial({
    color: 0xffaa00,
    emissive: 0xff6600,
    emissiveIntensity: 0.85,
    roughness: 0.3,
  });
  const pip = new THREE.Mesh(new THREE.TetrahedronGeometry(0.17, 0), pipMat);
  pip.position.set(2.5, 1.6, -1);
  const pipLight = new THREE.PointLight(0xffaa00, 0.7, 5);
  pip.add(pipLight);
  sc.add(pip);

  // ── Speech bubble ────────────────────────────────────────────────────────

  const bubbleCanvas = document.createElement('canvas');
  bubbleCanvas.width = 512;
  bubbleCanvas.height = 128;
  const bubbleTex = new THREE.CanvasTexture(bubbleCanvas);
  const bubbleMat = new THREE.SpriteMaterial({ map: bubbleTex, transparent: true, opacity: 0 });
  const bubble = new THREE.Sprite(bubbleMat);
  bubble.scale.set(3.2, 0.8, 1);
  bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z);
  sc.add(bubble);

  // ── Face lerp state ───────────────────────────────────────────────────────

  return {
    group, robe, head, hat, star,
    eyeL, eyeR, pupilL, pupilR,
    mouth,
    magicOrb, magicLight,
    cb, cbMat, crystalGroup, crystalLight,
    pip, pipLight, pipMat,
    bubble, bubbleCanvas, bubbleTex, bubbleMat,
    pulsePhase: Math.random() * Math.PI * 2,
    speechTimer: 0,
    faceParams:   { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
    faceTarget:   { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
    _overrideMood: null,
    // Ragdoll state machine
    rd: {
      state:     RD_STAND,
      timer:     0,
      fallDirX:  0,
      fallDirZ:  1,
      fallAngle: 0,
      // Mini residual spring (STAND only)
      slapOffset:   { x: 0, z: 0 },
      slapVelocity: { x: 0, z: 0 },
    },
    // Pip startle
    pipStartleTimer: 0,
    pipStartleDir:   { x: 0, z: 0 },
    // Crystal ball hit flash
    hitFlashTimer:   0,
  };
}

// ── updateAgents (called every frame) ────────────────────────────────────────

export function updateAgents(time) {
  if (!timmy) return;
  const t = time * 0.001;
  const dt = _lastFrameTime > 0 ? Math.min((time - _lastFrameTime) * 0.001, 0.05) : 0.016;
  _lastFrameTime = time;

  const vs = deriveTimmyState();
  const pulse = Math.sin(t * 1.8 + timmy.pulsePhase);
  const pulse2 = Math.sin(t * 3.5 + timmy.pulsePhase * 1.5);

  let bodyBob, headTilt, crystalI, cbPulseSpeed, robeGlow;
  switch (vs) {
    case 'working':
      bodyBob = Math.sin(t * 4.2) * 0.11;
      headTilt = Math.sin(t * 2.8) * 0.09;
      crystalI = 3.8 + pulse * 1.4;
      cbPulseSpeed = 3.2;
      robeGlow = 0.18;
      break;
    case 'thinking':
      bodyBob = Math.sin(t * 1.6) * 0.06;
      headTilt = Math.sin(t * 1.1) * 0.13;
      crystalI = 1.9 + pulse2 * 0.7;
      cbPulseSpeed = 1.6;
      robeGlow = 0.09;
      break;
    case 'active':
      bodyBob = Math.sin(t * 2.2) * 0.07;
      headTilt = Math.sin(t * 0.9) * 0.05;
      crystalI = 1.3 + pulse * 0.5;
      cbPulseSpeed = 1.3;
      robeGlow = 0.05;
      break;
    default:
      bodyBob = Math.sin(t * 0.85) * 0.04;
      headTilt = Math.sin(t * 0.42) * 0.025;
      crystalI = 0.45 + pulse * 0.2;
      cbPulseSpeed = 0.55;
      robeGlow = 0.0;
  }

  timmy.head.rotation.z = headTilt;

  // ── Ragdoll state machine (handles group.rotation + position.y) ───────────
  _updateRagdoll(dt, t, bodyBob);

  // ── Crystal ball flash on slap hit ───────────────────────────────────────
  if (timmy.hitFlashTimer > 0) {
    timmy.hitFlashTimer = Math.max(0, timmy.hitFlashTimer - dt);
    timmy.crystalLight.intensity = 10.0 * (timmy.hitFlashTimer / 0.5);
    timmy.cbMat.emissiveIntensity = 0.9 * (timmy.hitFlashTimer / 0.5);
  } else {
    timmy.crystalLight.intensity = crystalI;
    timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72;
  }
  timmy.crystalGroup.rotation.y += 0.004 * cbPulseSpeed;
  const cbScale = 1 + Math.sin(t * cbPulseSpeed) * 0.022;
  timmy.cb.scale.setScalar(cbScale);

  timmy.robe.material.emissive = new THREE.Color(0x5c14b0);
  timmy.robe.material.emissiveIntensity = 0.10 + robeGlow;

  // Magic energy orb — pulsing orange glow in Timmy's hand
  const magicPulse = 0.85 + Math.sin(t * 3.4) * 0.35;
  timmy.magicOrb.scale.setScalar(magicPulse * (vs === 'working' ? 1.35 : 1.0));
  timmy.magicLight.intensity = 1.0 + Math.sin(t * 4.2) * 0.5 + (vs === 'working' ? 0.8 : 0.0);
  timmy.magicOrb.position.y = 1.55 + Math.sin(t * 2.8) * 0.04;

  // ── Pip familiar — startle reaction on slap ───────────────────────────────
  if (timmy.pipStartleTimer > 0) timmy.pipStartleTimer = Math.max(0, timmy.pipStartleTimer - dt);
  const startled = timmy.pipStartleTimer > 0;
  const startleRatio = startled ? (timmy.pipStartleTimer / 3.0) : 0;
  const pipSpeedMult = startled ? (1 + 3 * startleRatio) : 1;
  const pipX = Math.sin(t * 0.38 * pipSpeedMult + 1.4) * 3.2 + timmy.pipStartleDir.x * startleRatio;
  const pipZ = Math.sin(t * 0.65 * pipSpeedMult + 0.8) * 2.2 - 1.8 + timmy.pipStartleDir.z * startleRatio;
  const pipY = 1.55 + Math.sin(t * 1.6 * (startled ? 3.5 : 1.0)) * (startled ? 0.72 : 0.32);
  timmy.pip.position.set(pipX, pipY, pipZ);
  timmy.pip.rotation.x += 0.022 * (startled ? 4 : 1);
  timmy.pip.rotation.y += 0.031 * (startled ? 4 : 1);
  timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2 + (startled ? 0.6 * startleRatio : 0);

  // ── Speech bubble fade ───────────────────────────────────────────────────
  if (timmy.speechTimer > 0) {
    timmy.speechTimer -= 0.016;
    const fadeStart = 1.5;
    timmy.bubbleMat.opacity = timmy.speechTimer > fadeStart ? 1.0 : Math.max(0, timmy.speechTimer / fadeStart);
    if (timmy.speechTimer <= 0) timmy.bubbleMat.opacity = 0;
  }

  // ── Face expression lerp ─────────────────────────────────────────────────
  // setFaceEmotion() sets _overrideMood; it takes precedence over derived state.
  // Falls back to deriveTimmyState() when no external override is active.
  const effectiveMood = timmy._overrideMood ?? vs;
  const faceTarget = FACE_TARGETS[effectiveMood] ?? FACE_TARGETS.idle;
  timmy.faceTarget.lidScale    = faceTarget.lidScale;
  timmy.faceTarget.pupilScale  = faceTarget.pupilScale;
  timmy.faceTarget.smileAmount = faceTarget.smileAmount;

  const LERP = 0.055;

  // Lerp all three face parameters toward targets
  timmy.faceParams.lidScale    += (timmy.faceTarget.lidScale    - timmy.faceParams.lidScale)    * LERP;
  timmy.faceParams.pupilScale  += (timmy.faceTarget.pupilScale  - timmy.faceParams.pupilScale)  * LERP;

  // Lip-sync: oscillate smile while speaking (~1 Hz, range 0.2–0.6); override smileAmount target
  const speaking = timmy.speechTimer > 0;
  const smileTarget = speaking
    ? 0.40 + Math.sin(t * 6.283) * 0.20  // 1 Hz, range 0.20–0.60
    : timmy.faceTarget.smileAmount;
  timmy.faceParams.smileAmount += (smileTarget - timmy.faceParams.smileAmount) * LERP;

  // Apply lid scale — squash eye Y axis for squint / wide-open effect
  timmy.eyeL.scale.y = timmy.faceParams.lidScale;
  timmy.eyeR.scale.y = timmy.faceParams.lidScale;

  // Apply pupil dilation — uniform scale on pupil meshes
  const ps = timmy.faceParams.pupilScale;
  timmy.pupilL.scale.setScalar(ps);
  timmy.pupilR.scale.setScalar(ps);

  // Swap precomputed mouth geometry when cached index changes (zero runtime allocation)
  const nextMouthGeo = _pickMouthGeo(timmy.faceParams.smileAmount);
  if (timmy.mouth.geometry !== nextMouthGeo) {
    timmy.mouth.geometry = nextMouthGeo;
  }
}

// ── setFaceEmotion — public API ───────────────────────────────────────────────
// Accepts both task-spec mood names (contemplative|curious|focused|attentive)
// and internal state names (idle|active|thinking|working).
// Sets _overrideMood so it persists across frames, taking precedence over the
// derived agent state in updateAgents(). Pass null to clear the override and
// return to automatic state-driven expressions.

export function setFaceEmotion(mood) {
  if (!timmy) return;
  if (mood === null || mood === undefined) {
    timmy._overrideMood = null;
    return;
  }
  timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle';
}

/**
 * setMood — convenience alias accepted by Task #28 spec.
 * Maps sentiment labels to appropriate Timmy face states:
 *   POSITIVE → curious (active) — wide eyes, smile
 *   NEGATIVE → focused (thinking) — squint, flat mouth
 *   NEUTRAL  → contemplative (idle) — half-lid, neutral
 * Also accepts raw mood names passed through to setFaceEmotion().
 */
export function setMood(moodOrSentiment) {
  if (!moodOrSentiment) { setFaceEmotion(null); return; }
  const m = String(moodOrSentiment).toUpperCase();
  if (m === 'POSITIVE') { setFaceEmotion('curious');       return; }
  if (m === 'NEGATIVE') { setFaceEmotion('focused');       return; }
  if (m === 'NEUTRAL')  { setFaceEmotion('contemplative'); return; }
  // Fall through to alias lookup for raw mood names
  setFaceEmotion(moodOrSentiment);
}

// ── _updateRagdoll — integrated per-frame from updateAgents ──────────────────
// Controls group.rotation and group.position.y for all ragdoll states.
// In RD_STAND it runs the residual micro-spring; all other states run the
// full fall/down/rise/counter animation.

function _updateRagdoll(dt, t, bodyBob) {
  const rd = timmy.rd;
  rd.timer += dt;

  switch (rd.state) {

    // ── FALL — ease-in tipping over (0.55 s) ────────────────────────────────
    case RD_FALL: {
      const p = Math.min(1, rd.timer / 0.55);
      rd.fallAngle = (Math.PI / 2 + 0.12) * (p * p);   // ease-in, overshoot slightly
      timmy.group.rotation.x  =  rd.fallAngle * rd.fallDirZ;
      timmy.group.rotation.z  = -rd.fallAngle * rd.fallDirX;
      timmy.group.position.y  = 0;
      timmy._overrideMood = 'active';                   // wide-eyed surprise

      if (rd.timer >= 0.55) { rd.state = RD_DOWN; rd.timer = 0; }
      break;
    }

    // ── DOWN — sprawled, struggling (1.9 s) ─────────────────────────────────
    case RD_DOWN: {
      // Settle to exactly PI/2 then heave/twitch while trying to get up
      const settle = Math.min(1, rd.timer / 0.12);
      rd.fallAngle = (Math.PI / 2 + 0.12) * (1 - settle) + (Math.PI / 2) * settle;
      rd.fallAngle += Math.sin(rd.timer * 8.5) * 0.025 * (1 - rd.timer / 1.9);
      timmy.group.rotation.x  =  rd.fallAngle * rd.fallDirZ;
      timmy.group.rotation.z  = -rd.fallAngle * rd.fallDirX;
      timmy.group.position.y  = Math.sin(rd.timer * 2.5) * -0.05;
      timmy._overrideMood = 'thinking';                 // squinting / dazed

      if (rd.timer >= 1.9) { rd.state = RD_RISE; rd.timer = 0; }
      break;
    }

    // ── RISE — ease-out clambering up (1.0 s) ───────────────────────────────
    case RD_RISE: {
      const p = Math.min(1, rd.timer / 1.0);
      const eased = 1 - (1 - p) * (1 - p);             // ease-out
      rd.fallAngle = (Math.PI / 2) * (1 - eased);
      timmy.group.rotation.x  =  rd.fallAngle * rd.fallDirZ;
      timmy.group.rotation.z  = -rd.fallAngle * rd.fallDirX;
      timmy.group.position.y  = 0;
      timmy._overrideMood = 'working';                  // determined

      if (rd.timer >= 1.0) {
        // Just stood up — now counter-slap!
        rd.state     = RD_COUNTER;
        rd.timer     = 0;
        rd.fallAngle = 0;
        timmy.group.rotation.x = 0;
        timmy.group.rotation.z = 0;

        // Pick retort, show speech bubble
        const retort = COUNTER_RETORTS[Math.floor(Math.random() * COUNTER_RETORTS.length)];
        setSpeechBubble(retort);
        timmy.speechTimer = 4.5;
        timmy._overrideMood = 'active';

        // Smack sound + camera shake
        _playSmack(true /* high-pitched counter variant */);
        _cameraShakeStr = 1.0;
        timmy.hitFlashTimer = 0.6;
      }
      break;
    }

    // ── COUNTER — lunge forward toward camera (0.6 s) ───────────────────────
    case RD_COUNTER: {
      const p = Math.min(1, rd.timer / 0.6);
      // Sinusoidal lunge: leans toward camera then snaps back
      const lunge = Math.sin(p * Math.PI) * 0.35;
      timmy.group.rotation.x  = -lunge;                // lean toward +Z (camera)
      timmy.group.rotation.z  = 0;
      timmy.group.position.y  = Math.sin(p * Math.PI) * 0.25; // slight hop
      _cameraShakeStr = Math.max(0, 1.0 - rd.timer * 4.5);

      if (rd.timer >= 0.6) {
        rd.state = RD_STAND;
        rd.timer = 0;
        _cameraShakeStr = 0;
        timmy.group.rotation.x = 0;
        timmy.group.rotation.z = 0;
        timmy._overrideMood = null;
      }
      break;
    }

    // ── STAND — normal idle with residual micro-spring trembling ────────────
    case RD_STAND:
    default: {
      const so = rd.slapOffset, sv = rd.slapVelocity;
      sv.x += (-SPRING_STIFFNESS * so.x - SPRING_DAMPING * sv.x) * dt;
      sv.z += (-SPRING_STIFFNESS * so.z - SPRING_DAMPING * sv.z) * dt;
      so.x = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.x + sv.x * dt));
      so.z = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.z + sv.z * dt));
      timmy.group.rotation.x = so.x;
      timmy.group.rotation.z = so.z;
      timmy.group.position.y = bodyBob;
      break;
    }
  }
}

// ── applySlap — called by interaction.js on hit ───────────────────────────────
export function applySlap(hitPoint) {
  if (!timmy) return;

  // Ignore re-slap while already falling/down — wait until standing again
  const rd = timmy.rd;
  if (rd.state !== RD_STAND) return;

  // XZ direction from Timmy to hit point (fall away from impact)
  const dx = hitPoint.x - TIMMY_POS.x;
  const dz = hitPoint.z - TIMMY_POS.z;
  const len = Math.sqrt(dx * dx + dz * dz) || 1;
  rd.fallDirX = dx / len;
  rd.fallDirZ = dz / len;

  // Start ragdoll fall
  rd.state     = RD_FALL;
  rd.timer     = 0;
  rd.fallAngle = 0;

  // Pip startle — maximum scatter
  timmy.pipStartleTimer = 5.0;
  timmy.pipStartleDir.x = (Math.random() - 0.5) * 8.0;
  timmy.pipStartleDir.z = (Math.random() - 0.5) * 8.0;

  // Crystal flash on impact
  timmy.hitFlashTimer = 0.5;

  // Cartoonish SMACK sound
  _playSmack(false);
}

// ── _playSmack — layered cartoon impact SFX ──────────────────────────────────
// counter=false → being slapped (lower pitch thud)
// counter=true  → Timmy retaliates (higher, snappier crack)

function _playSmack(counter) {
  try {
    if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    if (_audioCtx.state === 'suspended') _audioCtx.resume();
    const now = _audioCtx.currentTime;

    // ① Noise crack — sharp transient
    const crackLen = counter ? 0.045 : 0.06;
    const bufSize  = Math.ceil(_audioCtx.sampleRate * crackLen);
    const noiseBuf = _audioCtx.createBuffer(1, bufSize, _audioCtx.sampleRate);
    const nData    = noiseBuf.getChannelData(0);
    for (let i = 0; i < bufSize; i++) nData[i] = Math.random() * 2 - 1;
    const nSrc = _audioCtx.createBufferSource();
    nSrc.buffer = noiseBuf;
    const nGain = _audioCtx.createGain();
    nGain.gain.setValueAtTime(counter ? 1.4 : 1.1, now);
    nGain.gain.exponentialRampToValueAtTime(0.001, now + crackLen);
    nSrc.connect(nGain); nGain.connect(_audioCtx.destination);
    nSrc.start(now); nSrc.stop(now + crackLen + 0.01);

    // ② Low body thump — sine sweep
    const thumpStart = counter ? 340 : 220;
    const thumpEnd   = counter ? 80  : 45;
    const thumpDur   = counter ? 0.14 : 0.20;
    const thump = _audioCtx.createOscillator();
    thump.type = 'sine';
    thump.frequency.setValueAtTime(thumpStart, now);
    thump.frequency.exponentialRampToValueAtTime(thumpEnd, now + thumpDur);
    const tGain = _audioCtx.createGain();
    tGain.gain.setValueAtTime(counter ? 0.75 : 0.95, now);
    tGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur + 0.04);
    thump.connect(tGain); tGain.connect(_audioCtx.destination);
    thump.start(now); thump.stop(now + thumpDur + 0.05);

    // ③ Comic wobble tail — cartoon spring spring
    const wobbleStart = counter ? 280 : 180;
    const wobbleEnd   = counter ? 100 : 55;
    const wobbleDur   = counter ? 0.40 : 0.55;
    const wobble = _audioCtx.createOscillator();
    wobble.type = 'sine';
    wobble.frequency.setValueAtTime(wobbleStart, now + 0.04);
    wobble.frequency.exponentialRampToValueAtTime(wobbleEnd, now + 0.04 + wobbleDur);
    const wGain = _audioCtx.createGain();
    wGain.gain.setValueAtTime(0.0, now);
    wGain.gain.linearRampToValueAtTime(counter ? 0.30 : 0.40, now + 0.05);
    wGain.gain.exponentialRampToValueAtTime(0.001, now + 0.06 + wobbleDur);
    wobble.connect(wGain); wGain.connect(_audioCtx.destination);
    wobble.start(now + 0.03); wobble.stop(now + 0.06 + wobbleDur);

  } catch (_) {
    // Autoplay policy / audio not supported — silently skip
  }
}

// ── getTimmyGroup — used by interaction.js for raycasting ────────────────────
export function getTimmyGroup() {
  return timmy ? timmy.group : null;
}

// ── getCameraShakeStrength — read each frame by main.js for viewport shake ───
// Returns 0-1; main.js applies a transient random offset to camera.position.
export function getCameraShakeStrength() {
  return _cameraShakeStr;
}

export function setAgentState(agentId, state) {
  if (agentId in agentStates) agentStates[agentId] = state;
}

export function setSpeechBubble(text) {
  if (!timmy) return;
  const canvas = timmy.bubbleCanvas;
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = 'rgba(8, 6, 16, 0.9)';
  _roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
  ctx.fill();
  ctx.strokeStyle = '#5599dd';
  ctx.lineWidth = 2;
  _roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
  ctx.stroke();

  ctx.fillStyle = '#aaddff';
  ctx.font = 'bold 21px Courier New';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  const words = text.split(' ');
  const lines = [];
  let line = '';
  for (const w of words) {
    const test = line ? `${line} ${w}` : w;
    if (test.length > 42) { lines.push(line); line = w; } else line = test;
  }
  if (line) lines.push(line);

  const lineH = 30;
  const startY = canvas.height / 2 - ((lines.length - 1) * lineH) / 2;
  lines.slice(0, 3).forEach((l, i) => ctx.fillText(l, canvas.width / 2, startY + i * lineH));

  timmy.bubbleTex.needsUpdate = true;
  timmy.bubbleMat.opacity = 1;
  timmy.speechTimer = 9;
}

function _roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

export function getAgentCount() { return 1; }

export function getAgentStates() { return { ...agentStates }; }

export function applyAgentStates(snapshot) {
  if (!snapshot) return;
  for (const [k, v] of Object.entries(snapshot)) {
    if (k in agentStates) agentStates[k] = v;
  }
}

export function getAgentDefs() {
  return [{ id: 'timmy', label: 'TIMMY', role: 'wizard', color: 0x5599ff, state: deriveTimmyState() }];
}

export function disposeAgents() {
  if (!timmy) return;
  [timmy.robe, timmy.head, timmy.hat, timmy.cb, timmy.pip].forEach(m => {
    if (m) { m.geometry?.dispose(); if (Array.isArray(m.material)) m.material.forEach(x => x.dispose()); else m.material?.dispose(); }
  });
  [timmy.eyeL, timmy.eyeR].forEach(m => {
    if (m) { m.geometry?.dispose(); m.material?.dispose(); }
  });
  // Cached mouth geometries are shared; dispose the cache here
  _MOUTH_GEO_CACHE.forEach(g => g.dispose());
  timmy.mouth?.material?.dispose();
  timmy.bubbleTex?.dispose();
  timmy.bubbleMat?.dispose();
  timmy = null;
  scene = null;
}
