// ═══════════════════════════════════════════════════════════════════════════
// OM GROUP ERP — AGENT CHARACTER SYSTEM  (v2 — single-plate renderer)
// ---------------------------------------------------------------------------
// ROOT CAUSE OF THE PREVIOUS CORRUPTION
//   v1 cross-faded expressions: the incoming plate mounted on top at opacity 0
//   and interpolated to 1 while the outgoing plate stayed visible underneath.
//   The petals are identical between plates so that blend was invisible there —
//   but the FACE region is not. For the whole 200ms both faces were partially
//   transparent and superimposed, which is exactly the reported defect: two
//   mouths, doubled eyes, a ghost mic boom, half of one emotion over another.
//   Opacity interpolation between two different faces can never be clean.
//
// THE FIX — NEVER BLEND TWO FACES
//   There is exactly one <img> in the DOM. An expression change is a single-
//   frame hard cut of its src, and that cut is hidden behind physical motion
//   rather than a blend: the character dips (scale down / tilt / settle), the
//   face is replaced at the bottom of the dip, then it springs back out. Same
//   trick real animation plays on a cut. At no instant do two faces coexist,
//   so face integrity is structurally guaranteed, not merely tuned.
//
// ONE SOURCE OF TRUTH
//   mode (from the ERP) → base expression; the director may raise a transient
//   beat on top. Those two collapse into ONE target expression, and one swap
//   engine owns what is on screen. Newest target always wins; an in-flight
//   swap is cancelled by token, never queued.
//
// Layers:  react (hover / press / pointer attention)
//            └ breathe (idle micro-motion)
//                └ swap (expression transition motion)
//                    └ the single face plate
// ═══════════════════════════════════════════════════════════════════════════
const { useState: omcSt, useEffect: omcEf, useRef: omcRf } = React;

const OMC_ART = {
  idle: 'erp/agent-faces/idle.png'   // the one approved face — bright eyes, smile
};

// One approved face across every agent mode. The mode still drives the
// character's MOTION and the surrounding glow — it just never swaps the face.
const OMC_MODE = {
  idle: 'idle', listening: 'idle', thinking: 'idle',
  success: 'idle', nodata: 'idle', error: 'idle'
};

// Per-expression motion language. The emotion drives the movement, so settling
// back to rest and a moment of delight do not travel the same curve.
//   out  — ms spent dipping away from the old face
//   in   — ms spent settling into the new one
//   dip  — scale at the bottom of the dip (the frame the swap happens on)
//   dy   — vertical travel during the dip, in % of the avatar (+ = downward)
//   rot  — degrees of head tilt carried through the dip
//   ease — settle curve; a little overshoot for the energetic states
const OMC_DEFAULT_MOTION = { out: 300, in: 760, dip: 0.955, dy: 0.6, rot: 0, ease: 'cubic-bezier(.22,1,.36,1)' };
const OMC_MOTION = {
  idle: { out: 300, in: 780, dip: 0.958, dy: 0.6, rot: 0, ease: 'cubic-bezier(.22,1,.36,1)' }
};
const omcMotion = k => OMC_MOTION[k] || OMC_DEFAULT_MOTION;
// A beat must outlive its own transition, or it would be cancelled mid-settle.
const omcCost = k => { const m = omcMotion(k); return m.out + m.in; };

// ── Preload + integrity gate ───────────────────────────────────────────────
// Every plate is fetched and decoded up front. A plate is only ever shown once
// it is known-good; a failed asset falls back to idle rather than rendering a
// broken or half-decoded frame.
const OMC_READY = {};
(function () {
  if (typeof Image === 'undefined') return;
  Object.keys(OMC_ART).forEach(function (k) {
    const img = new Image();
    img.onload = function () { OMC_READY[k] = true; };
    img.onerror = function () { OMC_READY[k] = false; };
    img.src = OMC_ART[k];
    if (img.decode) img.decode().then(function () { OMC_READY[k] = true; }, function () {});
  });
})();
const omcSafe = k => (OMC_ART[k] && OMC_READY[k] !== false ? k : 'idle');

let omcSeq = 0;
function omcReduced() {
  try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; }
}
const omcRand = (a, b) => a + Math.random() * (b - a);

