/* ==========================================================================
   Niwa Chatbot -- Message Renderer
   ---------------------------------------------------------------------------
   Handles message rendering, typing indicator, suggestions, date separators,
   and chat history persistence.

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

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

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

  var Utils = null; // Lazy reference to Niwa.Utils
  var bodyEl = null;
  var historyCache = [];
  var saveTimeout = null;
  var STORAGE_KEY = '';
  var lastSenderRole = null;
  var avatarUrl = '';
  var lastSeenBotSignature = '';
  var lastRenderedDateStr = null;
  var DEFAULT_HISTORY_RENDER_LIMIT = 150;

  function _utils() {
    if (!Utils) Utils = window.Niwa && window.Niwa.Utils;
    return Utils;
  }

  function _storageKey() {
    if (!STORAGE_KEY) {
      STORAGE_KEY = (_utils() && _utils().STORAGE_KEY)
        || (window.NiwaAI && window.NiwaAI.storage_key)
        || 'niwa_ai_chat_history_v1';
    }
    return STORAGE_KEY;
  }

  function _getAvatarUrl() {
    if (!avatarUrl) {
      avatarUrl = (window.NiwaAI && window.NiwaAI.avatar_url)
        ? window.NiwaAI.avatar_url
        : ((window.NiwaAI && window.NiwaAI.default_avatar_url)
          ? window.NiwaAI.default_avatar_url
          : ((window.NiwaAI && window.NiwaAI.pluginURL)
            ? window.NiwaAI.pluginURL + 'assets/img/niwa-admin-personal-assistant.png'
            : ''));
    }
    return avatarUrl;
  }

  function _getLabels() {
    return (window.NiwaAI && window.NiwaAI.labels) || {};
  }

  function _getLanguage() {
    var lang = (window.NiwaAI && window.NiwaAI.language) || 'en';
    return String(lang || 'en').toLowerCase();
  }

  function _historyRenderLimit() {
    return DEFAULT_HISTORY_RENDER_LIMIT;
  }

  function _sliceDisplayHistory(entries) {
    var items = Array.isArray(entries) ? entries : [];
    var limit = _historyRenderLimit();
    return items.length > limit ? items.slice(-limit) : items;
  }

  function _normalizeCouponCode(code) {
    if (typeof code !== 'string' && typeof code !== 'number') return null;
    var normalized = String(code).trim().toUpperCase();
    return /^[A-Z0-9-]{4,20}$/.test(normalized) ? normalized : null;
  }

  function _extractCouponCodeFromText(text) {
    var t = String(text || '').replace(/\u00A0/g, ' ');
    var lang = _getLanguage().split(/[_-]/)[0];
    var patterns = {
      sr: [/\b(?:promo\s+kod|kod|kupon)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i],
      hr: [/\b(?:promo\s+kod|kod|kupon)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i],
      bs: [/\b(?:promo\s+kod|kod|kupon)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i],
      en: [/\b(?:discount\s+code|promo\s+code|coupon(?:\s+code)?|code)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i],
      de: [/\b(?:gutschein(?:code)?|rabattcode|code)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i],
      es: [/\b(?:c[oó]digo(?:\s+promocional)?|cup[oó]n)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i],
      fr: [/\b(?:code(?:\s+promo)?|coupon)\s*:\s*["“]?([A-Z0-9-]{4,20})["”]?\b/i]
    };
    var matchers = patterns[lang] || patterns.en;

    for (var i = 0; i < matchers.length; i++) {
      var match = t.match(matchers[i]);
      var code = _normalizeCouponCode(match && match[1] ? match[1] : '');
      if (code) return code;
    }

    return null;
  }

  function _scrollToBottom() {
    if (bodyEl && window.Niwa && window.Niwa.ScrollBehavior) {
      window.Niwa.ScrollBehavior.scrollToBottom(bodyEl);
    } else if (bodyEl) {
      bodyEl.scrollTop = bodyEl.scrollHeight;
    }
  }

  function _scrollNodeToTop(node) {
    if (!bodyEl || !node) return;

    var scroll = function () {
      var bodyRect = bodyEl.getBoundingClientRect();
      var nodeRect = node.getBoundingClientRect();
      bodyEl.scrollTop += nodeRect.top - bodyRect.top;
    };

    requestAnimationFrame(scroll);
    setTimeout(scroll, 50);
    setTimeout(scroll, 150);
    setTimeout(scroll, 300);
  }

  /* -- Init --------------------------------------------------------------- */

  function init(el) {
    bodyEl = el;
    historyCache = [];
    lastSenderRole = null;
    lastRenderedDateStr = null;
    lastSeenBotSignature = _readLastSeenBotSignature();
  }

  /* -- Add Message -------------------------------------------------------- */

  /**
   * Add a message to the chat.
   * @param {string} text - Message content
   * @param {string} role - 'user' or 'bot'
   * @param {Object} [opts] - Options: { source, skipSave, timestamp, forceNewGroup, requestId }
   */
  function addMessage(text, role, opts) {
    if (!bodyEl || !text) return;
    opts = opts || {};
    var u = _utils();

    var isContinuation = !opts.forceNewGroup && (lastSenderRole === role);
    lastSenderRole = role;

    var timestamp = opts.timestamp || Date.now();
    var timeStr = u ? u.formatTime(new Date(timestamp)) : '';
    var dateStr = u ? u.formatDate(new Date(timestamp)) : null;
    if (lastRenderedDateStr && dateStr && dateStr !== lastRenderedDateStr) {
      addDateSeparator(dateStr);
    } else if (!lastRenderedDateStr && dateStr) {
      lastRenderedDateStr = dateStr;
    }

    // Build message HTML
    var msgDiv = document.createElement('div');
    msgDiv.className = 'niwa-message niwa-message--' + role;
    if (isContinuation) {
      msgDiv.classList.add('niwa-message--continuation');
    }
    if (opts.source) {
      msgDiv.setAttribute('data-source', opts.source);
    }
    if (opts.requestId) {
      msgDiv.setAttribute('data-request-id', String(opts.requestId));
    }

    // Avatar
    var avatarDiv = document.createElement('div');
    avatarDiv.className = 'niwa-message__avatar';
    if (role === 'bot') {
      var imgUrl = _getAvatarUrl();
      if (imgUrl) {
        var img = document.createElement('img');
        img.src = imgUrl;
        img.alt = 'Niwa';
        img.width = 28;
        img.height = 28;
        avatarDiv.appendChild(img);
      }
    }
    msgDiv.appendChild(avatarDiv);

    // Content wrapper
    var contentWrapper = document.createElement('div');
    contentWrapper.className = 'niwa-message__content';

    // Bubble
    var bubbleDiv = document.createElement('div');
    bubbleDiv.className = 'niwa-message__bubble';

    var textEl = document.createElement('p');
    textEl.className = 'niwa-message__text';

    if (role === 'user') {
      // User messages: use textContent (escaped)
      textEl.textContent = text;
    } else {
      // Bot messages: allow safe HTML
      var formatted = u ? u.formatBotRichText(text) : text;
      var sanitized = u ? u.sanitizeHtml(formatted) : formatted;
      textEl.innerHTML = sanitized;
    }

    bubbleDiv.appendChild(textEl);
    contentWrapper.appendChild(bubbleDiv);

    // Timestamp
    if (timeStr) {
      var timeDiv = document.createElement('div');
      timeDiv.className = 'niwa-message__time';
      timeDiv.textContent = timeStr;
      contentWrapper.appendChild(timeDiv);
    }

    msgDiv.appendChild(contentWrapper);
    bodyEl.appendChild(msgDiv);

    // Scroll + media bookkeeping skipped when the caller is bulk-rendering
    // a transcript (e.g. loadFromStorage rendering 150 cached turns at page
    // load, or _renderMappedTextHistory rendering a fresh server transcript).
    // Each `_scrollToBottom()` call does `getBoundingClientRect()` (forced
    // layout reflow) + schedules 4 setTimeout-driven re-scrolls; per-message
    // media wiring adds 2 event listeners + another setTimeout per <img>/<video>.
    // Doing that N times in a synchronous loop is the layout-thrashing root
    // cause that froze the page. Callers responsible for
    // bulk rendering should pass `skipScroll: true` and call _scrollToBottom()
    // exactly once after the loop completes.
    if (!opts.skipScroll) {
      _scrollToBottom();

      // If bot bubble contains media, scroll again after load
      if (role === 'bot') {
        var media = msgDiv.querySelectorAll('img, video');
        if (media.length) {
          setTimeout(_scrollToBottom, 50);
          for (var mi = 0; mi < media.length; mi++) {
            media[mi].addEventListener('load', function () {
              setTimeout(_scrollToBottom, 50);
            });
            media[mi].addEventListener('loadedmetadata', function () {
              setTimeout(_scrollToBottom, 50);
            });
          }
        }
      }
    }

    if (role === 'bot') {
      // Attach copy button for coupon codes — cheap and useful even during
      // bulk render (coupon UI is per-message functional state, not a layout
      // event).
      _attachCopyButton(bubbleDiv, text, opts);
    }

    // Save to history
    if (!opts.skipSave) {
      _addToHistory({
        type: 'text',
        role: role,
        content: text,
        source: opts.source || null,
        timestamp: timestamp,
        couponCode: _normalizeCouponCode(opts.couponCode),
        requestId: opts.requestId || ''
      });
    }

    return msgDiv;
  }

  /* -- Date Separator ----------------------------------------------------- */

  function addDateSeparator(text) {
    if (!bodyEl) return;
    var sep = document.createElement('div');
    sep.className = 'niwa-messages__date-sep';
    sep.textContent = text;
    bodyEl.appendChild(sep);
    lastRenderedDateStr = text;
    lastSenderRole = null; // Reset continuation tracking
  }

  /* -- Typing Indicator --------------------------------------------------- */

  function showTyping() {
    if (!bodyEl) return;
    // Don't duplicate
    if (bodyEl.querySelector('#niwaTyping')) return;

    var typingDiv = document.createElement('div');
    typingDiv.className = 'niwa-typing';
    typingDiv.id = 'niwaTyping';

    var avatarDiv = document.createElement('div');
    avatarDiv.className = 'niwa-typing__avatar';
    var imgUrl = _getAvatarUrl();
    if (imgUrl) {
      var img = document.createElement('img');
      img.src = imgUrl;
      img.alt = 'Niwa';
      img.width = 28;
      img.height = 28;
      avatarDiv.appendChild(img);
    }
    typingDiv.appendChild(avatarDiv);

    var textSpan = document.createElement('span');
    textSpan.className = 'niwa-typing__text';
    textSpan.textContent = 'Thinking...';
    typingDiv.appendChild(textSpan);

    bodyEl.appendChild(typingDiv);
    _scrollToBottom();
  }

  function hideTyping() {
    if (!bodyEl) return;
    var typingEl = bodyEl.querySelector('#niwaTyping');
    if (typingEl && typingEl.parentNode) {
      typingEl.parentNode.removeChild(typingEl);
    }
  }

  /* -- Suggestions -------------------------------------------------------- */

  /**
   * Render suggestion buttons.
   * @param {Array} items - Array of { label, icon, code } objects or strings
   * @param {Array} [codes] - Optional parallel codes array (legacy format)
   * @param {boolean} [skipSave] - If true, don't save to history
   */
  function renderSuggestions(items, codes, skipSave, opts) {
    if (!bodyEl || !items || !items.length) return;
    opts = opts || {};

    // Clear existing suggestions first
    clearSuggestions();

    var container = document.createElement('div');
    container.className = 'niwa-suggestions';
    container.id = 'niwaSuggestions';

    for (var i = 0; i < items.length; i++) {
      var item = items[i];
      var label = typeof item === 'string' ? item : (item.label || item);
      var icon = typeof item === 'object' ? item.icon : null;
      var code = (codes && codes[i]) ? codes[i] : (typeof item === 'object' ? item.code : null);

      var btn = document.createElement('button');
      btn.className = 'niwa-action';
      btn.type = 'button';

      if (code) {
        btn.setAttribute('data-code', code);
        if (typeof code === 'string' && code.indexOf('page_link:') === 0) {
          btn.setAttribute('data-url', code.substring('page_link:'.length));
        }
      }

      if (icon) {
        var iconSpan = document.createElement('span');
        iconSpan.className = 'niwa-action__icon';
        iconSpan.innerHTML = icon;
        btn.appendChild(iconSpan);
      }

      var labelSpan = document.createElement('span');
      labelSpan.className = 'niwa-action__label';
      labelSpan.textContent = label;
      btn.appendChild(labelSpan);

      // Click handler
      (function (lbl, cd) {
        btn.addEventListener('click', function () {
          _handleSuggestionClick(lbl, cd);
        });
      })(label, code);

      container.appendChild(btn);
    }

    bodyEl.appendChild(container);
    if (opts.anchorEl) {
      _scrollNodeToTop(opts.anchorEl);
    } else {
      _scrollToBottom();
    }

    // Save to history
    if (!skipSave) {
      var list = items.map(function (itm) {
        return typeof itm === 'string' ? itm : (itm.label || '');
      });
      var codesList = codes || items.map(function (itm) {
        return typeof itm === 'object' ? (itm.code || null) : null;
      });
      _addToHistory({
        type: 'suggestions',
        data: { list: list, codes: codesList },
        timestamp: Date.now()
      });
    }
  }

  function clearSuggestions() {
    if (!bodyEl) return;
    var existing = bodyEl.querySelector('#niwaSuggestions');
    if (existing && existing.parentNode) {
      existing.parentNode.removeChild(existing);
    }
  }

  function _handleSuggestionClick(label, code) {
    if (code) {
      // Page link navigation
      if (typeof code === 'string' && code.indexOf('page_link:') === 0) {
        window.location.assign(code.substring('page_link:'.length));
        return;
      }
      // Checkout
      if (code === 'checkout') {
        if (window.Niwa && window.Niwa.Utils && typeof window.Niwa.Utils.trackFrontendEvent === 'function') {
          window.Niwa.Utils.trackFrontendEvent('product_checkout_click', {
            source: 'suggestion_checkout',
            meta: {
              label: label || '',
              code: code
            }
          }, { transport: 'beacon' });
        }
        window.location.href = (window.NiwaAI && window.NiwaAI.checkout) || '/checkout/';
        return;
      }
      if (code === 'add_first') {
        var firstProduct = document.querySelector('.niwa-morph__body .niwa-product-tile__open, .niwa-center__body .niwa-product-tile__open');
        if (firstProduct) {
          firstProduct.click();
          setTimeout(function () {
            var addBtn = document.querySelector('.niwa-product-focus.is-visible [data-product-action="cart"]');
            if (addBtn) addBtn.click();
          }, 240);
        }
        return;
      }
      if (code === 'first_details') {
        var detailLink = document.querySelector('.niwa-morph__body .niwa-product-tile__open, .niwa-center__body .niwa-product-tile__open');
        if (detailLink) detailLink.click();
        return;
      }
    }

    // Default: send as chat message
    if (window.Niwa && window.Niwa.ChatClient) {
      window.Niwa.ChatClient.sendMessage(label);
    }
  }

  /* -- History persistence ------------------------------------------------ */

  function _addToHistory(entry, immediate) {
    historyCache.push(entry);
    var limit = _historyRenderLimit();
    if (historyCache.length > limit) {
      historyCache = historyCache.slice(-limit);
    }
    if (immediate) {
      saveHistory();
    } else {
      _debouncedSave();
    }
  }

  function addHistoryEntry(entry, immediate) {
    if (!entry || typeof entry !== 'object') return;
    _addToHistory(entry, !!immediate);
  }

  function _debouncedSave() {
    if (saveTimeout) clearTimeout(saveTimeout);
    saveTimeout = setTimeout(function () {
      try {
        localStorage.setItem(_storageKey(), JSON.stringify(historyCache.slice(-_historyRenderLimit())));
        localStorage.setItem('niwa_ai_last_activity', String(Date.now()));
        // Persist greeting version
        var gv = window.NiwaAI && window.NiwaAI.greeting_version;
        if (gv) {
          localStorage.setItem(_storageKey() + '_gv', gv);
        }
      } catch (e) { /* ignore */ }
    }, 500);
  }

  function saveHistory() {
    try {
      localStorage.setItem(_storageKey(), JSON.stringify(historyCache.slice(-_historyRenderLimit())));
      localStorage.setItem('niwa_ai_last_activity', String(Date.now()));
    } catch (e) { /* ignore */ }
  }

  function getHistory() {
    return historyCache.slice();
  }

  /**
   * Remove all bot messages tagged with the given source from both
   * the history cache and the rendered DOM. Used for flow handoffs
   * (e.g. CB takes over from ESCC and needs to clear the unlock
   * prompt that no longer applies).
   */
  function _normalizeRemovalText(text) {
    return String(text || '').replace(/\s+/g, ' ').trim();
  }

  function _buildRemovalEntryMap(filters) {
    if (!filters || !Array.isArray(filters.entries) || !filters.entries.length) {
      return null;
    }

    var map = {};
    for (var i = 0; i < filters.entries.length; i++) {
      var entry = filters.entries[i] || {};
      var role = entry.role || 'bot';
      var text = _normalizeRemovalText(entry.content || entry.text || '');
      if (text) {
        map[role + '::' + text] = true;
      }
    }

    return map;
  }

  function _matchesRemovalEntry(map, role, text) {
    if (!map) return true;
    return !!map[(role || 'bot') + '::' + _normalizeRemovalText(text)];
  }

  function removeBySource(sourceTag, filters) {
    if (!sourceTag) return 0;
    var entryMap = _buildRemovalEntryMap(filters);
    var removed = 0;

    // Strip matching entries from in-memory history.
    for (var i = historyCache.length - 1; i >= 0; i--) {
      var entry = historyCache[i];
      if (entry
        && entry.source === sourceTag
        && _matchesRemovalEntry(entryMap, entry.role || 'bot', entry.content || entry.text || '')) {
        historyCache.splice(i, 1);
        removed++;
      }
    }

    // Strip matching DOM nodes.
    if (bodyEl) {
      var nodes = bodyEl.querySelectorAll('[data-source="' + sourceTag + '"]');
      for (var j = 0; j < nodes.length; j++) {
        var node = nodes[j];
        var role = node.classList && node.classList.contains('niwa-message--user') ? 'user' : 'bot';
        var textEl = node.querySelector ? node.querySelector('.niwa-message__text') : null;
        var text = (textEl && textEl.textContent) || node.textContent || '';
        if (!_matchesRemovalEntry(entryMap, role, text)) {
          continue;
        }
        if (node.parentNode) {
          node.parentNode.removeChild(node);
        }
      }
    }

    if (removed > 0) {
      saveHistory();
    }
    return removed;
  }

	  function _textHistoryFromCache() {
	    var items = [];
	    for (var i = 0; i < historyCache.length; i++) {
      var entry = historyCache[i];
      if (!entry) continue;
      if (entry.type === 'text' || !entry.type) {
        items.push({
          type: 'text',
          role: entry.role || 'bot',
          content: entry.content || entry.text || '',
          source: entry.source || null,
          timestamp: entry.timestamp || Date.now(),
          couponCode: entry.couponCode || entry.coupon_code || null
        });
      }
	    }
	    return items;
	  }

	  function _normalizeServerTextHistory(serverHistory) {
	    var mapped = [];
	    if (!Array.isArray(serverHistory)) return mapped;

	    for (var i = 0; i < serverHistory.length; i++) {
	      var item = serverHistory[i];
	      if (!item || !item.text) continue;
	      mapped.push({
	        type: 'text',
	        role: item.role === 'user' ? 'user' : 'bot',
	        content: String(item.text),
	        source: item.source || null,
	        timestamp: item.ts ? parseInt(item.ts, 10) * 1000 : Date.now(),
          couponCode: item.couponCode || item.coupon_code || null
	      });
	    }

	    return mapped;
	  }

  function _messageSignature(entry) {
    if (!entry) return '';
    var role = entry.role || 'bot';
    var content = entry.content || entry.text || '';
    var source = entry.source || '';
    return [role, String(content).trim(), String(source).trim()].join('|');
  }

  function _messageSignatureCounts(entries) {
    var counts = {};
    var items = Array.isArray(entries) ? entries : [];

    for (var i = 0; i < items.length; i++) {
      var signature = _messageSignature(items[i]);
      if (!signature) continue;
      counts[signature] = (counts[signature] || 0) + 1;
    }

    return counts;
  }

  function _unmatchedServerTextHistory(mapped, localText) {
    var localCounts = _messageSignatureCounts(localText);
    var additions = [];

    for (var i = 0; i < mapped.length; i++) {
      var signature = _messageSignature(mapped[i]);
      if (signature && localCounts[signature] > 0) {
        localCounts[signature]--;
        continue;
      }

      additions.push(mapped[i]);
    }

    return additions;
  }

  function _lastBotSignature(entries) {
    var items = Array.isArray(entries) ? entries : [];
    for (var i = items.length - 1; i >= 0; i--) {
      if (items[i] && (items[i].role || 'bot') === 'bot') {
        return _messageSignature(items[i]);
      }
    }
    return '';
  }

  function _readLastSeenBotSignature() {
    try {
      return localStorage.getItem(_storageKey() + '_last_seen_bot') || '';
    } catch (e) {
      return '';
    }
  }

	  function markLatestBotAsSeen() {
	    var signature = _lastBotSignature(_textHistoryFromCache());
	    lastSeenBotSignature = signature;
	    try {
	      localStorage.setItem(_storageKey() + '_last_seen_bot', signature);
	    } catch (e) { /* ignore */ }
	    return signature;
	  }

	  function _clearRenderedConversation() {
	    if (bodyEl) {
	      bodyEl.innerHTML = '';
	    }
	    historyCache = [];
	    lastSenderRole = null;
	    lastRenderedDateStr = null;
	  }

	  function _renderMappedTextHistory(mapped) {
	    var u = _utils();
	    var lastDateStr = null;

	    for (var i = 0; i < mapped.length; i++) {
	      var entry = mapped[i];
	      if (!entry || !entry.content) continue;

	      if (entry.timestamp) {
	        var dateStr = u ? u.formatDate(new Date(entry.timestamp)) : null;
	        if (dateStr && dateStr !== lastDateStr) {
	          addDateSeparator(dateStr);
	          lastDateStr = dateStr;
	        }
	      }

	      // skipScroll: true — bulk render path. Calling _scrollToBottom() on
	      // every message would force N layout reflows + 4N setTimeout scroll
	      // callbacks. We scroll once after the loop.
	      addMessage(entry.content, entry.role || 'bot', {
	        source: entry.source || null,
	        skipSave: true,
	        skipScroll: true,
	        timestamp: entry.timestamp || Date.now(),
          couponCode: entry.couponCode || entry.coupon_code || null
	      });
	    }

	    if (mapped.length > 0) {
	      _scrollToBottom();
	    }
	  }


	  function replaceServerHistory(serverHistory, opts) {
	    opts = opts || {};

	    var mapped = _sliceDisplayHistory(_normalizeServerTextHistory(serverHistory));
	    var localText = _textHistoryFromCache();
	    var additions = _unmatchedServerTextHistory(mapped, localText);

	    // Short-circuit when the server transcript and the local cache are
	    // already in sync. Without this guard, replaceServerHistory was
	    // calling `_clearRenderedConversation()` + `_renderMappedTextHistory()`
	    // on every pageshow / focus / visibilitychange / initial recovery
	    // tick (frontend sync path), which on heavy long transcripts (200+
	    // turns) blocks the main thread for 1-3s on every navigation. The
	    // pill itself opens slowly for the same reason: paint queue is
	    // saturated by the redundant clear+render. `mergeServerHistory`
	    // already has this exact guard (just below); replaceServerHistory
	    // didn't — that was the regression introduced when the prefix-while
	    // loop was replaced with signature-count merging in 0.12.77.
	    // DOM is already populated by loadFromStorage before this runs, so
	    // skipping clear+render leaves the visible state correct.
	    if (
	      mapped.length
	      && !additions.length
	      && localText.length === mapped.length
	    ) {
	      historyCache = mapped.slice();
	      saveHistory();
	      return {
	        added: 0,
	        newBotCount: 0,
	        lastRole: mapped[mapped.length - 1].role || ''
	      };
	    }

	    _clearRenderedConversation();

	    if (mapped.length) {
	      _renderMappedTextHistory(mapped);
	      historyCache = mapped.slice();
	      saveHistory();
	    } else {
	      saveHistory();

	      if (opts.greeting) {
	        addMessage(opts.greeting, 'bot', { source: 'greeting' });
	      }

	      if (Array.isArray(opts.suggestions) && opts.suggestions.length) {
	        renderSuggestions(opts.suggestions, opts.suggestionCodes || null, false);
	      }
	    }

	    var newBotCount = 0;
	    for (var i = 0; i < additions.length; i++) {
	      if (additions[i].role === 'bot' && _messageSignature(additions[i]) !== lastSeenBotSignature) {
	        newBotCount++;
	      }
	    }

	    return {
	      added: additions.length,
	      newBotCount: newBotCount,
	      lastRole: mapped.length ? mapped[mapped.length - 1].role : ''
	    };
	  }

	  function mergeServerHistory(serverHistory) {
	    if (!Array.isArray(serverHistory) || !serverHistory.length) {
	      return { added: 0, newBotCount: 0 };
	    }

    var mapped = _sliceDisplayHistory(_normalizeServerTextHistory(serverHistory));

	    if (!mapped.length) {
	      return { added: 0, newBotCount: 0 };
    }

    var localText = _textHistoryFromCache();
    var additions = _unmatchedServerTextHistory(mapped, localText);

    if (!additions.length && localText.length === mapped.length) {
      return { added: 0, newBotCount: 0 };
    }

    for (var j = 0; j < additions.length; j++) {
      addMessage(additions[j].content, additions[j].role, {
        source: additions[j].source || null,
        skipSave: false,
        timestamp: additions[j].timestamp || Date.now(),
        couponCode: additions[j].couponCode || additions[j].coupon_code || null
      });
    }

    historyCache = mapped.slice();
    saveHistory();

    var newBotCount = 0;
    for (var k = 0; k < additions.length; k++) {
      if (additions[k].role === 'bot' && _messageSignature(additions[k]) !== lastSeenBotSignature) {
        newBotCount++;
      }
    }

    return {
      added: additions.length,
      newBotCount: newBotCount
    };
  }

  /**
   * Load history from localStorage and render messages.
   * Returns the history array if loaded, or null if none found.
   */
  function loadFromStorage() {
    var u = _utils();
    try {
      var raw = localStorage.getItem(_storageKey());
      if (!raw) return null;
      var msgs = JSON.parse(raw);
      if (!Array.isArray(msgs) || !msgs.length) return null;
      var originalLength = msgs.length;
      var renderLimit = _historyRenderLimit();
      if (msgs.length > renderLimit) {
        msgs = msgs.slice(-renderLimit);
        try {
          localStorage.setItem(_storageKey(), JSON.stringify(msgs));
        } catch (e) { /* ignore */ }
      }

      // Check for greeting version change
      var greetingVersionKey = _storageKey() + '_gv';
      var storedGV = localStorage.getItem(greetingVersionKey);
      var currentGV = window.NiwaAI && window.NiwaAI.greeting_version;
      var greetingChanged = currentGV && storedGV !== currentGV;

      if (greetingChanged && msgs.length > 0 && msgs[0] && msgs[0].role === 'bot' && (msgs[0].type === 'text' || !msgs[0].type)) {
        var newGreeting = window.NiwaAI && window.NiwaAI.greeting;
        if (newGreeting) {
          msgs[0].content = newGreeting;
        }
        try {
          localStorage.setItem(_storageKey(), JSON.stringify(msgs.slice(-renderLimit)));
          localStorage.setItem(greetingVersionKey, currentGV);
        } catch (e) { /* ignore */ }
      } else if (currentGV && !storedGV) {
        try { localStorage.setItem(greetingVersionKey, currentGV); } catch (e) { /* ignore */ }
      }

      // Product recommendations own their action surface. Remove legacy
      // suggestion pills that were persisted directly after a product grid.
      msgs = msgs.filter(function (entry, index, list) {
        return !(entry && entry.type === 'suggestions' && index > 0 && list[index - 1] && list[index - 1].type === 'products');
      });

      // Populate cache
      historyCache = msgs.slice();

      // Track last date separator to avoid duplicates
      var lastDateStr = null;

      // Render messages
      for (var i = 0; i < msgs.length; i++) {
        var m = msgs[i];

        if (m.type === 'text' || !m.type) {
          // Maybe add date separator
          if (m.timestamp) {
            var dateStr = u ? u.formatDate(new Date(m.timestamp)) : null;
            if (dateStr && dateStr !== lastDateStr) {
              addDateSeparator(dateStr);
              lastDateStr = dateStr;
            }
          }

          // skipScroll: true — see addMessage() comment. Bulk-rendering the
          // entire transcript synchronously while each addMessage forces a
          // layout reflow + schedules 4 setTimeout scroll callbacks turns
          // every page load with launcher enabled into a 1-3s UI
          // freeze. We scroll exactly once below after the loop.
          addMessage(m.content || m.text || '', m.role || 'bot', {
            source: m.source || null,
            skipSave: true,
            skipScroll: true,
            timestamp: m.timestamp || Date.now(),
            couponCode: m.couponCode || m.coupon_code || null
          });
        } else if (m.type === 'products' && m.data) {
          if (window.Niwa && window.Niwa.ProductManager) {
            window.Niwa.ProductManager.renderProducts(m.data, true);
          }
        } else if (m.type === 'suggestions' && m.data) {
          renderSuggestions(
            m.data.list || [],
            m.data.codes || null,
            true // skipSave
          );
        }
      }

      // Single scroll at the end — much cheaper than 1 per message.
      if (msgs.length > 0) {
        _scrollToBottom();
      }

      // Update activity timestamp
      if (msgs.length > 0) {
        localStorage.setItem('niwa_ai_last_activity', String(Date.now()));
      }

      if (originalLength !== msgs.length) {
        saveHistory();
      }

      return msgs;
    } catch (e) {
      return null;
    }
  }

  /**
   * Inject an assistant message into the chat (for Attention Capture, Leave Stoper, etc.)
   * Opens the chat if not already open.
   * @param {string} message - The message text
   * @param {string} [source] - Source identifier
   */
  function injectAssistantMessage(message, source) {
    source = source || 'System';

    // Open chat if not open
    if (window.Niwa && window.Niwa.MorphController && !window.Niwa.MorphController.isOpen()) {
      window.Niwa.MorphController.open();
    }

    // Wait for chat body to be ready
    setTimeout(function () {
      addMessage(message, 'bot', {
        source: source,
        forceNewGroup: true
      });
    }, 300);
  }

  /* -- Copy button for coupon codes --------------------------------------- */

  function _attachCopyButton(bubbleEl, text, opts) {
    try {
      if (!bubbleEl || !text) return;
      var labels = _getLabels();
      var copyLabel = labels.coupon_copy || 'Copy Code';
      var copiedLabel = labels.coupon_copied || 'Copied!';
      var code = _normalizeCouponCode(opts && opts.couponCode);
      if (!code) code = _extractCouponCodeFromText(text);
      if (!code) return;
      if (bubbleEl.querySelector('.niwa-copy-code-btn')) return;

      var btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'niwa-copy-code-btn';
      btn.textContent = copyLabel;
      btn.setAttribute('aria-label', copyLabel);
      btn.onclick = function (e) {
        e.stopPropagation();
        var done = function () {
          btn.textContent = copiedLabel;
          setTimeout(function () { btn.textContent = copyLabel; }, 1200);
        };
        try {
          if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(code).then(done).catch(function () {
              _fallbackCopy(code);
              done();
            });
          } else {
            _fallbackCopy(code);
            done();
          }
        } catch (err) {
          done();
        }

        if (window.Niwa && window.Niwa.Utils && typeof window.Niwa.Utils.trackFrontendEvent === 'function') {
          window.Niwa.Utils.trackFrontendEvent('coupon_copy', {
            source: 'coupon_message',
            coupon_code: code
          });
        }
      };
      bubbleEl.appendChild(btn);
    } catch (e) { /* ignore */ }
  }

  function _fallbackCopy(text) {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); } catch (e) { /* ignore */ }
    document.body.removeChild(ta);
  }

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

  return {
    init: init,
    addMessage: addMessage,
    scrollNodeToTop: _scrollNodeToTop,
    addHistoryEntry: addHistoryEntry,
    addDateSeparator: addDateSeparator,
    showTyping: showTyping,
    hideTyping: hideTyping,
    renderSuggestions: renderSuggestions,
    clearSuggestions: clearSuggestions,
    loadFromStorage: loadFromStorage,
    saveHistory: saveHistory,
	    getHistory: getHistory,
	    removeBySource: removeBySource,
	    mergeServerHistory: mergeServerHistory,
	    replaceServerHistory: replaceServerHistory,
	    markLatestBotAsSeen: markLatestBotAsSeen,
	    injectAssistantMessage: injectAssistantMessage,
      normalizeCouponCode: _normalizeCouponCode
	  };
})();
