// motion-kit.jsx — Spring-physics motion primitives for Vital (Nurse CNE)
// ─────────────────────────────────────────────────────────────────────────
//  Public API (mirrors framer-motion's vocabulary so the consumer
//  feels familiar):
//
//    M.div / M.button / M.li / M.a / M.span / M.section / M.nav
//        Drop-in elements that accept `whileTap`, `whileHover`,
//        `whileFocus` style overrides + a `transition` knob.
//
//    <AnimatePresence>{cond && <Thing key="x" />}</AnimatePresence>
//        Keeps a single child mounted long enough to play its exit
//        animation. The child receives `data-anim-state="enter"|"exit"`
//        which the CSS layer hooks for keyframes. Single-child only —
//        we render exactly the surfaces (Sheet, Toast, Overlay) that
//        need exit choreography, no more.
//
//    <Stagger delay={50}>{ items.map(...) }</Stagger>
//        Inline-cascade entrance for arrays — no per-item wiring.
//
//    useReducedMotion()
//        Live boolean. Every primitive here checks it and degrades
//        gracefully (no transform, no animation).
//
//    <Skeleton w h r />
//        Shimmer placeholder using the project's tokens (bg-2 / surface)
//        — never invents colors.
//
//    <LoadingButton isLoading disabled> children </LoadingButton>
//        Disabled + spinner + aria-busy + click-suppressed.
//        Defeats the classic "user spam-clicks → API connects 5x" foot-gun.
//
//    showToast(message, { tone, duration })
//        Inline toast using existing .vital-toast styles.
//
//  Why a hand-rolled module instead of framer-motion?
//  ───────────────────────────────────────────────────
//  The host page is Babel-standalone UMD React. framer-motion's UMD
//  has been historically brittle in that environment (export shape
//  drift between minor versions). This module is ~180 lines and zero
//  network dependency. The animations themselves use CSS transitions
//  tuned to a critically-damped spring curve (.18, .7, .2, 1) and a
//  tiny overshoot (.5, .8, .35, 1.4) for `whileTap`. Subjectively they
//  read as the same physics framer-motion's default spring produces.
//
//  Reduced motion is enforced both centrally (CSS @media block) AND
//  per-component (useReducedMotion()) so we degrade even when CSS
//  matchMedia gets out of sync mid-session.
// ─────────────────────────────────────────────────────────────────────────

const {
  useState:        mkState,
  useEffect:       mkEffect,
  useLayoutEffect: mkLayoutEffect,
  useRef:          mkRef,
  Children:        mkChildren,
  cloneElement:    mkClone,
  isValidElement:  mkIsValid,
  forwardRef:      mkForwardRef,
  createElement:   mkH,
} = React;

// ── prefers-reduced-motion hook ─────────────────────────────────────────
function useReducedMotion() {
  const read = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
  const [reduced, setReduced] = mkState(read);
  mkEffect(() => {
    const mq = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)');
    if (!mq) return;
    const onChange = () => setReduced(read());
    if (mq.addEventListener) {
      mq.addEventListener('change', onChange);
      return () => mq.removeEventListener('change', onChange);
    }
    mq.addListener(onChange);
    return () => mq.removeListener(onChange);
  }, []);
  return reduced;
}

// ── spring curves (cubic-bezier approximations) ─────────────────────────
const SPRING = {
  // critically-damped — close to {stiffness:300, damping:25}
  std:    'cubic-bezier(.18, .7, .2, 1)',
  // tiny overshoot on release (whileTap release feel)
  bounce: 'cubic-bezier(.5, .8, .35, 1.4)',
  // smooth press-in (whileTap entry)
  press:  'cubic-bezier(.4, 0, .2, 1)',
};

// ── style → CSS transform fragment ──────────────────────────────────────
function styleToTransform(s) {
  if (!s) return '';
  const parts = [];
  if (s.scale  != null) parts.push('scale(' + s.scale + ')');
  if (s.x      != null) parts.push('translateX(' + s.x + 'px)');
  if (s.y      != null) parts.push('translateY(' + s.y + 'px)');
  if (s.rotate != null) parts.push('rotate(' + s.rotate + 'deg)');
  return parts.join(' ');
}

