import { defineTrait } from '../define.js';
import { prefersReducedMotion } from '../motion.js';

/**
 * `error-shake` — horizontal lateral oscillation on validation failure.
 *
 * The de-facto "wrong password" affordance. Listens for the `validated`
 * event with `detail.valid === false` (fired by the `validation` trait),
 * watches the `data-validation-invalid` attribute for false→true
 * transitions, and supports a programmatic `data-error-shake-trigger`
 * attribute toggle so non-form contexts can fire shakes too.
 *
 * Composes naturally with `validation`:
 *   <input-ui traits="validation error-shake" data-validate="required, email">
 *
 * On trigger, the host shakes for ~400ms via a translateX-only keyframe
 * animation — never `margin`/`width`, so siblings don't reflow. The
 * keyframes run on the compositor and the keyframe `<style>` is owned
 * by the trait instance (not shared) so disconnect is clean.
 *
 * Reduced-motion: skip the animation entirely, but still mark
 * `data-error-shake-active` for 200ms before removing — gives consumer
 * stylesheets a CSS hook to swap in a static error-tint or border
 * without re-querying the media query themselves.
 */

const DEFAULT_AMPLITUDE = 8;     // px lateral travel
const DEFAULT_DURATION = 400;    // ms total
const REDUCED_MOTION_HOLD = 200; // ms — how long the marker sits in reduced-motion mode

export const errorShake = defineTrait({
  name: 'error-shake',
  category: 'animation-feedback',
  description: 'Horizontal lateral oscillation on validation failure',
  attributes: ['data-error-shake-active'],
  events: ['error-shake-done'],
  config: ['data-error-shake-amplitude', 'data-error-shake-duration', 'data-error-shake-trigger'],
  setup({ host }) {
    const activeTimers = new Set();
    let styleEl = null;
    let keyframeName = null;
    let lastInvalid = host.hasAttribute('data-validation-invalid');

    function readAmplitude() {
      const v = parseFloat(host.getAttribute('data-error-shake-amplitude'));
      return Number.isFinite(v) && v > 0 ? v : DEFAULT_AMPLITUDE;
    }

    function readDuration() {
      const v = parseInt(host.getAttribute('data-error-shake-duration'), 10);
      return Number.isFinite(v) && v > 0 ? v : DEFAULT_DURATION;
    }

    function ensureKeyframes(amp) {
      if (styleEl) styleEl.remove();
      keyframeName = `adia-error-shake-${Math.random().toString(36).slice(2, 8)}`;
      styleEl = document.createElement('style');
      // 4-cycle oscillation. translateX only — never margin/width — so
      // the host's siblings don't reflow during the shake.
      styleEl.textContent = `
        @keyframes ${keyframeName} {
          0%, 100% { transform: translateX(0); }
          15% { transform: translateX(-${amp}px); }
          30% { transform: translateX(${amp}px); }
          45% { transform: translateX(-${amp * 0.7}px); }
          60% { transform: translateX(${amp * 0.7}px); }
          75% { transform: translateX(-${amp * 0.4}px); }
          90% { transform: translateX(${amp * 0.4}px); }
        }
      `;
      document.head.appendChild(styleEl);
    }

    function fireDone() {
      host.removeAttribute('data-error-shake-active');
      host.dispatchEvent(new CustomEvent('error-shake-done', { bubbles: true }));
    }

    function reducedMotionShake() {
      host.setAttribute('data-error-shake-active', '');
      const t = setTimeout(() => {
        activeTimers.delete(t);
        fireDone();
      }, REDUCED_MOTION_HOLD);
      activeTimers.add(t);
    }

    function shake() {
      if (prefersReducedMotion()) {
        reducedMotionShake();
        return;
      }

      const amp = readAmplitude();
      const dur = readDuration();
      ensureKeyframes(amp);

      // Force-reset any in-flight animation so back-to-back triggers
      // restart cleanly (e.g. user mashes submit on an invalid form).
      host.style.animation = '';
      // Reading offsetWidth flushes the style change — the next assignment
      // is then guaranteed to be seen as a transition by the engine.
      void host.offsetWidth;
      host.style.animation = `${keyframeName} ${dur}ms ease-in-out`;
      host.setAttribute('data-error-shake-active', '');

      const t = setTimeout(() => {
        activeTimers.delete(t);
        host.style.animation = '';
        fireDone();
      }, dur);
      activeTimers.add(t);
    }

    function onValidated(e) {
      if (e?.detail && e.detail.valid === false) shake();
    }

    // Watch data-validation-invalid + data-error-shake-trigger for
    // attribute changes. validation trait sets data-validation-invalid
    // synchronously *before* dispatching `validated`, so the observer
    // and event listener can both fire — we de-dupe by tracking the
    // last-known invalid state and only triggering on false → true.
    const observer = new MutationObserver((muts) => {
      for (const m of muts) {
        if (m.attributeName === 'data-validation-invalid') {
          const nowInvalid = host.hasAttribute('data-validation-invalid');
          if (nowInvalid && !lastInvalid) shake();
          lastInvalid = nowInvalid;
        } else if (m.attributeName === 'data-error-shake-trigger') {
          // Toggle (any change) fires a shake. Empty-string and "" are
          // both treated as "present" — the convention for boolean attrs.
          if (host.hasAttribute('data-error-shake-trigger')) shake();
        }
      }
    });
    observer.observe(host, {
      attributes: true,
      attributeFilter: ['data-validation-invalid', 'data-error-shake-trigger'],
    });

    host.addEventListener('validated', onValidated);

    return () => {
      observer.disconnect();
      host.removeEventListener('validated', onValidated);
      for (const t of activeTimers) clearTimeout(t);
      activeTimers.clear();
      if (styleEl) {
        styleEl.remove();
        styleEl = null;
      }
      host.style.animation = '';
      host.removeAttribute('data-error-shake-active');
    };
  },
});
