/* ==========================================================================
   Niwa Chatbot -- Morph Controller
   ---------------------------------------------------------------------------
   Manages the morph open/close transitions for both side-morph and
   centerstage modes. Handles pill click, close button, Escape key,
   click outside, textarea auto-resize, send button state, and badge.

   CRITICAL TIMING (CSS-driven):
     Open/close: width, height and radius morph together for 360ms.

   NAMESPACE: window.Niwa.MorphController
   ========================================================================== */

window.Niwa = window.Niwa || {};

window.Niwa.MorphController = (function () {
  'use strict';

  var _isOpen = false;
  var _mode = 'morph'; // 'morph' or 'center'
  var _position = 'br';
  var TEXTAREA_MIN_HEIGHT = 22;
  var TEXTAREA_MAX_HEIGHT = 96;
  var TEXTAREA_LINE_HEIGHT = 22;
  var TEXTAREA_VERTICAL_PADDING = 8;
  var MULTILINE_THRESHOLD = 4;
  var PHONE_LAYOUT_QUERY = '(max-width: 480px)';
  var MOBILE_FULLSCREEN_QUERY = '(max-width: 782px)';
  var TABLET_LAYOUT_QUERY = '(max-width: 1024px)';
  var PAGE_SCROLL_LOCK_CLASS = 'niwa-mobile-morph-open';
  var VIEWPORT_RESIZE_CLASS = 'niwa-morph--viewport-resizing';
  var _visualViewportFrame = 0;
  var _visualViewportSettleTimer = 0;
  var _visualViewportHeight = 0;

  // Per-device launcher settings (passed from niwa-app.js).
  var _launcherDesktop = { position: 'br' };
  var _launcherTablet  = { position: 'br' };
  var _launcherMobile  = { position: 'br' };
  var _adminMode = false;
  var _launcherText = '';
  var _launcherMobileText = '';
  var _launcherAutoFit = false;
  var _assistantDisplayName = '';
  var _labelObserver = null;
  var _textMeasureCanvas = null;
  var _transitionToken = 0;

  // Element references
  var morphEl = null;
  var pillEl = null;
  var closeBtn = null;
  var textarea = null;
  var sendBtn = null;
  var badgeEl = null;

  // CSS class prefixes vary by mode
  var _prefix = 'niwa-morph';

  function init(config) {
    config = config || {};
    _mode = config.mode || 'morph';
    _position = config.position || 'br';
    _prefix = _mode === 'center' ? 'niwa-center' : 'niwa-morph';

    // Store per-device launcher settings for responsive handling.
    _launcherDesktop = config.launcher_desktop || { position: _position };
    _launcherTablet  = config.launcher_tablet  || { position: _position };
    _launcherMobile  = config.launcher_mobile  || { position: _position };
    _adminMode = !!config.admin_mode;
    _assistantDisplayName = (config.assistant_display_name || '').trim();

    // Find elements
    if (_mode === 'center') {
      morphEl = document.getElementById('niwaCenter');
      pillEl = document.getElementById('niwaCenterPill');
      closeBtn = morphEl ? morphEl.querySelector('.niwa-center__close') : null;
      textarea = document.getElementById('niwaCenterInput');
      sendBtn = document.getElementById('niwaCenterSend');
      badgeEl = document.getElementById('niwaCenterBadge');
    } else {
      // Right or left morph
      var isLeft = (_position === 'bl');
      if (isLeft) {
        morphEl = document.getElementById('niwaMorphLeft');
        pillEl = document.getElementById('niwaChatheadLeft');
        textarea = document.getElementById('niwaChatheadInputLeft');
        sendBtn = document.getElementById('niwaChatheadSendLeft');
        badgeEl = document.getElementById('niwaBadgeLeft');
      } else {
        morphEl = document.getElementById('niwaMorph');
        pillEl = document.getElementById('niwaChathead');
        textarea = document.getElementById('niwaChatheadInput');
        sendBtn = document.getElementById('niwaChatheadSend');
        badgeEl = document.getElementById('niwaBadge');
      }
      closeBtn = morphEl ? morphEl.querySelector('.niwa-morph__close') : null;
    }

    if (!morphEl) return;

    _hydrateLauncherCopy(config);
    _bindLauncherLabelObserver();

    // Bind pill click -> open
    if (pillEl) {
      pillEl.addEventListener('click', function (e) {
        // Only open if clicking on the pill area and not already open
        if (!_isOpen) {
          e.preventDefault();
          e.stopPropagation();
          open('launcher_click');
        }
      });
    }

    // Bind close button -> close
    if (closeBtn) {
      closeBtn.addEventListener('click', function (e) {
        e.preventDefault();
        e.stopPropagation();
        close();
      });
    }

    // Bind Escape key -> close
    document.addEventListener('keydown', function (e) {
      if (e.key === 'Escape' && _isOpen) close();
    });

    // Bind click outside -> close
    document.addEventListener('mousedown', function (e) {
      if (_isOpen && morphEl && !morphEl.contains(e.target)) {
        close();
      }
    });

    // Textarea auto-resize + send button state
    if (textarea && sendBtn) {
      textarea.addEventListener('input', function () {
        _syncTextareaLayout();
        _syncSendButtonState();
      });

      _syncTextareaLayout();
      _syncSendButtonState();
    }

    // Mobile fullscreen support — also syncs the collapsed launcher height so
    // admin-configured per-device heights are honored. Width is now content-
    // based in CSS (`width: max-content`) so no JS measurement is required.
    _handleResize();
    window.addEventListener('resize', _debounce(_handleResize, 150));
    _syncCollapsedLauncherMetrics(_getActiveLauncher());

    // Visual viewport listener — keeps every open mobile chat pinned to the
    // visible area when mobile WordPress or the on-screen keyboard pans it.
    // Without this, the fixed element stays at inset:0 of the layout viewport.
    if (window.visualViewport) {
      window.visualViewport.addEventListener('resize', _onVisualViewportResize);
      window.visualViewport.addEventListener('scroll', _onVisualViewportResize);
    }
  }

  function open(source) {
    if (!morphEl) return;
    var transitionToken = ++_transitionToken;
    _isOpen = true;

    if (source === 'launcher_click' && window.Niwa && window.Niwa.Utils && typeof window.Niwa.Utils.trackFrontendEvent === 'function') {
      window.Niwa.Utils.trackFrontendEvent('chathead_open', {
        source: 'chathead',
        meta: {
          launcher: morphEl.id || 'niwaMorph'
        }
      });
    }

    // Lock the current pill width as an inline px value so the
    // max-content → flyout-width transition animates cleanly.
    if (_mode !== 'center') {
      _lockWidthForTransition();
    }

    // Remove closing state, add open state
    morphEl.classList.remove(_prefix + '--closing');
    morphEl.classList.add(_prefix + '--open');
    _dispatchLifecycleEvent('open', source);


    // Clear the inline width after the class is committed so the
    // CSS `.niwa-morph--open { width: var(--niwa-flyout-width) }`
    // rule drives the width transition from the locked starting value.
    if (_mode !== 'center') {
      _unlockWidthAfterFrame();
    }


    _syncPageScrollLock();

    // Clear badge
    if (badgeEl) {
      badgeEl.textContent = '';
      badgeEl.classList.remove(_prefix + '__badge--visible');
    }

    if (window.Niwa && window.Niwa.MessageRenderer && window.Niwa.MessageRenderer.markLatestBotAsSeen) {
      window.Niwa.MessageRenderer.markLatestBotAsSeen();
    }

    // Remove CB suppression
    try { sessionStorage.removeItem('niwa_ai_cb_suppress'); } catch (e) { /* ignore */ }

    // Focus textarea after the container morph completes.
    var focusDelay = 360;
    setTimeout(function () {
      if (!_isOpen || transitionToken !== _transitionToken) return;
      if (_isMobileViewport() && window.visualViewport) {
        _onVisualViewportResize();
      }
      if (textarea) {
        _syncTextareaLayout();
        _syncSendButtonState();
        textarea.focus();
      }
      _dispatchLifecycleEvent('opened', source);
    }, focusDelay);
  }

  function close() {
    if (!morphEl || !_isOpen) return;
    _dispatchLifecycleEvent('before-close');
    var transitionToken = ++_transitionToken;
    _isOpen = false;

    _syncPageScrollLock();

    // Remove visual viewport overrides so the element returns to CSS-driven position.
    _clearVisualViewportOverride();

    // Lock the current expanded width as an inline px value so the
    // flyout-width → max-content transition animates cleanly.
    if (_mode !== 'center') {
      _lockWidthForTransition(true);
    }

    // Add closing state, remove open state
    morphEl.classList.add(_prefix + '--closing');
    morphEl.classList.remove(_prefix + '--open');
    _dispatchLifecycleEvent('close');

    // Blur textarea
    if (textarea) textarea.blur();

    // Clear inline width next frame so CSS max-content takes over and
    // the width transition animates from the locked expanded value.
    if (_mode !== 'center') {
      _unlockWidthAfterFrame(true);
    }

    // Keep a rapid close/open/close sequence from letting an older timer
    // remove the current closing state early.
    setTimeout(function () {
      if (!_isOpen && transitionToken === _transitionToken && morphEl) {
        morphEl.classList.remove(_prefix + '--closing');
        morphEl.style.maxWidth = '';
      }
    }, 360);
  }

  function toggle() {
    if (_isOpen) {
      close();
    } else {
      open();
    }
  }

  function isOpen() {
    return _isOpen;
  }

  function getTextarea() {
    return textarea;
  }

  function getSendBtn() {
    return sendBtn;
  }

  function syncTextareaLayout() {
    _syncTextareaLayout();
  }

  /* -- Badge management --------------------------------------------------- */

  function showBadge(count) {
    if (!badgeEl) return;
    badgeEl.textContent = String(count);
    badgeEl.classList.add(_prefix + '__badge--visible');
  }

  function clearBadge() {
    if (!badgeEl) return;
    badgeEl.textContent = '';
    badgeEl.classList.remove(_prefix + '__badge--visible');
  }

  function _bindLauncherLabelObserver() {
    if (_labelObserver) {
      _labelObserver.disconnect();
      _labelObserver = null;
    }

    if (_mode === 'center' || !window.MutationObserver || !morphEl) return;

    var labelEl = morphEl.querySelector('.niwa-morph__label');
    if (!labelEl) return;

    _labelObserver = new MutationObserver(function () {
      _syncCollapsedLauncherMetrics(_getActiveLauncher());
    });

    _labelObserver.observe(labelEl, {
      characterData: true,
      childList: true,
      subtree: true
    });
  }

  function _syncTextareaLayout() {
    if (!textarea) return;

    _applyTextareaGuards();

    var multilineClass = _prefix + '--multiline';
    var hasText = textarea.value.trim().length > 0;
    if (!hasText) {
      textarea.style.setProperty('height', TEXTAREA_MIN_HEIGHT + 'px', 'important');
      textarea.style.setProperty('overflow-y', 'hidden', 'important');
      if (morphEl) {
        morphEl.classList.remove(multilineClass);
      }
      return;
    }

    // Measure in border-box units so one visual line stays compact instead of
    // growing by padding on every sync.
    textarea.style.setProperty('height', TEXTAREA_MIN_HEIGHT + 'px', 'important');

    var measuredHeight = Math.min(
      Math.max(textarea.scrollHeight, TEXTAREA_MIN_HEIGHT),
      TEXTAREA_MAX_HEIGHT
    );
    var singleLineMeasuredHeight = TEXTAREA_LINE_HEIGHT + TEXTAREA_VERTICAL_PADDING;
    var isMultiline = measuredHeight > (singleLineMeasuredHeight + MULTILINE_THRESHOLD);
    var nextHeight = isMultiline ? measuredHeight : TEXTAREA_MIN_HEIGHT;

    textarea.style.setProperty('height', nextHeight + 'px', 'important');
    textarea.style.setProperty('overflow-y', nextHeight >= TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden', 'important');
    if (morphEl) {
      morphEl.classList.toggle(multilineClass, isMultiline);
    }
  }

  function _applyTextareaGuards() {
    if (!textarea) return;

    // Frontend themes often inject textarea min-heights, box models, and
    // padding with higher precedence than component CSS. Lock the layout-
    // critical values inline so admin and frontend behave identically.
    textarea.style.setProperty('display', 'block', 'important');
    textarea.style.setProperty('width', '100%', 'important');
    textarea.style.setProperty('min-width', '0', 'important');
    textarea.style.setProperty('box-sizing', 'border-box', 'important');
    textarea.style.setProperty('font-family', 'var(--niwa-font-family)', 'important');
    // iOS Safari auto-zooms the viewport when a focused field is < 16px, which
    // in mobile fullscreen pushes the chat out of frame. Use 16px on the mobile
    // breakpoint to suppress that zoom; desktop keeps the 14px design.
    textarea.style.setProperty('font-size', _matchesMedia(MOBILE_FULLSCREEN_QUERY) ? '16px' : '14px', 'important');
    textarea.style.setProperty('font-weight', '400', 'important');
    textarea.style.setProperty('min-height', TEXTAREA_MIN_HEIGHT + 'px', 'important');
    textarea.style.setProperty('max-height', TEXTAREA_MAX_HEIGHT + 'px', 'important');
    textarea.style.setProperty('line-height', TEXTAREA_LINE_HEIGHT + 'px', 'important');
    textarea.style.setProperty('letter-spacing', '-0.14px', 'important');
    textarea.style.setProperty('padding', '4px 8px', 'important');
    textarea.style.setProperty('margin', '0', 'important');
    textarea.style.setProperty('border', 'none', 'important');
    textarea.style.setProperty('border-radius', '0', 'important');
    textarea.style.setProperty('background', 'transparent', 'important');
    textarea.style.setProperty('box-shadow', 'none', 'important');
    textarea.style.setProperty('outline', 'none', 'important');
    textarea.style.setProperty('resize', 'none', 'important');
    textarea.style.setProperty('overflow-x', 'hidden', 'important');
  }

  function _syncSendButtonState() {
    if (!textarea || !sendBtn) return;

    var hasText = textarea.value.trim().length > 0;
    var disabledClass = _prefix + '__send--disabled';
    sendBtn.classList.toggle(disabledClass, !hasText);
  }

  /* -- Responsive positioning + mobile fullscreen -------------------------- */

  function _handleResize() {
    if (!morphEl) return;
    var isMobile  = _matchesMedia(PHONE_LAYOUT_QUERY);
    var isFullscreenViewport = _matchesMedia(MOBILE_FULLSCREEN_QUERY);
    var isTablet  = !isMobile && _matchesMedia(TABLET_LAYOUT_QUERY);
    var mobileFullscreen = window.NiwaAI && window.NiwaAI.mobile && window.NiwaAI.mobile.fullscreen;

    // Determine active launcher settings for current breakpoint.
    var activeLauncher = _getActiveLauncher(isMobile, isTablet);

    // Toggle left/right class based on active position (morph mode only).
    if (_mode !== 'center') {
      var isLeft = (activeLauncher.position === 'bl' || activeLauncher.position === 'tl');
      var isTop = (activeLauncher.position === 'tr' || activeLauncher.position === 'tl');
      morphEl.classList.toggle('niwa-morph--left', isLeft);
      morphEl.classList.toggle('niwa-morph--top', isTop);
      _syncLauncherLabel(isMobile);
    }

    // Mobile classes.
    // Phone layout still uses the narrow breakpoint; fullscreen gets the wider
    // WP mobile breakpoint so large phones do not fall back to tablet flyout size.
    var shouldFullscreen = mobileFullscreen || _mode === 'center';
    if (isMobile) {
      morphEl.classList.add(_prefix + '--mobile');
    } else {
      morphEl.classList.remove(_prefix + '--mobile');
    }

    morphEl.classList.toggle('niwa-morph--fullscreen', isFullscreenViewport && shouldFullscreen);

    // Tablet class (useful for CSS hooks).
    morphEl.classList.toggle(_prefix + '--tablet', isTablet);
    _syncCollapsedLauncherMetrics(activeLauncher);
    _syncPageScrollLock();

    if (_isOpen && _isMobileViewport() && window.visualViewport) {
      _onVisualViewportResize();
    } else if (_isOpen) {
      _clearVisualViewportOverride();
    }
  }

  function _getActiveLauncher(isMobile, isTablet) {
    if (typeof isMobile !== 'boolean') {
      isMobile = _matchesMedia(PHONE_LAYOUT_QUERY);
    }

    if (typeof isTablet !== 'boolean') {
      isTablet = !isMobile && _matchesMedia(TABLET_LAYOUT_QUERY);
    }

    if (isMobile) return _launcherMobile || _launcherDesktop;
    if (isTablet) return _launcherTablet || _launcherDesktop;
    return _launcherDesktop;
  }

  function _hydrateLauncherCopy(config) {
    config = config || {};

    var desktopText = (config.launcher_text || '').trim();
    var mobileText = (config.launcher_text_mobile || '').trim();
    var autoFit = !!config.launcher_auto_fit;
    var pill = pillEl || (morphEl ? morphEl.querySelector('.niwa-morph__pill') : null);

    if (pill && pill.dataset) {
      desktopText = (pill.dataset.launcherText || desktopText).trim();
      mobileText = (pill.dataset.launcherTextMobile || mobileText).trim();
      autoFit = autoFit || pill.dataset.launcherAutoFit === '1';
    }

    _launcherText = desktopText;
    _launcherMobileText = mobileText || desktopText;
    _launcherAutoFit = autoFit;
  }

  function _syncLauncherLabel(isMobile) {
    if (!morphEl || _mode === 'center') return;

    var pill = pillEl || morphEl.querySelector('.niwa-morph__pill');
    var labelEl = pill ? pill.querySelector('.niwa-morph__label') : null;
    var nextText = _resolveLauncherLabelText(isMobile);

    if (!labelEl || !nextText) return;
    if ((labelEl.textContent || '').trim() === nextText) return;

    labelEl.textContent = nextText;
  }

  function _resolveLauncherLabelText(isMobile) {
    if (isMobile && _launcherMobileText) {
      return _launcherMobileText;
    }

    return _launcherText;
  }

  function _syncCollapsedLauncherMetrics(activeLauncher) {
    if (!morphEl || _mode === 'center') return;

    activeLauncher = activeLauncher || _getActiveLauncher();

    // Height: keep per-device launcher height from admin settings.
    var baseHeight = Math.max(0, parseInt(activeLauncher && activeLauncher.h, 10) || 48);
    morphEl.style.setProperty('--niwa-launcher-height', baseHeight + 'px');

    // Width: PHP pre-calculates pill widths for desktop AND mobile label text
    // and exposes them on the pill as data-launcher-width-desktop /
    // data-launcher-width-mobile (see class-chat-renderer.php ->
    // estimate_launcher_pill_width). Use those values so JS width matches the
    // SSR value for the currently-active label — which prevents any flash
    // when we swap between desktop and mobile copy on resize.
    //
    // If the data attributes aren't present (older markup), keep whatever
    // width PHP emitted as the inline `--niwa-launcher-width` and don't
    // overwrite it with admin's `w` setting — the admin `w` value was the
    // source of the old "flash from default width to content width" bug.
    var pill = pillEl || morphEl.querySelector('.niwa-morph__pill');
    var isMobile = _matchesMedia(PHONE_LAYOUT_QUERY);
    var desktopWidth = pill && pill.dataset ? parseInt(pill.dataset.launcherWidthDesktop, 10) : NaN;
    var mobileWidth = pill && pill.dataset ? parseInt(pill.dataset.launcherWidthMobile, 10) : NaN;
    var nextWidth = isMobile && !isNaN(mobileWidth) ? mobileWidth : desktopWidth;

    if (!isNaN(nextWidth) && nextWidth > 0) {
      morphEl.style.setProperty('--niwa-launcher-width', nextWidth + 'px');
    }
  }

  function _matchesMedia(query) {
    return !!(window.matchMedia && window.matchMedia(query).matches);
  }

  function _isFullscreenActive() {
    return !!morphEl && morphEl.classList.contains('niwa-morph--fullscreen');
  }

  function _isMobileViewport() {
    return _matchesMedia(MOBILE_FULLSCREEN_QUERY);
  }

  function _syncPageScrollLock() {
    var shouldLock = _isOpen && _isMobileViewport();
    document.documentElement.classList.toggle(PAGE_SCROLL_LOCK_CLASS, shouldLock);
    document.body.classList.toggle(PAGE_SCROLL_LOCK_CLASS, shouldLock);
  }

  // Called on visualViewport resize/scroll — keeps fullscreen and sized mobile
  // chat inside the visible area on both axes while the keyboard is open.
  function _onVisualViewportResize() {
    if (!_isOpen || !morphEl || !_isMobileViewport()) return;
    if (_visualViewportFrame) return;

    _visualViewportFrame = window.requestAnimationFrame(function () {
      _visualViewportFrame = 0;
      _syncVisualViewportGeometry();
    });
  }

  function _syncVisualViewportGeometry() {
    if (!_isOpen || !morphEl || !_isMobileViewport()) return;
    var vv = window.visualViewport;
    if (!vv) return;
    var left = vv.offsetLeft;
    var top = vv.offsetTop;
    var width = vv.width;
    var height = vv.height;

    if (!_isFullscreenActive()) {
      var mobile = window.NiwaAI && window.NiwaAI.mobile ? window.NiwaAI.mobile : {};
      var widthPercent = Math.min(100, Math.max(1, parseFloat(mobile.w) || 95)) / 100;
      var heightPercent = Math.min(100, Math.max(1, parseFloat(mobile.h) || 95)) / 100;
      width = vv.width * widthPercent;
      height = vv.height * heightPercent;
      left = vv.offsetLeft + ((vv.width - width) / 2);
      top = vv.offsetTop + ((vv.height - height) / 2);
    }

    var shouldPinLatest = _visualViewportHeight > height + 1;
    _visualViewportHeight = height;
    morphEl.classList.add(VIEWPORT_RESIZE_CLASS);
    morphEl.style.setProperty('left', Math.round(left) + 'px', 'important');
    morphEl.style.setProperty('top', Math.round(top) + 'px', 'important');
    morphEl.style.setProperty('width', Math.round(width) + 'px', 'important');
    morphEl.style.setProperty('height', Math.round(height) + 'px', 'important');
    if (_mode === 'center') {
      morphEl.style.setProperty('transform', 'none', 'important');
    }
    if (shouldPinLatest && window.Niwa && window.Niwa.ScrollBehavior) {
      window.Niwa.ScrollBehavior.scrollToBottom(
        morphEl.querySelector('.niwa-morph__body, .niwa-center__body')
      );
    }

    clearTimeout(_visualViewportSettleTimer);
    _visualViewportSettleTimer = setTimeout(function () {
      if (morphEl) morphEl.classList.remove(VIEWPORT_RESIZE_CLASS);
    }, 80);
  }

  function _clearVisualViewportOverride() {
    if (!morphEl) return;
    if (_visualViewportFrame) {
      window.cancelAnimationFrame(_visualViewportFrame);
      _visualViewportFrame = 0;
    }
    clearTimeout(_visualViewportSettleTimer);
    _visualViewportSettleTimer = 0;
    _visualViewportHeight = 0;
    morphEl.classList.remove(VIEWPORT_RESIZE_CLASS);
    morphEl.style.removeProperty('left');
    morphEl.style.removeProperty('top');
    morphEl.style.removeProperty('width');
    morphEl.style.removeProperty('height');
    morphEl.style.removeProperty('transform');
  }

  function _shouldAutoFitLauncherLabel(labelText) {
    if (_adminMode) {
      if (!_launcherText || !_assistantDisplayName) return false;
      if (_launcherText !== _assistantDisplayName) return false;
      return labelText === _assistantDisplayName;
    }

    if (_launcherAutoFit && (labelText === _launcherText || labelText === _launcherMobileText)) {
      return true;
    }

    return !!(_launcherMobileText && _launcherMobileText !== _launcherText && labelText === _launcherMobileText);
  }

  function _measureRenderedLabelWidth(labelEl) {
    if (!labelEl) return 0;

    var scrollWidth = Math.ceil(labelEl.scrollWidth || 0);
    if (scrollWidth > 0) {
      return scrollWidth;
    }

    var rect = labelEl.getBoundingClientRect();
    return Math.ceil((rect && rect.width) || 0);
  }

  function _measureTextWidth(text, style) {
    if (!_textMeasureCanvas) {
      _textMeasureCanvas = document.createElement('canvas');
    }

    var ctx = _textMeasureCanvas.getContext('2d');
    if (!ctx) return 0;

    var font = style.font && style.font !== ''
      ? style.font
      : [
          style.fontStyle,
          style.fontVariant,
          style.fontWeight,
          style.fontSize,
          style.fontFamily
        ].join(' ');

    ctx.font = font;

    var width = ctx.measureText(text).width;
    var letterSpacing = _readPx(style.letterSpacing);

    if (letterSpacing !== 0) {
      width += letterSpacing * Math.max(text.length - 1, 0);
    }

    return width;
  }

  function _readSpacing(primary, fallback) {
    var value = _readPx(primary);
    if (value > 0) return value;
    return _readPx(fallback);
  }

  function _readPx(value) {
    var parsed = parseFloat(value);
    return isNaN(parsed) ? 0 : parsed;
  }

  /* -- Debounce utility --------------------------------------------------- */

  /**
   * Bridge CSS intrinsic sizing (`width: max-content`) to a numeric pixel
   * value so the width transition can animate on open/close. CSS can't
   * interpolate between `max-content` and a pixel value, so we briefly
   * lock the current measured width as inline px, force a reflow, and
   * after the class toggle clear the inline value so the CSS target
   * width (flyout on open, max-content on close) takes over and the
   * transition fires from the committed pixel starting point.
   */
  function _lockWidthForTransition(lockMaxWidth) {
    if (!morphEl) return 0;
    var px = Math.round(morphEl.getBoundingClientRect().width);
    morphEl.style.width = px + 'px';
    if (lockMaxWidth) morphEl.style.maxWidth = px + 'px';
    // Read a layout prop to force the browser to commit the inline width.
    // eslint-disable-next-line no-unused-expressions
    morphEl.offsetWidth;
    return px;
  }

  function _unlockWidthAfterFrame(keepMaxWidth) {
    if (!morphEl) return;
    var cleared = false;
    var clear = function () {
      if (cleared) return;
      cleared = true;
      if (morphEl && !(_isOpen && _isMobileViewport() && window.visualViewport)) {
        morphEl.style.width = '';
        if (!keepMaxWidth) morphEl.style.maxWidth = '';
      }
    };
    // Prefer rAF so the clear happens right after the class-change paint.
    // Fall back (also) to a short setTimeout for environments where rAF is
    // throttled (headless browsers, background tabs) so the inline pixel
    // width never gets stuck and the CSS target width drives the transition.
    if (typeof window.requestAnimationFrame === 'function') {
      window.requestAnimationFrame(clear);
    }
    setTimeout(clear, 32);
  }

  function _dispatchLifecycleEvent(state, source) {
    if (!morphEl || !window.dispatchEvent) return;

    var detail = {
      state: state,
      source: source || '',
      mode: _mode,
      id: morphEl.id || ''
    };

    try {
      window.dispatchEvent(new CustomEvent('niwa:morph:' + state, { detail: detail }));
    } catch (e) {
      try {
        var event = document.createEvent('CustomEvent');
        event.initCustomEvent('niwa:morph:' + state, false, false, detail);
        window.dispatchEvent(event);
      } catch (ignore) { /* ignore */ }
    }
  }

  function _debounce(fn, delay) {
    var timer = null;
    return function () {
      var ctx = this;
      var args = arguments;
      if (timer) clearTimeout(timer);
      timer = setTimeout(function () { fn.apply(ctx, args); }, delay);
    };
  }

  /* -- Public API --------------------------------------------------------- */

  return {
    init: init,
    open: open,
    close: close,
    toggle: toggle,
    isOpen: isOpen,
    getTextarea: getTextarea,
    getSendBtn: getSendBtn,
    syncTextareaLayout: syncTextareaLayout,
    showBadge: showBadge,
    clearBadge: clearBadge
  };
})();