// ── M.<tag> factory ─────────────────────────────────────────────────────
function makeMotion(Tag) {
  const Comp = mkForwardRef(function MotionComp(props, ref) {
    const {
      whileHover, whileTap, whileFocus,
      transition,
      style,
      onPointerDown, onPointerUp, onPointerCancel,
      onPointerEnter, onPointerLeave,
      onFocus, onBlur,
      disabled,
      ...rest
    } = props;

    const reduced = useReducedMotion();
    const [tap,   setTap]   = mkState(false);
    const [hover, setHover] = mkState(false);
    const [foc,   setFoc]   = mkState(false);

    // Disabled elements never animate — feels more correct than a
    // jiggle-on-press that does nothing.
    const allowMotion = !reduced && !disabled;
    const active = allowMotion ? (tap ? whileTap : foc ? whileFocus : hover ? whileHover : null) : null;
    const xform = styleToTransform(active);
    const dur   = (transition && transition.duration != null) ? transition.duration : 0.18;
    const ease  = (transition && transition.ease) ? transition.ease : SPRING.std;

    const mergedStyle = {
      ...style,
      transform: xform || (style && style.transform) || undefined,
      transition: reduced
        ? 'none'
        : ('transform ' + dur + 's ' + ease +
           ', background-color .14s ease' +
           ', box-shadow .18s ease' +
           ', color .14s ease' +
           ', opacity .18s ease' +
           ', border-color .14s ease'),
      willChange: active ? 'transform' : (style && style.willChange) || undefined,
    };

    return mkH(Tag, {
      ref,
      disabled,
      ...rest,
      style: mergedStyle,
      onPointerDown: (e) => { if (!disabled) setTap(true);  onPointerDown && onPointerDown(e); },
      onPointerUp:   (e) => { setTap(false); onPointerUp && onPointerUp(e); },
      onPointerCancel: (e) => { setTap(false); onPointerCancel && onPointerCancel(e); },
      onPointerEnter:  (e) => { if (!disabled) setHover(true);  onPointerEnter && onPointerEnter(e); },
      onPointerLeave:  (e) => { setHover(false); setTap(false); onPointerLeave && onPointerLeave(e); },
      onFocus:         (e) => { setFoc(true);   onFocus && onFocus(e); },
      onBlur:          (e) => { setFoc(false);  onBlur && onBlur(e); },
    });
  });
  Comp.displayName = 'M.' + Tag;
  return Comp;
}

const M = {
  div:     makeMotion('div'),
  button:  makeMotion('button'),
  a:       makeMotion('a'),
  li:      makeMotion('li'),
  span:    makeMotion('span'),
  section: makeMotion('section'),
  nav:     makeMotion('nav'),
};