function OMAgentCharacter({ state = 'idle', size = 40, interactive = false, onClick, title, style }) {
  const reduce = omcReduced();
  const [hover, setHover] = omcSt(false);
  const [pressed, setPressed] = omcSt(false);
  const [beat, setBeat] = omcSt(null);      // transient expression from the director
  const [ripples, setRipples] = omcSt([]);
  const reactRef = omcRf(null);
  const timers = omcRf([]);
  const rootRef = omcRf(null);
  const [onScreen, setOnScreen] = omcSt(true);

  // Pause beats only when the character is genuinely off-screen. (document.hidden
  // is NOT used: embedded/preview frames can report hidden while fully visible,
  // which would freeze the character.)
  omcEf(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(es => setOnScreen(es[0].isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const mode = OMC_MODE[state] ? state : 'idle';
  const base = OMC_MODE[mode];
  const target = omcSafe(beat || base);   // ← the single source of truth

  // ── Swap engine ──────────────────────────────────────────────────────────
  // `shown` is what the one <img> is displaying. `dip` is the transition
  // transform. A change to `target` cancels any in-flight swap by token, so a
  // burst of state changes collapses onto the newest one instead of stacking.
  const [shown, setShown] = omcSt(target);
  const [dip, setDip] = omcSt(null);       // {m, phase:'out'|'in'} | null
  const swapRef = omcRf({ token: 0, timers: [] });

  omcEf(() => {
    const S = swapRef.current;
    const clear = () => { S.timers.forEach(clearTimeout); S.timers = []; };
    if (target === shown) return;
    clear();
    const token = ++S.token;
    if (reduce) { setShown(target); setDip(null); return; }   // no decorative motion
    const m = omcMotion(target);
    setDip({ m: m, phase: 'out' });
    S.timers.push(setTimeout(() => {
      if (S.token !== token) return;
      // ── the cut: one frame, at the bottom of the dip, single element ──
      setShown(target);
      setDip({ m: m, phase: 'in' });
      S.timers.push(setTimeout(() => { if (S.token === token) setDip(null); }, m.in));
    }, m.out));
    return clear;
  }, [target, shown, reduce]);

  omcEf(() => () => { swapRef.current.timers.forEach(clearTimeout); }, []);

  // ── Director — one scheduler, weighted random beats, per mode ────────────
  omcEf(() => {
    timers.current.forEach(clearTimeout); timers.current = [];
    setBeat(null);
    if (reduce || !onScreen) return;
    let alive = true;
    const at = (ms, fn) => { timers.current.push(setTimeout(() => { if (alive) fn(); }, ms)); };
    // A beat is held for its own dwell PLUS the cost of getting there, so a
    // transition is never interrupted halfway by the beat expiring.
    const show = (k, ms, next) => { setBeat(k); at(omcCost(k) + ms, () => { setBeat(null); if (next) next(); }); };

    // — beats ————————————————————————————————————————————————
    // The face is a single approved asset, so there are no expression beats to
    // schedule. Life comes from the breathing layer, the glow and the pointer
    // response — the mode changes the character's energy, never its face.
    void show;

    return () => { alive = false; timers.current.forEach(clearTimeout); timers.current = []; };
  }, [mode, reduce, onScreen]);

  // ── Pointer attention — a couple of pixels of lean, never a rotation ─────
  function onMove(e) {
    if (!interactive || reduce || !reactRef.current) return;
    const r = reactRef.current.getBoundingClientRect();
    const dx = (e.clientX - (r.left + r.width / 2)) / (r.width / 2);
    const dy = (e.clientY - (r.top + r.height / 2)) / (r.height / 2);
    const m = Math.max(1.6, size * 0.05);
    reactRef.current.style.setProperty('--omc-tx', (Math.max(-1, Math.min(1, dx)) * m).toFixed(2) + 'px');
    reactRef.current.style.setProperty('--omc-ty', (Math.max(-1, Math.min(1, dy)) * m).toFixed(2) + 'px');
  }
  function resetLean() {
    if (!reactRef.current) return;
    reactRef.current.style.setProperty('--omc-tx', '0px');
    reactRef.current.style.setProperty('--omc-ty', '0px');
  }

  function handleClick(e) {
    if (interactive && !reduce) {
      const id = ++omcSeq;
      setRipples(r => r.concat([id]));
      setTimeout(() => setRipples(r => r.filter(x => x !== id)), 640);

    }
    if (onClick) onClick(e);
  }

  const scale = pressed ? 0.955 : (hover && interactive ? 1.028 : 1);

  // Transition motion for the one plate. 'out' travels to the dip, 'in' settles
  // home from it; with no swap running the element rests at identity.
  const swapStyle = dip
    ? (dip.phase === 'out'
      ? { transform: 'translateY(' + dip.m.dy + '%) rotate(' + dip.m.rot + 'deg) scale(' + dip.m.dip + ')', transition: 'transform ' + dip.m.out + 'ms cubic-bezier(.4,0,.7,1)' }
      : { transform: 'none', transition: 'transform ' + dip.m.in + 'ms ' + dip.m.ease })
    : { transform: 'none', transition: 'transform 620ms cubic-bezier(.22,1,.36,1)' };

  return (
    <div className="omc" ref={rootRef} data-mode={mode} data-hover={hover && interactive ? '1' : '0'}
      style={Object.assign({ width: size, height: size }, style || {})}
      title={title} onClick={onClick || interactive ? handleClick : undefined}
      onMouseEnter={interactive ? () => setHover(true) : undefined}
      onMouseLeave={interactive ? () => { setHover(false); setPressed(false); resetLean(); } : undefined}
      onMouseMove={interactive ? onMove : undefined}
      onMouseDown={interactive ? () => setPressed(true) : undefined}
      onMouseUp={interactive ? () => setPressed(false) : undefined}>
      {ripples.map(id => <span className="omc-ripple" key={id} />)}
      <div className="omc-glow" />
      <div className="omc-react" ref={reactRef} style={{ transform: 'translate(var(--omc-tx,0px),var(--omc-ty,0px)) scale(' + scale + ')' }}>
        <div className="omc-breathe">
          <div className="omc-swap" style={swapStyle}>
            <img className="omc-plate" data-k={shown} src={OMC_ART[shown]} alt="" draggable="false" />
          </div>
        </div>
      </div>
    </div>
  );
}

window.OMAgentCharacter = OMAgentCharacter;
// Back-compat alias: earlier builds mount window.OMAgentFace.
window.OMAgentFace = function (p) {
  return <OMAgentCharacter state={(p.state === 'warning' || p.state === 'sad') ? 'nodata' : p.state} size={p.size} style={p.style} />;
};