// ── AnimatePresence (single-child) ──────────────────────────────────────
function AnimatePresence({ children, exitDuration = 240, onExitComplete }) {
  const incoming = mkChildren.toArray(children).find(Boolean) || null;
  const [shown,  setShown]  = mkState(incoming);
  const [phase,  setPhase]  = mkState(incoming ? 'enter' : null);
  const timerRef = mkRef(null);
  const reduced  = useReducedMotion();
  const effExit  = reduced ? 0 : exitDuration;

  mkLayoutEffect(() => {
    // Enter from nothing
    if (incoming && !shown) {
      setShown(incoming); setPhase('enter'); return;
    }
    // Exit to nothing
    if (!incoming && shown && phase !== 'exit') {
      setPhase('exit');
      timerRef.current = setTimeout(() => {
        setShown(null); setPhase(null); onExitComplete && onExitComplete();
      }, effExit);
      return () => clearTimeout(timerRef.current);
    }
    // Key swap (e.g. one sheet replacing another) — we treat as
    // straight re-mount; smooth cross-fade is not common in this app.
    if (incoming && shown && incoming.key !== shown.key) {
      setShown(incoming); setPhase('enter'); return;
    }
    // Same key, props update
    if (incoming && shown) setShown(incoming);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [incoming && incoming.key, incoming]);

  mkEffect(() => () => clearTimeout(timerRef.current), []);

  if (!shown) return null;
  return mkIsValid(shown) ? mkClone(shown, { 'data-anim-state': phase }) : shown;
}

// ── Stagger — sequential entrance for direct children ───────────────────
function Stagger({ children, delay = 50, initialDelay = 0, distance = 8, className, style }) {
  const reduced = useReducedMotion();
  const items = mkChildren.toArray(children).filter((c) => c != null);
  return mkH('div', { className, style },
    ...items.map((child, i) => {
      if (!mkIsValid(child)) return child;
      const merged = {
        ...(child.props.style || {}),
        ...(reduced ? {} : {
          animation: 'vital-stagger-in 360ms ' + SPRING.std + ' both',
          animationDelay: (initialDelay + i * delay) + 'ms',
          ['--vital-stagger-dist']: distance + 'px',
        }),
      };
      return mkClone(child, { key: child.key != null ? child.key : i, style: merged });
    })
  );
}

// ── Skeleton — token-aware shimmer ──────────────────────────────────────
function Skeleton({ w = '100%', h = 14, r = 8, style, className }) {
  return mkH('div', {
    className: 'vital-skel' + (className ? ' ' + className : ''),
    style: { width: w, height: h, borderRadius: r, ...(style || {}) },
    'aria-hidden': true,
  });
}

// ── Spinner ─────────────────────────────────────────────────────────────
function Spinner({ size = 14, color = 'currentColor', style }) {
  return mkH('span', {
    className: 'vital-spinner',
    style: {
      width:  size,
      height: size,
      borderColor: 'color-mix(in oklch, ' + color + ' 22%, transparent)',
      borderTopColor: color,
      ...(style || {}),
    },
    'aria-hidden': true,
    role: 'status',
  });
}

// ── LoadingButton ───────────────────────────────────────────────────────
// Wraps M.button. Disables itself + shows a spinner when isLoading; sets
// aria-busy so screen readers announce the wait. Never fires onClick
// when loading or disabled — that's the central debounce.
function LoadingButton({
  isLoading, disabled,
  leftIcon, loadingLabel,
  children,
  className = 'btn btn-primary',
  onClick,
  ...rest
}) {
  const blocked = !!(isLoading || disabled);
  return mkH(M.button, {
    ...rest,
    className,
    disabled: blocked,
    'aria-busy': !!isLoading || undefined,
    whileTap:   blocked ? null : { scale: 0.97 },
    whileHover: blocked ? null : { y: -1 },
    transition: { duration: 0.16, ease: SPRING.bounce },
    onClick: (e) => { if (blocked) { e.preventDefault(); return; } onClick && onClick(e); },
    style: {
      ...(rest.style || {}),
      opacity:  blocked && !isLoading ? 0.55 : 1,
      cursor:   blocked ? 'not-allowed' : 'pointer',
    },
  },
    isLoading
      ? mkH(Spinner, { size: 15, color: 'currentColor' })
      : (leftIcon || null),
    mkH('span', null, isLoading ? (loadingLabel || children) : children)
  );
}

// ── showToast — imperative bottom toast (uses existing .vital-toast) ────
function showToast(message, opts) {
  opts = opts || {};
  const el = document.createElement('div');
  el.className = 'vital-toast';
  if (opts.tone === 'danger')  el.classList.add('is-danger');
  if (opts.tone === 'success') el.classList.add('is-success');
  el.textContent = message;
  document.body.appendChild(el);
  const duration = opts.duration != null ? opts.duration : 1600;
  setTimeout(() => {
    el.classList.add('is-out');
    setTimeout(() => el.remove(), 240);
  }, duration);
}

// ── exports ─────────────────────────────────────────────────────────────
Object.assign(window, {
  M,
  AnimatePresence,
  Stagger,
  Skeleton,
  Spinner,
  LoadingButton,
  showToast,
  useReducedMotion,
  VITAL_SPRING: SPRING,
});
