# mxchat-basic/3.2.13/js/chat-script.js

MxChat – AI Chatbot &amp; Content Generation for WordPress, version 3.2.13. 4,593 lines.

- Page: https://pluginprobe.com/plugins/mxchat-basic/3.2.13/code/js/chat-script.js
- Raw: https://pluginprobe.com/plugins/mxchat-basic/3.2.13/raw/js/chat-script.js
- Modified: 2026-07-10T11:44:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/mxchat-basic/3.2.13/code/js/chat-script.js#L10-L20`.

````javascript
jQuery(document).ready(function($) {

    // Nonce refresh — v2 (plan-6a68c9).
    //
    // The widget no longer relies on a nonce embedded in inline cached HTML.
    // Before each chat-send / stream-send / upload, we call the REST endpoint
    // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
    // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
    // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
    // 30-day backwards-compat window so cached pages still in users' browsers
    // (which carry the legacy inline-localized nonce) keep working.
    //
    // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
    // 24h but we refetch at half-life so a freshly-cached-page user never sees
    // a borderline-stale nonce).
    var cachedFreshNonce = null;
    var cachedFreshNonceFetchedAt = 0;
    var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
    var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
    var nonceRefreshCallbacks = [];

    function getRestNonceUrl() {
        if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
            return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
        }
        // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
        return window.location.origin + '/wp-json/mxchat/v1/nonce';
    }

    function fetchFreshNonceFromRest() {
        return fetch(getRestNonceUrl(), {
            credentials: 'same-origin',
            headers: { 'Accept': 'application/json' }
        }).then(function (resp) {
            if (!resp.ok) {
                throw new Error('REST nonce fetch failed: ' + resp.status);
            }
            return resp.json();
        }).then(function (data) {
            if (data && data.nonce) {
                return data.nonce;
            }
            throw new Error('REST nonce response had no nonce field.');
        });
    }

    /**
     * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
     * Tries REST endpoint first (cache-bypass design); falls back to the legacy
     * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
     * calls share the same in-flight refresh.
     */
    function withFreshNonce(callback) {
        if (typeof mxchatChat === 'undefined') {
            if (callback) callback();
            return;
        }
        var now = Date.now();
        if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
            mxchatChat.nonce = cachedFreshNonce;
            if (callback) callback();
            return;
        }
        if (callback) nonceRefreshCallbacks.push(callback);
        if (nonceRefreshState === 'pending') return;
        nonceRefreshState = 'pending';

        var resolved = function (nonce) {
            if (nonce) {
                cachedFreshNonce = nonce;
                cachedFreshNonceFetchedAt = Date.now();
                mxchatChat.nonce = nonce;
            }
            nonceRefreshState = 'done';
            var pending = nonceRefreshCallbacks;
            nonceRefreshCallbacks = [];
            pending.forEach(function (cb) { try { cb(); } catch (e) {} });
        };

        fetchFreshNonceFromRest()
            .then(resolved)
            .catch(function () {
                // Fallback to the legacy admin-ajax refresh path (issued with the
                // old action `mxchat_chat_nonce`; the server still accepts both
                // during the compat window).
                if (mxchatChat.ajax_url) {
                    $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
                        .done(function (res) {
                            if (res && res.success && res.data && res.data.nonce) {
                                resolved(res.data.nonce);
                                return;
                            }
                            resolved(null);
                        })
                        .fail(function () { resolved(null); });
                } else {
                    resolved(null);
                }
            });
    }

    // Backwards-compat alias — every existing caller in this file (and any
    // out-of-tree consumer that hit this internal API) keeps working unchanged.
    function refreshNonceIfNeeded(callback) {
        return withFreshNonce(callback);
    }

    // Dynamic-settings refresh (plan-32db95).
    //
    // Every widget setting ships inline in cached page HTML, so behind a
    // full-page cache the site owner can't purge (host cache, CDN, the
    // browser itself) a toggled setting looks broken until the cache turns
    // over. Same distrust-cached-HTML reasoning as the per-request nonce:
    // on the FIRST widget open per page load we ask the nonce endpoint for
    // the current behavior-gate settings (?with_settings=1), merge them over
    // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
    // they're server-inline-styled, so a runtime swap would visibly flash.
    // On any failure we keep the inline values silently (nonce-fallback
    // posture). At most one request per page load, only if a widget opens.
    var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'

    function mxchatRefreshDynamicSettings() {
        if (dynamicSettingsState !== 'idle') return;
        if (typeof mxchatChat === 'undefined') return;
        dynamicSettingsState = 'pending';

        var applied = function (data) {
            dynamicSettingsState = 'done';
            if (!data) return; // endpoint unavailable — inline values stand.
            if (data.nonce) {
                // Seed the nonce cache too: saves the first send's REST
                // round-trip and keeps us under the endpoint's rate limit.
                cachedFreshNonce = data.nonce;
                cachedFreshNonceFetchedAt = Date.now();
                mxchatChat.nonce = data.nonce;
            }
            if (data.settings && typeof data.settings === 'object') {
                $.extend(mxchatChat, data.settings);
                mxchatRebuildHeaderMenus();
            }
        };

        fetch(getRestNonceUrl() + '?with_settings=1', {
            credentials: 'same-origin',
            headers: { 'Accept': 'application/json' }
        }).then(function (resp) {
            if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
            return resp.json();
        }).then(applied).catch(function () {
            // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
            if (mxchatChat.ajax_url) {
                $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
                    .done(function (res) {
                        applied(res && res.success && res.data ? res.data : null);
                    })
                    .fail(function () { applied(null); });
            } else {
                applied(null);
            }
        });
    }

    // ====================================
    // MULTI-INSTANCE MANAGEMENT SYSTEM
    // ====================================

    // Instance registry - tracks all chatbot instances on the page
    const MxChatInstances = {
        instances: {},

        // Initialize an instance for a bot
        init: function(botId) {
            if (!this.instances[botId]) {
                // When persistence is OFF, track when this session started
                // so the AI only sees messages from this page load
                var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';

                this.instances[botId] = {
                    botId: botId,
                    sessionId: null,
                    lastSeenMessageId: '',
                    notificationCheckInterval: null,
                    pollingInterval: null,
                    processedMessageIds: new Set(),
                    activePdfFile: null,
                    activeWordFile: null,
                    chatHistoryLoaded: false,
                    isStreaming: false,
                    // Fresh context timestamp - only used when persistence is OFF
                    sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now()
                };
            }
            return this.instances[botId];
        },

        // Get instance by botId
        get: function(botId) {
            return this.instances[botId] || this.init(botId);
        },

        // Get all active bot IDs
        getAllBotIds: function() {
            return Object.keys(this.instances);
        },

        // Session management per bot
        // Returns existing session ID from cookie or localStorage (with in-memory fallback),
        // or null if none exists. Does NOT create a new session — use ensureSession() for that.
        getChatSession: function(botId) {
            var cookieName = 'mxchat_session_id_' + botId;
            var storageKey = 'mxchat_session_id_' + botId;
            var sessionId = getCookie(cookieName);

            // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
            if (!sessionId) {
                try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
            }

            // Fallback to in-memory instance when cookie AND localStorage are both blocked
            // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
            // storage). Without this, ensureSession() can generate and store an ID that
            // getChatSession() then can't read back, causing null session_ids on send.
            if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
                sessionId = this.instances[botId].sessionId;
            }

            // Guard against stored sentinel values that indicate earlier broken writes.
            if (sessionId === 'null' || sessionId === 'undefined') {
                sessionId = null;
            }

            // Re-sync cookie from localStorage if cookie was lost
            if (sessionId && !getCookie(cookieName)) {
                document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
            }

            return sessionId || null;
        },

        // Lazy session initializer — called on first user interaction
        ensureSession: function(botId) {
            botId = botId || 'default';
            var instance = this.instances[botId] || this.init(botId);

            if (instance.sessionId) {
                return instance.sessionId;
            }

            // Check for existing session from cookie or localStorage
            var existingSession = this.getChatSession(botId);

            if (existingSession) {
                instance.sessionId = existingSession;
            } else {
                // Brand new session
                var newId = generateSessionId();
                this.setChatSession(botId, newId);
                instance.sessionId = newId;
            }

            // Now that we have a session, do the deferred work
            refreshNonceIfNeeded();
            trackOriginatingPage();

            // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
            // so we do NOT call it here to avoid a race condition.

            return instance.sessionId;
        },

        setChatSession: function(botId, sessionId) {
            var cookieName = 'mxchat_session_id_' + botId;
            var storageKey = 'mxchat_session_id_' + botId;
            document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
            try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
            if (this.instances[botId]) {
                this.instances[botId].sessionId = sessionId;
            }
        },

        resetChatSession: function(botId) {
            // Clear old session from localStorage before setting new one
            try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
            var newSessionId = generateSessionId();
            this.setChatSession(botId, newSessionId);
            var $chatBox = getElement(botId, 'chat-box');
            if ($chatBox.length) {
                $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
            }
            if (this.instances[botId]) {
                this.instances[botId].chatHistoryLoaded = false;
                this.instances[botId].processedMessageIds = new Set();
            }
        },

        // Silent reset — new session ID without clearing the chat UI
        // Used when IP changes mid-conversation so the user doesn't see messages vanish
        silentResetSession: function(botId) {
            try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
            var newSessionId = generateSessionId();
            this.setChatSession(botId, newSessionId);
            if (this.instances[botId]) {
                this.instances[botId].sessionId = newSessionId;
            }
            return newSessionId;
        }
    };

    // ====================================
    // ELEMENT SELECTOR HELPERS
    // ====================================

    // Check if a specific bot has an AI theme assigned (skip inline colors)
    function shouldSkipInlineColors(botId) {
        // If global AI theme is active, skip inline colors for all bots
        if (mxchatChat.skip_inline_colors) {
            return true;
        }
        // Check if this specific bot has a theme assignment
        var botAssignments = mxchatChat.bot_theme_assignments || {};
        return botAssignments.hasOwnProperty(botId);
    }

    // Get element by ID with bot suffix - returns jQuery object
    function getElement(botId, elementName) {
        return $('#' + elementName + '-' + botId);
    }

    // Get element by ID with bot suffix - returns DOM element
    function getElementDOM(botId, elementName) {
        return document.getElementById(elementName + '-' + botId);
    }

    // Get bot ID from any element within a chatbot instance
    function getBotIdFromElement(element) {
        var $wrapper = $(element).closest('.mxchat-chatbot-wrapper');
        if ($wrapper.length) {
            return $wrapper.data('bot-id') || 'default';
        }
        // Fallback: try to find from floating container
        var $floating = $(element).closest('.floating-chatbot');
        if ($floating.length) {
            var id = $floating.attr('id') || '';
            var match = id.match(/floating-chatbot-(.+)/);
            if (match) return match[1];
        }
        // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
        // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
        // .close-pre-chat-message button, which carries only a class and no id — miss both
        // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
        // and read the suffix. (closest() includes the element itself, so a click directly on
        // #pre-chat-message-{bot_id} resolves here too.)
        var $preChat = $(element).closest('[id^="pre-chat-message-"]');
        if ($preChat.length) {
            var preId = $preChat.attr('id') || '';
            var preMatch = preId.match(/^pre-chat-message-(.+)$/);
            if (preMatch) return preMatch[1];
        }
        // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
        var elementId = $(element).attr('id') || '';
        if (elementId) {
            // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
            var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+)$/);
            if (idMatch) return idMatch[1];
        }
        return 'default';
    }

    // Get wrapper element for a bot
    function getWrapper(botId) {
        return getElement(botId, 'mxchat-chatbot-wrapper');
    }

    // ====================================
    // GLOBAL VARIABLES & CONFIGURATION
    // ====================================
    const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121';

    // Initialize color settings (these are global as they come from PHP)
    var userMessageBgColor = mxchatChat.user_message_bg_color;
    var userMessageFontColor = mxchatChat.user_message_font_color;
    var botMessageBgColor = mxchatChat.bot_message_bg_color;
    var botMessageFontColor = mxchatChat.bot_message_font_color;
    var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color;
    var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color;

    var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self';

    // ====================================
    // SESSION MANAGEMENT (Legacy compatibility)
    // ====================================

    function getCookie(name) {
        let value = "; " + document.cookie;
        let parts = value.split("; " + name + "=");
        if (parts.length == 2) return parts.pop().split(";").shift();
    }

    function generateSessionId() {
        return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
    }

    // Legacy function - now delegates to instance manager
    function getChatSession(botId) {
        botId = botId || 'default';
        return MxChatInstances.getChatSession(botId);
    }

    function setChatSession(sessionId, botId) {
        botId = botId || 'default';
        MxChatInstances.setChatSession(botId, sessionId);
    }

    function resetChatSession(botId) {
        botId = botId || 'default';
        MxChatInstances.resetChatSession(botId);
    }

    // ====================================
    // INITIALIZE ALL CHATBOT INSTANCES
    // ====================================

    function initializeAllInstances() {
        // Find all chatbot wrappers on the page
        $('.mxchat-chatbot-wrapper').each(function() {
            var botId = $(this).data('bot-id') || 'default';
            MxChatInstances.init(botId);
            initializeBotInstance(botId);
        });
    }

    function initializeBotInstance(botId) {
        var instance = MxChatInstances.get(botId);

        // Initialize quick questions state for this bot
        checkQuickQuestionsState(botId);

        // Note: Event handlers use event delegation with class selectors,
        // so they work automatically for all instances without per-bot setup
    }

// ====================================
// CONTEXTUAL AWARENESS FUNCTIONALITY
// ====================================

function getPageContext() {
    // Check if contextual awareness is enabled
    if (mxchatChat.contextual_awareness_toggle !== 'on') {
        return null;
    }
    
    // Get page URL
    const pageUrl = window.location.href;
    
    // Get page title
    const pageTitle = document.title || '';
    
    // Get main content from the page
    let pageContent = '';
    
    // Try to get content from common content areas
    const contentSelectors = [
        'main',
        '[role="main"]',
        '.content',
        '.main-content',
        '.post-content',
        '.entry-content',
        '.page-content',
        'article',
        '#content',
        '#main'
    ];
    
    let contentElement = null;
    for (const selector of contentSelectors) {
        contentElement = document.querySelector(selector);
        if (contentElement) {
            break;
        }
    }
    
    // If no specific content area found, use body but exclude header, footer, nav, sidebar
    if (!contentElement) {
        contentElement = document.body;
    }
    
    if (contentElement) {
        // Clone the element to avoid modifying the original
        const clone = contentElement.cloneNode(true);
        
        // Remove unwanted elements
        const unwantedSelectors = [
            'header',
            'footer',
            'nav',
            '.navigation',
            '.sidebar',
            '.widget',
            '.menu',
            'script',
            'style',
            '.comments',
            '#comments',
            '.breadcrumb',
            '.breadcrumbs',
            '#floating-chatbot',
            '#floating-chatbot-button',
            '.mxchat',
            '[class*="chat"]',
            '[id*="chat"]'
        ];
        
        unwantedSelectors.forEach(selector => {
            const elements = clone.querySelectorAll(selector);
            elements.forEach(el => el.remove());
        });
        
        //   Extract MxChat context data attributes before getting text content
        const contextData = [];
        clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
            const contextValue = el.dataset.mxchatContext;
            if (contextValue && contextValue.trim()) {
                contextData.push(contextValue);
            }
        });
        
        // Get text content and clean it up
        pageContent = clone.textContent || clone.innerText || '';
        
        // Add context data to page content if any were found
        if (contextData.length > 0) {
            pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
        }
        
        // Clean up whitespace and limit length
        pageContent = pageContent
            .replace(/\s+/g, ' ')
            .trim()
            .substring(0, 3000); // Limit to 3000 characters to avoid token limits
    }
    
    // Only return context if we have meaningful content
    if (!pageContent || pageContent.length < 50) {
        return null;
    }
    
    return {
        url: pageUrl,
        title: pageTitle,
        content: pageContent
    };
}

//   Track originating page when chat starts
function trackOriginatingPage() {
    const sessionId = getChatSession();
    const pageUrl = window.location.href;
    const pageTitle = document.title || 'Untitled Page';
    
    // Only track once per session
    const trackingKey = 'mxchat_originating_tracked_' + sessionId;
    if (sessionStorage.getItem(trackingKey)) {
        return;
    }
    
    $.ajax({
        url: mxchatChat.ajax_url,
        type: 'POST',
        data: {
            action: 'mxchat_track_originating_page',
            session_id: sessionId,
            page_url: pageUrl,
            page_title: pageTitle,
            nonce: mxchatChat.nonce
        },
        success: function(response) {
            if (response.success) {
                sessionStorage.setItem(trackingKey, 'true');
            }
        }
    });
}

// ====================================
// CORE CHAT FUNCTIONALITY
// ====================================

// Helper functions to disable/enable chat input while waiting for response
function disableChatInput(botId) {
    botId = botId || 'default';
    var chatInput = getElementDOM(botId, 'chat-input');
    var sendButton = getElementDOM(botId, 'send-button');
    if (chatInput) {
        chatInput.disabled = true;
        chatInput.style.opacity = '0.6';
    }
    if (sendButton) {
        sendButton.disabled = true;
        sendButton.style.opacity = '0.5';
        sendButton.style.pointerEvents = 'none';
    }
}

function enableChatInput(botId) {
    botId = botId || 'default';
    var chatInput = getElementDOM(botId, 'chat-input');
    var sendButton = getElementDOM(botId, 'send-button');
    if (chatInput) {
        chatInput.disabled = false;
        chatInput.style.opacity = '1';
        chatInput.focus();
    }
    if (sendButton) {
        sendButton.disabled = false;
        sendButton.style.opacity = '1';
        sendButton.style.pointerEvents = 'auto';
    }
    // Every completion path re-enables input, so this is the single restore
    // point for the streaming Stop affordance (no-op when not in stop mode).
    mxchatRestoreSendButton(botId);
}

// --- Streaming Stop control -------------------------------------------------
// One live stream handle per bot instance, so Stop on one widget never aborts
// another bot on the same page.
var mxchatActiveStreams = {};
// Original send-button markup, captured once per bot the first time the Stop
// state is shown (never captured while already in stop mode, so a rapid
// stop-then-resend can't save the stop glyph as the "original").
var mxchatSendMarkup = {};

function mxchatShowStopButton(botId) {
    var btn = getElementDOM(botId, 'send-button');
    if (!btn) return;
    if (!btn.classList.contains('mxchat-stop-mode')) {
        mxchatSendMarkup[botId] = {
            html: btn.innerHTML,
            label: btn.getAttribute('aria-label')
        };
    }

    // Mirror the send icon's rendered size + color so the stop glyph looks
    // native, including custom send images/colors and theme overrides.
    var child = btn.querySelector('svg, img');
    var size = 25;
    var color = '';
    if (child) {
        var rect = child.getBoundingClientRect();
        if (rect.width) {
            size = Math.round(Math.min(rect.width, rect.height));
        }
        var cs = window.getComputedStyle(child);
        color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
    }
    var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
    btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
    // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
    // start a stream synchronously while the originating click is still
    // bubbling up to our delegated handler. Without this guard, that handler
    // reads the just-added stop-mode class as a user Stop press and aborts the
    // brand-new stream — the user's message renders but no reply ever fires
    // (plan-4bba64 silent message loss). The flag only spans the current event
    // dispatch: cleared on the next macrotask, long before a real Stop click.
    btn.__mxchatStopJustShown = true;
    setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
    btn.classList.add('mxchat-stop-mode');
    btn.setAttribute('aria-label', stopLabel);
    btn.setAttribute('title', stopLabel);
    // disableChatInput() ran when the turn was sent; the Stop control itself
    // must stay clickable while the textarea remains disabled.
    btn.disabled = false;
    btn.style.opacity = '1';
    btn.style.pointerEvents = 'auto';
}

function mxchatRestoreSendButton(botId) {
    var btn = getElementDOM(botId, 'send-button');
    var saved = mxchatSendMarkup[botId];
    if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
    btn.innerHTML = saved.html;
    btn.classList.remove('mxchat-stop-mode');
    btn.removeAttribute('title');
    if (saved.label) {
        btn.setAttribute('aria-label', saved.label);
    }
}

function mxchatStopStreaming(botId) {
    var entry = mxchatActiveStreams[botId];
    if (!entry || !entry.controller) return;
    entry.aborted = true;
    try { entry.controller.abort(); } catch (e) {}
}

// Returns true when a stream rejection came from an intentional Stop click:
// keep the partial text as the turn's answer — no error UI, no fallback resend.
function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
    var entry = mxchatActiveStreams[botId];
    if (!entry || !entry.aborted) return false;
    delete mxchatActiveStreams[botId];
    if (!accumulatedContent) {
        // Stopped before the first chunk: drop the thinking bubble, no orphan message.
        getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
    }
    enableChatInput(botId); // also restores the send icon
    if (callback) {
        callback(accumulatedContent || '');
    }
    return true;
}

// Update your existing sendMessage function
function sendMessage(botId) {
    botId = botId || 'default';
    MxChatInstances.ensureSession(botId);
    var $chatInput = getElement(botId, 'chat-input');
    var message = $chatInput.val();

    // ADD PROMPT HOOK HERE
    if (typeof customMxChatFilter === 'function') {
        message = customMxChatFilter(message, "prompt");
    }

    if (message) {
        // Don't disable input in live agent mode - let users chat freely
        var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
        var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
        if (!isAgentMode) {
            disableChatInput(botId);
        }

        appendMessage("user", message, '', [], false, botId);
        $chatInput.val('');
        mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
        $chatInput.css('height', 'auto');

        if (hasQuickQuestions(botId)) {
            collapseQuickQuestions(botId);
        }
        appendThinkingMessage(botId);
        scrollToBottom(botId);

        const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';

        // Check if streaming is enabled AND supported for this model
        if (shouldUseStreaming(currentModel)) {
            callMxChatStream(message, function(response) {
                getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
            }, botId);
        } else {
            callMxChat(message, function(response) {
                replaceLastMessage("bot", response, '', [], botId);
            }, botId);
        }
    }
}

// Update your existing sendMessageToChatbot function
function sendMessageToChatbot(message, botId) {
    botId = botId || 'default';
    MxChatInstances.ensureSession(botId);

    // ADD PROMPT HOOK HERE
    if (typeof customMxChatFilter === 'function') {
        message = customMxChatFilter(message, "prompt");
    }

    // Don't disable input in live agent mode - let users chat freely
    var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
    var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
    if (!isAgentMode) {
        disableChatInput(botId);
    }

    var sessionId = getChatSession(botId);

    if (hasQuickQuestions(botId)) {
        collapseQuickQuestions(botId);
    }
    appendThinkingMessage(botId);
    scrollToBottom(botId);

    const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';

    // Check if streaming is enabled AND supported for this model
    if (shouldUseStreaming(currentModel)) {
        callMxChatStream(message, function(response) {
            getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
        }, botId);
    } else {
        callMxChat(message, function(response) {
            getElement(botId, 'chat-box').find('.temporary-message').remove();
            replaceLastMessage("bot", response, '', [], botId);
        }, botId);
    }
}

// Updated shouldUseStreaming function with debugging
function shouldUseStreaming(model) {
    // Check if streaming is enabled in settings (using your toggle naming pattern)
    const streamingEnabled = mxchatChat.enable_streaming_toggle === 'on';
    
    // Check if model supports streaming
    const streamingSupported = isStreamingSupported(model);
    
    
    // Only use streaming if both enabled and supported
    return streamingEnabled && streamingSupported;
}

// Helper function to handle chat mode updates
function handleChatModeUpdates(response, responseText) {
    // Check for explicit chat mode in response (THIS IS THE KEY FIX)
    if (response.chat_mode) {
        updateChatModeIndicator(response.chat_mode);
        return; // Return early since we found explicit mode
    }
    // Check for fallback response chat mode
    else if (response.fallbackResponse && response.fallbackResponse.chat_mode) {
        updateChatModeIndicator(response.fallbackResponse.chat_mode);
        return; // Return early since we found explicit mode
    }
    
    // Only do text-based detection if no explicit mode was provided
    // Check for specific AI chatbot response text
    if (responseText === 'You are now chatting with the AI chatbot.' || 
        responseText.includes('now chatting with the AI') ||
        responseText.includes('switched to AI mode') ||
        responseText.includes('AI chatbot is now')) {
        updateChatModeIndicator('ai');
    }
    // Check for agent transfer messages
    else if (responseText.includes('agent') && 
             (responseText.includes('transfer') || responseText.includes('connected'))) {
        updateChatModeIndicator('agent');
    }
}

// Function to get bot ID from any element or wrapper
// If element is provided, finds the bot ID from its wrapper
// If no element, returns 'default' (for backward compatibility)
function getMxChatBotId(element) {
    if (element) {
        return getBotIdFromElement(element);
    }
    // Fallback: find first chatbot wrapper on page
    const chatbotWrapper = document.querySelector('.mxchat-chatbot-wrapper');
    return chatbotWrapper ? chatbotWrapper.getAttribute('data-bot-id') || 'default' : 'default';
}

function callMxChat(message, callback, botId) {
    botId = botId || getMxChatBotId();

    // Streaming fallbacks land here: drop any leftover stream handle and
    // return the button to its send state (no-op for plain non-stream turns).
    if (mxchatActiveStreams[botId]) {
        delete mxchatActiveStreams[botId];
    }
    mxchatRestoreSendButton(botId);

    // Store the message in case we need to retry after session reset
    getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);

    // Get page context if contextual awareness is enabled
    const pageContext = getPageContext();

    // Get instance for session start timestamp (used when persistence is OFF)
    var instance = MxChatInstances.get(botId);

    // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
    // and returns the guaranteed-present session id from the in-memory instance even when
    // cookie/localStorage writes are silently blocked by the browser.
    var sessionId = MxChatInstances.ensureSession(botId);
    if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
        // Last-resort generation to ensure we never POST a null marker.
        sessionId = generateSessionId();
        MxChatInstances.setChatSession(botId, sessionId);
    }

    // Wait for the page-cache nonce refresh to complete before firing the
    // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
    // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
    // callback guarantees we read the fresh value. See plan-c5457f.
    refreshNonceIfNeeded(function() {
    // Prepare AJAX data
    const ajaxData = {
        action: 'mxchat_handle_chat_request',
        message: message,
        session_id: sessionId,
        nonce: mxchatChat.nonce,
        current_page_url: window.location.href,
        current_page_title: document.title,
        bot_id: botId,
        // Pass session start timestamp so AI context matches what user sees
        session_start_timestamp: instance.sessionStartTimestamp || 0
    };

    // Add page context if available
    if (pageContext) {
        ajaxData.page_context = JSON.stringify(pageContext);
    }

    // CHECK FOR VISION FLAGS AND ADD THEM
    if (window.mxchatVisionProcessed) {
        ajaxData.vision_processed = true;
        ajaxData.original_user_message = window.mxchatOriginalMessage || message;
        ajaxData.vision_images_count = window.mxchatVisionImagesCount || 0;
        // Clear the flags after use
        window.mxchatVisionProcessed = false;
        window.mxchatOriginalMessage = null;
        window.mxchatVisionImagesCount = 0;
    }

    $.ajax({
        url: mxchatChat.ajax_url,
        type: 'POST',
        dataType: 'json',
        data: ajaxData,
        success: function(response) {
            // IMMEDIATE CHAT MODE UPDATE - This should be FIRST
            if (response.chat_mode) {
                updateChatModeIndicator(response.chat_mode, botId);
            }

            // Also check in data property if response is wrapped
            if (response.data && response.data.chat_mode) {
                updateChatModeIndicator(response.data.chat_mode, botId);
            }

            // SECURITY FIX: Check for errors FIRST before checking for success
            // This ensures API errors (quota exceeded, invalid key, rate limit) are properly displayed
            if (response.success === false || (response.data && response.data.error_message)) {
                let errorMessage = "";
                let errorCode = "";

                // Check various possible error locations in the response
                if (response.data && response.data.error_message) {
                    errorMessage = response.data.error_message;
                    errorCode = response.data.error_code || "";
                } else if (response.error_message) {
                    errorMessage = response.error_message;
                    errorCode = response.error_code || "";
                } else if (response.message) {
                    errorMessage = response.message;
                } else if (typeof response.data === 'string') {
                    errorMessage = response.data;
                } else {
                    // Fallback for any other unexpected response format
                    errorMessage = "An error occurred. Please try again or contact support.";
                }

                // Handle session reset action (IP changed, session expired, etc.)
                // Silent reset — keep chat UI intact, just get a new session and retry
                if (response.data && response.data.action === 'reset_session') {
                    MxChatInstances.silentResetSession(botId);
                    // Re-send the original message with the new session (user message is already displayed)
                    var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
                    if (originalMessage) {
                        getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
                        var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
                        if (shouldUseStreaming(currentModel)) {
                            callMxChatStream(originalMessage, function(response) {
                                getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
                            }, botId);
                        } else {
                            callMxChat(originalMessage, function(response) {
                                replaceLastMessage("bot", response, '', [], botId);
                            }, botId);
                        }
                    }
                    return;
                }

                // Format user-friendly error message
                let displayMessage = errorMessage;

                // Customize message for admin users
                if (mxchatChat.is_admin) {
                    // For admin users, show more technical details including error code
                    displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
                }

                replaceLastMessage("bot", displayMessage, '', [], botId);
                return; // Exit early for errors
            }

            // NOW check if this is a successful response by looking for text, html, or message fields
            // This preserves compatibility with your server response format
            if (response.text !== undefined || response.html !== undefined || response.message !== undefined ||
                (response.success === true && response.data && response.data.status === 'waiting_for_agent')) {

                // Handle successful response - this is your original success handling code

                // Handle other responses
                let responseText = response.text || '';
                let responseHtml = response.html || '';
                let responseMessage = response.message || '';

                // Add PDF filename handling
                if (response.data && response.data.filename) {
                    showActivePdf(response.data.filename, botId);
                    var instance = MxChatInstances.get(botId);
                    instance.activePdfFile = response.data.filename;
                }

                // Add redirect check here
                if (response.redirect_url) {
                    if (responseText) {
                        replaceLastMessage("bot", responseText, '', [], botId);
                    }
                    setTimeout(() => {
                        window.location.href = response.redirect_url;
                    }, 1500);
                    return;
                }

                // Check for live agent response
                if (response.success && response.data && response.data.status === 'waiting_for_agent') {
                    removeThinkingDots(botId);
                    updateChatModeIndicator('agent', botId);
                    enableChatInput(botId);
                    return;
                }

                // Handle the message and show notification if chat is hidden
                if (responseText || responseHtml || responseMessage) {

                    // ADD RESPONSE HOOKS HERE - BEFORE DISPLAYING
                    if (responseText && typeof customMxChatFilter === 'function') {
                        responseText = customMxChatFilter(responseText, "response");
                    }
                    if (responseMessage && typeof customMxChatFilter === 'function') {
                        responseMessage = customMxChatFilter(responseMessage, "response");
                    }

                    // Update the messages as before
                    if (responseText && responseHtml) {
                        replaceLastMessage("bot", responseText, responseHtml, [], botId);
                    } else if (responseText) {
                        replaceLastMessage("bot", responseText, '', [], botId);
                    } else if (responseHtml) {
                        replaceLastMessage("bot", "", responseHtml, [], botId);
                    } else if (responseMessage) {
                        replaceLastMessage("bot", responseMessage, '', [], botId);
                    }

                    // Check if chat is hidden and show notification
                    var $floatingChatbot = getElement(botId, 'floating-chatbot');
                    if ($floatingChatbot.hasClass('hidden')) {
                        var $badge = getElement(botId, 'chat-notification-badge');
                        if ($badge.length) {
                            $badge.show();
                        }
                    }
                } else {
                    var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
                    if (response.vectorstore_error) {
                        emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
                    }
                    replaceLastMessage("bot", emptyMsg, '', [], botId);
                }

                if (response.message_id) {
                    var instance = MxChatInstances.get(botId);
                    instance.lastSeenMessageId = response.message_id;
                }

                return;
            }

            // Fallback for truly unexpected response formats
            replaceLastMessage("bot", "Unexpected response format. Please try again or contact support.", '', [], botId);
        },
        error: function(xhr, status, error) {
            let errorMessage = "An unexpected error occurred.";

            // Try to parse the response if it's JSON
            try {
                const responseJson = JSON.parse(xhr.responseText);

                if (responseJson.data && responseJson.data.error_message) {
                    errorMessage = responseJson.data.error_message;
                } else if (responseJson.message) {
                    errorMessage = responseJson.message;
                }
            } catch (e) {
                // Not JSON or parsing failed, use HTTP status based messages
                if (xhr.status === 0) {
                    errorMessage = "Network error: Please check your internet connection.";
                } else if (xhr.status === 403) {
                    errorMessage = "Access denied: Your session may have expired. Please refresh the page.";
                } else if (xhr.status === 404) {
                    errorMessage = "API endpoint not found. Please contact support.";
                } else if (xhr.status === 429) {
                    errorMessage = "Too many requests. Please try again in a moment.";
                } else if (xhr.status >= 500) {
                    errorMessage = "Server error: The server encountered an issue. Please try again later.";
                }
            }

            replaceLastMessage("bot", errorMessage, '', [], botId);
        }
    });
    }); // refreshNonceIfNeeded
}

function callMxChatStream(message, callback, botId) {
    botId = botId || getMxChatBotId();

    // Store the message in case we need to retry after session reset
    getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);

    const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
    if (!isStreamingSupported(currentModel)) {
        callMxChat(message, callback, botId);
        return;
    }

    // Get page context if contextual awareness is enabled
    const pageContext = getPageContext();

    // Get instance for session start timestamp (used when persistence is OFF)
    var instance = MxChatInstances.get(botId);

    // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
    // non-string value via String(), so passing `null` would POST the literal string "null"
    // and land in the transcripts table as a ghost session. ensureSession() always returns
    // a real string even when cookies/localStorage are blocked.
    var streamSessionId = MxChatInstances.ensureSession(botId);
    if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
        streamSessionId = generateSessionId();
        MxChatInstances.setChatSession(botId, streamSessionId);
    }

    // Wait for the page-cache nonce refresh before constructing formData (which
    // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
    refreshNonceIfNeeded(function() {
    const formData = new FormData();
    formData.append('action', 'mxchat_stream_chat');
    formData.append('message', message);
    formData.append('session_id', streamSessionId);
    formData.append('nonce', mxchatChat.nonce);
    formData.append('current_page_url', window.location.href);
    formData.append('current_page_title', document.title);
    formData.append('bot_id', botId);
    // Pass session start timestamp so AI context matches what user sees
    formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0);

    // Add page context if available
    if (pageContext) {
        formData.append('page_context', JSON.stringify(pageContext));
    }

    // CHECK FOR VISION FLAGS AND ADD THEM
    if (window.mxchatVisionProcessed) {
        formData.append('vision_processed', 'true');
        formData.append('original_user_message', window.mxchatOriginalMessage || message);
        formData.append('vision_images_count', window.mxchatVisionImagesCount || '0');
        // Clear the flags after use
        window.mxchatVisionProcessed = false;
        window.mxchatOriginalMessage = null;
        window.mxchatVisionImagesCount = 0;
    }

    let accumulatedContent = '';
    let testingDataReceived = false;
    let streamingStarted = false;

    // Abortable stream: a fresh controller per turn, keyed by bot instance.
    // The Stop control (send button swapped in place) aborts both the read
    // loop and the underlying request.
    var streamControl = { controller: new AbortController(), aborted: false };
    mxchatActiveStreams[botId] = streamControl;
    mxchatShowStopButton(botId);

    fetch(mxchatChat.ajax_url, {
        method: 'POST',
        body: formData,
        credentials: 'same-origin',
        signal: streamControl.controller.signal
    })
    .then(response => {
        // Store the response for potential fallback handling
        const responseClone = response.clone();

        if (!response.ok) {
            // Try to get error details from response
            return responseClone.json().then(errorData => {
                throw { isServerError: true, data: errorData };
            }).catch(() => {
                throw new Error('Network response was not ok');
            });
        }

    // Check if response is JSON instead of streaming
    const contentType = response.headers.get('content-type');
    if (contentType && contentType.includes('application/json')) {
        return responseClone.json().then(data => {
            // IMMEDIATE CHAT MODE UPDATE for JSON response
            if (data.chat_mode) {
                updateChatModeIndicator(data.chat_mode, botId);
            }

            // Check for testing panel
            if (window.mxchatTestPanelInstance && data.testing_data) {
                window.mxchatTestPanelInstance.handleTestingData(data.testing_data);
            }

            // Handle the JSON response directly
            handleNonStreamResponse(data, callback, botId);
            return Promise.resolve(); // Prevent further processing
        });
    }

        // Continue with streaming processing
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';

        function processStream() {
            reader.read().then(({ done, value }) => {
                if (done) {
                    // If streaming completed but no content was received, try to get response as fallback
                    if (!streamingStarted || !accumulatedContent) {
                        // Try to read the response as JSON
                        responseClone.text().then(text => {
                            try {
                                const data = JSON.parse(text);
                                if (data.text || data.message || data.html) {
                                    handleNonStreamResponse(data, callback, botId);
                                } else {
                                    // No valid data, fall back to regular call
                                    getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
                                    callMxChat(message, callback, botId);
                                }
                            } catch (e) {
                                // Could not parse, fall back to regular call
                                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
                                callMxChat(message, callback, botId);
                            }
                        }).catch(() => {
                            getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
                            callMxChat(message, callback, botId);
                        });
                        return;
                    }

                    // Re-enable chat input when stream ends with content
                    enableChatInput(botId);

                    // Scroll the user's last message to the top now that the
                    // bot's full reply has rendered (gives max reading room).
                    var $chatBoxDone = getElement(botId, 'chat-box');
                    var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
                    if ($lastUserMsgDone.length) {
                        scrollElementToTop($lastUserMsgDone, botId);
                    }

                    if (callback) {
                        callback(accumulatedContent);
                    }
                    return;
                }

                buffer += decoder.decode(value, { stream: true });
                const lines = buffer.split('\n');
                buffer = lines.pop() || '';

                for (const line of lines) {
                    if (line.startsWith('data: ')) {
                        const data = line.substring(6);

                        if (data === '[DONE]') {
                            if (!accumulatedContent) {
                                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
                                callMxChat(message, callback, botId);
                                return;
                            }

                            // Re-enable chat input after streaming completes
                            enableChatInput(botId);

                            // Scroll the user's last message to the top now
                            // that the bot's full reply has rendered.
                            var $chatBoxStreamDone = getElement(botId, 'chat-box');
                            var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
                            if ($lastUserMsgStreamDone.length) {
                                scrollElementToTop($lastUserMsgStreamDone, botId);
                            }

                            if (callback) {
                                callback(accumulatedContent);
                            }
                            return;
                        }

                        try {
                            const json = JSON.parse(data);

                            // IMMEDIATE CHAT MODE UPDATE FOR STREAMING
                            if (json.chat_mode) {
                                updateChatModeIndicator(json.chat_mode, botId);
                            }

                            // Handle testing data
                            if (json.testing_data && !testingDataReceived) {
                                if (window.mxchatTestPanelInstance) {
                                    window.mxchatTestPanelInstance.handleTestingData(json.testing_data);
                                    testingDataReceived = true;
                                }
                            }
                            // Handle content streaming
                            else if (json.content) {
                                streamingStarted = true;
                                accumulatedContent += json.content;
                                updateStreamingMessage(accumulatedContent, botId);
                            }
                            // Handle complete response in stream (fallback response)
                            else if (json.text || json.message || json.html) {
                                handleNonStreamResponse(json, callback, botId);
                                return;
                            }
                            // Handle errors
                            else if (json.error) {

                                // Get error message from various possible fields
                                let errorMessage = json.error_message || json.message || json.text ||
                                    (typeof json.error === 'string' ? json.error : 'An error occurred. Please try again.');

                                // Re-enable chat input on error
                                enableChatInput(botId);

                                // Display the error directly in the chat
                                replaceLastMessage("bot", errorMessage, '', [], botId);

                                if (callback) {
                                    callback(errorMessage);
                                }
                                return;
                            }
                        } catch (e) {
                            // SSE data parsing error - silently continue
                        }
                    }
                }

                processStream();
            }).catch(streamError => {
                if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
                callMxChat(message, callback, botId);
            });
        }

        processStream();
    })
        .catch(error => {
            if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
            // Check if we have server error data with chat mode
            if (error && error.isServerError && error.data) {
                // Check for chat mode in error data
                if (error.data.chat_mode) {
                    updateChatModeIndicator(error.data.chat_mode, botId);
                }

                handleNonStreamResponse(error.data, callback, botId);
            } else {
                // Only fall back to regular call if we don't have any response data
                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
                callMxChat(message, callback, botId);
            }
        });
    }); // refreshNonceIfNeeded
}

// Helper function to handle non-streaming responses
function handleNonStreamResponse(data, callback, botId) {
    botId = botId || 'default';

    // IMMEDIATE CHAT MODE UPDATE FOR NON-STREAMING RESPONSES
    if (data.chat_mode) {
        updateChatModeIndicator(data.chat_mode, botId);
    }

    // Also check in data property if response is wrapped
    if (data.data && data.data.chat_mode) {
        updateChatModeIndicator(data.data.chat_mode, botId);
    }

    // NOTE: Don't remove temporary message here - let replaceLastMessage handle it
    // This prevents a visual gap between thinking dots disappearing and content appearing

    // SECURITY FIX: Check for errors FIRST
    if (data.success === false || (data.data && data.data.error_message)) {
        let errorMessage = "";
        let errorCode = "";

        // Check various possible error locations
        if (data.data && data.data.error_message) {
            errorMessage = data.data.error_message;
            errorCode = data.data.error_code || "";
        } else if (data.error_message) {
            errorMessage = data.error_message;
            errorCode = data.error_code || "";
        } else if (data.message) {
            errorMessage = data.message;
        } else if (typeof data.data === 'string') {
            errorMessage = data.data;
        } else {
            errorMessage = "An error occurred. Please try again or contact support.";
        }

        // Handle session reset action (IP changed, session expired, etc.)
        // Silent reset — keep chat UI intact, just get a new session and retry
        if (data.data && data.data.action === 'reset_session') {
            MxChatInstances.silentResetSession(botId);
            // Re-send the original message with the new session (user message is already displayed)
            var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
            if (originalMessage) {
                getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
                var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
                if (shouldUseStreaming(currentModel)) {
                    callMxChatStream(originalMessage, callback, botId);
                } else {
                    callMxChat(originalMessage, callback, botId);
                }
            }
            return;
        }

        // Format user-friendly error message
        let displayMessage = errorMessage;
        if (mxchatChat.is_admin) {
            displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
        }

        replaceLastMessage("bot", displayMessage, '', [], botId);

        if (callback) {
            callback('');
        }
        return; // Exit early for errors
    }

    // Check for live agent response
    if (data.success && data.data && data.data.status === 'waiting_for_agent') {
        removeThinkingDots(botId);
        // Also remove any leftover bot-message that lost its temporary-message class
        var $chatBox = getElement(botId, 'chat-box');
        $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
        updateChatModeIndicator('agent', botId);
        enableChatInput(botId);
        if (callback) {
            callback('');
        }
        return;
    }

    // Handle different response formats
    if (data.text || data.html || data.message) {

        // Apply response hooks
        if (data.text && typeof customMxChatFilter === 'function') {
            data.text = customMxChatFilter(data.text, "response");
        }
        if (data.message && typeof customMxChatFilter === 'function') {
            data.message = customMxChatFilter(data.message, "response");
        }

        // Display the response
        if (data.text && data.html) {
            replaceLastMessage("bot", data.text, data.html, [], botId);
        } else if (data.text) {
            replaceLastMessage("bot", data.text, '', [], botId);
        } else if (data.html) {
            replaceLastMessage("bot", "", data.html, [], botId);
        } else if (data.message) {
            replaceLastMessage("bot", data.message, '', [], botId);
        }
    }

    // Handle other response properties
    if (data.data && data.data.filename) {
        showActivePdf(data.data.filename, botId);
        var instance = MxChatInstances.get(botId);
        instance.activePdfFile = data.data.filename;
    }

    if (data.redirect_url) {
        setTimeout(() => {
            window.location.href = data.redirect_url;
        }, 1500);
    }

    // Ensure chat input is re-enabled (safety net for edge cases)
    enableChatInput(botId);

    if (callback) {
        callback(data.text || data.message || '');
    }
}

// Enhanced updateChatModeIndicator function for immediate DOM updates
function updateChatModeIndicator(mode, botId) {
    botId = botId || 'default';
    const indicator = getElementDOM(botId, 'chat-mode-indicator');
    if (indicator) {
        const oldText = indicator.textContent;

        if (mode === 'agent') {
            indicator.textContent = 'Live Agent';
            startPolling(botId);
        } else {
            // Everything else is AI mode
            const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
            indicator.textContent = customAiText;
            stopPolling(botId);
        }

        // Force immediate DOM update and reflow
        if (oldText !== indicator.textContent) {
            // Force a reflow to ensure the change is visible immediately
            indicator.style.display = 'none';
            indicator.offsetHeight; // Trigger reflow
            indicator.style.display = '';

            // Double-check after a brief moment to ensure the change stuck
            setTimeout(() => {
                if (mode === 'agent' && indicator.textContent !== 'Live Agent') {
                    indicator.textContent = 'Live Agent';
                } else if (mode !== 'agent' && indicator.textContent === 'Live Agent') {
                    const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
                    indicator.textContent = customAiText;
                }
            }, 50);
        }
    }
}

// Function to update message during streaming
function updateStreamingMessage(content, botId) {
    botId = botId || 'default';

    // ADD RESPONSE HOOK FOR REAL-TIME STREAMING
    if (typeof customMxChatFilter === 'function') {
        content = customMxChatFilter(content, "response");
    }

    const formattedContent = linkify(content);

    // Find the temporary message in this bot's chat box
    var $chatBox = getElement(botId, 'chat-box');
    const tempMessage = $chatBox.find('.bot-message.temporary-message').last();

    if (tempMessage.length) {
        // Update existing message
        tempMessage.html(formattedContent);
    } else {
        // Create new temporary message if it doesn't exist
        appendMessage("bot", content, '', [], true, botId);
    }
}

function isStreamingSupported(model) {
    if (!model) return false;

    const modelPrefix = model.split('-')[0].toLowerCase();

    // Support streaming for OpenAI, Claude, Grok, DeepSeek, and OpenRouter models
    const isSupported = modelPrefix === 'gpt' || 
                        modelPrefix === 'o1' || 
                        modelPrefix === 'claude' || 
                        modelPrefix === 'grok' || 
                        modelPrefix === 'deepseek' || 
                        model === 'openrouter';  // Add this line - check full model name for OpenRouter
    
    return isSupported;
}

// Update the event handlers to use the correct function names (using event delegation)
// Use class-based selectors for multi-instance support
$(document).on('click', '.send-button', function() {
    var botId = getBotIdFromElement(this);
    // While a response is streaming the button is a Stop control.
    if (this.classList.contains('mxchat-stop-mode')) {
        // Same click that just started this stream (an add-on's direct handler
        // ran before this delegated one) — not a Stop press. See
        // mxchatShowStopButton for the full story (plan-4bba64).
        if (this.__mxchatStopJustShown) return;
        mxchatStopStreaming(botId);
        return;
    }
    var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
    if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
        disableChatInput(botId);
    }
    sendMessage(botId);
});

// Override enter key handler (using event delegation)
$(document).on('keypress', '.chat-input', function(e) {
    if (e.which == 13 && !e.shiftKey) {
        e.preventDefault();
        var botId = getBotIdFromElement(this);
        var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
        if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
            disableChatInput(botId);
        }
        sendMessage(botId);
    }
});

// Chat input character counter + soft limit feedback (plan 7091a2).
// Language-neutral: numbers + color only, no translatable strings. The counter
// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
function mxchatUpdateCharCounter(inputEl) {
    if (!inputEl || !inputEl.closest) return;
    var max = parseInt(inputEl.getAttribute('maxlength'), 10);
    var container = inputEl.closest('.input-container');
    if (!container || !max || max <= 0) return;
    var counter = container.querySelector('.mxchat-char-counter');
    if (!counter) return;
    var len = inputEl.value.length;
    var ratio = len / max;
    var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
    var cur = counter.querySelector('.mxchat-char-counter-current');
    if (cur) cur.textContent = len;
    var warn = ratio >= nearThreshold && len < max;
    var full = len >= max;
    counter.classList.toggle('is-visible', ratio >= nearThreshold);
    counter.classList.toggle('is-warn', warn);
    counter.classList.toggle('is-full', full);
    container.classList.toggle('mxchat-input-near-limit', warn);
    container.classList.toggle('mxchat-input-at-limit', full);
}

function mxchatBumpInput(inputEl) {
    var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
    if (!container) return;
    container.classList.remove('mxchat-input-bump');
    void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
    container.classList.add('mxchat-input-bump');
    clearTimeout($(container).data('mxchatBumpTimeout'));
    var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
    $(container).data('mxchatBumpTimeout', t);
}

// Live counter update on every input.
$(document).on('input', '.chat-input', function() {
    mxchatUpdateCharCounter(this);
});

// Visible "you've hit the edge" feedback when a printable keystroke is about to be
// rejected at the cap (maxlength silently swallows it otherwise).
$(document).on('keydown', '.chat-input', function(e) {
    var max = parseInt(this.getAttribute('maxlength'), 10);
    if (!max || max <= 0 || this.value.length < max) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    // A single printable char with no selection to overwrite WILL be rejected.
    if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
        mxchatBumpInput(this);
    }
});

// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
$(document).on('paste', '.chat-input', function() {
    var el = this;
    var max = parseInt(el.getAttribute('maxlength'), 10);
    if (!max || max <= 0) return;
    setTimeout(function() {
        mxchatUpdateCharCounter(el);
        if (el.value.length >= max) mxchatBumpInput(el);
    }, 0);
});

// Builds the list of overflow-menu items for a given bot.
// Adding a future item is one push to this array — do NOT hardcode "only download."
function mxchatGetHeaderMenuItems(botId) {
    var items = [];
    var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};

    // The `print_button_*` keys still gate this item for back-compat with
    // existing user options. The action is now a transcript download, not print.
    if (settings.print_button_enabled === 'on') {
        items.push({
            id: 'download-transcript',
            label: settings.print_button_label || 'Download Transcript',
            icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
            action: function() {
                mxchatDownloadTranscript(botId);
            }
        });
    }

    // "Start new chat" — surfaces the EXISTING per-conversation reset
    // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
    // without the site owner disabling chat persistence globally. Default OFF;
    // gated by the reset_chat_enabled option. plan ac2e81.
    if (settings.reset_chat_enabled === 'on') {
        items.push({
            id: 'reset-chat',
            label: settings.reset_chat_label || 'Start new chat',
            icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
            action: function() {
                var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
                if (window.confirm(confirmMsg)) {
                    MxChatInstances.resetChatSession(botId);
                }
            }
        });
    }

    return items;
}

// Builds a clean markdown transcript of the current conversation and triggers
// a file download. Used by the "Download Transcript" menu item.
function mxchatDownloadTranscript(botId) {
    var $chatBox = getElement(botId, 'chat-box');
    if (!$chatBox || !$chatBox.length) return;

    var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
    var headerTitle = settings.print_header_title || 'Chat transcript';
    var now = new Date();
    var stamp = now.toLocaleString();

    var lines = [];
    lines.push('# ' + headerTitle);
    lines.push('');
    lines.push('Exported: ' + stamp);
    lines.push('');
    lines.push('---');
    lines.push('');

    $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
        var $msg = $(this);
        // Skip thinking placeholders and any in-flight temporary messages.
        if ($msg.find('.thinking-dots').length) return;
        if ($msg.hasClass('temporary-message')) return;

        var sender;
        if ($msg.hasClass('user-message')) sender = 'User';
        else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
        else sender = 'AI Agent';

        // Strip interactive UI from the cloned message so we get the conversation text.
        var $clone = $msg.clone();
        $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
        var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
        if (!text) return;

        lines.push('**' + sender + '**');
        lines.push('');
        lines.push(text);
        lines.push('');
    });

    var content = lines.join('\n');
    var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
    var fname = 'mxchat-transcript-' + iso + '.md';
    var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
    var url = URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.href = url;
    a.download = fname;
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    setTimeout(function() {
        if (a.parentNode) a.parentNode.removeChild(a);
        URL.revokeObjectURL(url);
    }, 100);
}

// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
// on the menu wrap, so the dropdown matches whatever paints the bubble —
// saved options, AI theme CSS, or the mxchat-theme add-on.
function mxchatSyncMenuColors(botId, $wrap) {
    if (!$wrap || !$wrap.length) return;
    var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
    if (!$bot.length) return;
    var cs = window.getComputedStyle($bot[0]);
    if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
        $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
    }
    // Bot text color usually lives on a child div, not .bot-message itself.
    var $textChild = $bot.find('[style*="color"]').first();
    var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
    if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
}

// Renders (or re-renders) the item list for one menu wrap. Split out of
// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
// rebuild items + trigger visibility WITHOUT re-binding the one-time
// open/close/keyboard wiring. closeMenu is passed in by the init closure;
// a rebuild before init (never happens, but harmless) just skips it.
function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
    var $trigger = $wrap.find('.mxchat-menu-trigger');
    var $menu = $wrap.find('.mxchat-header-menu');
    var items = mxchatGetHeaderMenuItems(botId);

    $menu.empty();

    if (!items.length) {
        $trigger.hide();
        $menu.hide();
        return;
    }

    // Clear any inline display:none a previous zero-item render left behind —
    // open/close visibility is governed by the hidden prop + is-open class.
    $trigger.css('display', '');
    $menu.css('display', '');

    items.forEach(function(item, idx) {
        var $btn = $('<button>', {
            type: 'button',
            'class': 'mxchat-menu-item',
            'role': 'menuitem',
            'tabindex': '-1',
            'data-menu-id': item.id,
            html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
                  '<span class="mxchat-menu-item-label"></span>'
        });
        $btn.find('.mxchat-menu-item-label').text(item.label);
        $btn.on('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            if (closeMenuFn) closeMenuFn();
            try { item.action(); } catch (err) { /* no-op */ }
        });
        $menu.append($btn);
    });
}

// Re-render every menu on the page after a dynamic-settings merge
// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
// swapping items under the user mid-interaction yanks focus — and the
// rebuild runs when it closes instead (closeMenu checks the pending flag).
function mxchatRebuildHeaderMenus() {
    $('.mxchat-header-menu-wrap').each(function() {
        var $wrap = $(this);
        var botId = $wrap.data('bot-id');
        if (!botId) return;
        if (!$wrap.data('mxchatMenuReady')) {
            mxchatInitHeaderMenu(botId);
            return;
        }
        if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
            $wrap.data('mxchatMenuRebuildPending', true);
            return;
        }
        mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
    });
}

// One-time per-widget init: renders menu items, wires open/close,
// outside-click, Escape, and arrow-key navigation. If no items, hides the
// trigger. Wiring happens even when there are zero items at init, so a
// later dynamic-settings rebuild that adds items has a working trigger.
function mxchatInitHeaderMenu(botId) {
    var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
    if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;

    var $trigger = $wrap.find('.mxchat-menu-trigger');
    var $menu = $wrap.find('.mxchat-header-menu');

    // Initial color sync — covers normal page load.
    mxchatSyncMenuColors(botId, $wrap);

    function openMenu() {
        // Re-sync each open in case the active theme changed since init.
        mxchatSyncMenuColors(botId, $wrap);
        $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
        $trigger.attr('aria-expanded', 'true');
        // Focus the first item for keyboard users
        setTimeout(function() {
            $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
        }, 0);
    }
    function closeMenu(returnFocus) {
        $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
        $trigger.attr('aria-expanded', 'false');
        $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
        if (returnFocus) $trigger.trigger('focus');
        // A dynamic-settings rebuild that arrived while the menu was open
        // was deferred (mxchatRebuildHeaderMenus) — run it now.
        if ($wrap.data('mxchatMenuRebuildPending')) {
            $wrap.removeData('mxchatMenuRebuildPending');
            mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
        }
    }

    // Toggle on trigger click — stop propagation so the .chatbot-top-bar
    // click-to-collapse handler does not fire.
    $trigger.on('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if ($menu.hasClass('is-open')) closeMenu();
        else openMenu();
    });

    // Don't let clicks inside the menu bubble to the top-bar collapse handler.
    $menu.on('click', function(e) {
        e.stopPropagation();
    });

    // Outside click closes the menu.
    $(document).on('click.mxchatMenu-' + botId, function(e) {
        if (!$menu.hasClass('is-open')) return;
        if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
        closeMenu();
    });

    // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
    $menu.on('keydown', '.mxchat-menu-item', function(e) {
        var $items = $menu.find('.mxchat-menu-item');
        var idx = $items.index(this);
        if (e.key === 'Escape') {
            e.preventDefault();
            closeMenu(true);
        } else if (e.key === 'ArrowDown') {
            e.preventDefault();
            var $next = $items.eq((idx + 1) % $items.length);
            $items.attr('tabindex', '-1');
            $next.attr('tabindex', '0').trigger('focus');
        } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
            $items.attr('tabindex', '-1');
            $prev.attr('tabindex', '0').trigger('focus');
        } else if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            $(this).trigger('click');
        }
    });
    $trigger.on('keydown', function(e) {
        if (e.key === 'Escape' && $menu.hasClass('is-open')) {
            e.preventDefault();
            closeMenu(true);
        } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
            e.preventDefault();
            openMenu();
        }
    });

    // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
    // then do the initial item render.
    $wrap.data('mxchatMenuClose', closeMenu);
    mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);

    $wrap.data('mxchatMenuReady', true);
}

// Initialize header menus for every rendered widget on DOM ready.
$(function() {
    $('.mxchat-header-menu-wrap').each(function() {
        var botId = $(this).data('bot-id');
        if (botId) mxchatInitHeaderMenu(botId);
    });

    // Embedded (non-floating) widgets are open from the moment the page
    // renders — refresh dynamic settings at init (plan-32db95). Floating
    // widgets refresh on first launcher open instead.
    var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
        return !$(this).closest('.floating-chatbot').length;
    }).length > 0;
    if (hasEmbeddedWidget) {
        mxchatRefreshDynamicSettings();
    }
});

function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
    try {
        // Determine styles based on sender type
        let messageClass, bgColor, fontColor;

        if (sender === "user") {
            messageClass = "user-message";
            bgColor = userMessageBgColor;
            fontColor = userMessageFontColor;
            // Only sanitize user input
            messageText = sanitizeUserInput(messageText);
        } else if (sender === "agent") {
            messageClass = "agent-message";
            bgColor = liveAgentMessageBgColor;
            fontColor = liveAgentMessageFontColor;
        } else {
            messageClass = "bot-message";
            bgColor = botMessageBgColor;
            fontColor = botMessageFontColor;
        }

        const messageDiv = $('<div>')
            .addClass(messageClass)
            .attr('dir', 'auto');

        // Only apply inline colors if AI theme is not active (let CSS handle it)
        var skipColors = shouldSkipInlineColors(botId);
        if (skipColors) {
            messageDiv.css({
                'margin-bottom': '1em'
            });
        } else {
            messageDiv.css({
                'background': bgColor,
                'color': fontColor,
                'margin-bottom': '1em'
            });
        }

        // Process the message content - always run linkify to convert markdown
        // links and format text. linkify() handles existing HTML safely via
        // negative lookaheads that skip URLs already inside <a> tags.
        let fullMessage = linkify(messageText);

        // Add images if provided
        if (images && images.length > 0) {
            fullMessage += '<div class="image-gallery" dir="auto">';
            images.forEach(img => {
                const safeTitle = sanitizeUserInput(img.title);
                const safeUrl = encodeURI(img.image_url);
                const safeThumbnail = encodeURI(img.thumbnail_url);

                fullMessage += `
                    <div style="margin-bottom: 10px;">
                        <strong>${safeTitle}</strong><br>
                        <a href="${safeUrl}" target="_blank">
                            <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
                        </a>
                    </div>`;
            });
            fullMessage += '</div>';
        }

        // Append HTML content if provided
        if (messageHtml && sender !== "user") {
            // Only add line breaks if there's actual text content before the HTML
            if (fullMessage && fullMessage.trim()) {
                fullMessage += '<br><br>' + messageHtml;
            } else {
                fullMessage = messageHtml;
            }
        }

        messageDiv.html(fullMessage);

        if (isTemporary) {
            messageDiv.addClass('temporary-message');
        }

        // Append to the correct chatbot instance's chat-box
        var $chatBox = getElement(botId, 'chat-box');
        messageDiv.hide().appendTo($chatBox).fadeIn(300, function() {
            // FIXED: Use event delegation for link tracking
            if (sender === "bot" || sender === "agent") {
                attachLinkTracking(messageDiv, messageText, botId);
            }

            if (sender === "bot") {
                const lastUserMessage = $chatBox.find('.user-message').last();
                if (lastUserMessage.length) {
                    scrollElementToTop(lastUserMessage, botId);
                }
            }

            if ((sender === "bot" || sender === "agent") && !isTemporary) {
                if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
            }
        });

        if (messageText.id) {
            var instance = MxChatInstances.get(botId);
            instance.lastSeenMessageId = messageText.id;
            hideNotification(botId);
        }
    } catch (error) {
        // Error rendering message - silently continue
    }
}

//   Helper function to attach link tracking with proper event handling
function attachLinkTracking(messageDiv, messageText, botId) {
    botId = botId || 'default';
    // Use a slight delay to ensure DOM is ready
    setTimeout(function() {
        const links = messageDiv.find('a[href]').not('[data-tracked]');

        links.each(function() {
            const $link = $(this);
            const originalHref = $link.attr('href');

            // Mark as tracked to avoid duplicate handlers
            $link.attr('data-tracked', 'true');

            // Only track external URLs
            if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
                // Remove any existing click handlers first
                $link.off('click.tracking');

                // Add new click handler with namespace
                $link.on('click.tracking', function(e) {
                    e.preventDefault();
                    e.stopPropagation();

                    const messageContext = typeof messageText === 'string'
                        ? messageText.substring(0, 200)
                        : '';

                    // Track the click
                    $.ajax({
                        url: mxchatChat.ajax_url,
                        type: 'POST',
                        data: {
                            action: 'mxchat_track_url_click',
                            session_id: getChatSession(botId),
                            url: originalHref,
                            message_context: messageContext,
                            nonce: mxchatChat.nonce
                        },
                        complete: function() {
                            // Always redirect, even if tracking fails
                            if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
                                window.open(originalHref, '_blank');
                            } else {
                                window.location.href = originalHref;
                            }
                        }
                    });

                    return false; // Extra insurance to prevent default
                });
            }
        });
    }, 100); // Small delay to ensure DOM is ready
}

function replaceLastMessage(sender, responseText, responseHtml = '', images = [], botId = 'default') {
    var messageClass = sender === "user" ? "user-message" : sender === "agent" ? "agent-message" : "bot-message";
    var $chatBox = getElement(botId, 'chat-box');
    var lastMessageDiv = $chatBox.find('.bot-message.temporary-message, .agent-message.temporary-message').last();

    // Determine styles
    let bgColor, fontColor;
    if (sender === "user") {
        bgColor = userMessageBgColor;
        fontColor = userMessageFontColor;
    } else if (sender === "agent") {
        bgColor = liveAgentMessageBgColor;
        fontColor = liveAgentMessageFontColor;
    } else {
        bgColor = botMessageBgColor;
        fontColor = botMessageFontColor;
    }

    // Always run linkify to convert markdown links and format text.
    // linkify() already handles existing HTML (its URL patterns use negative lookaheads
    // to avoid double-processing URLs that are already inside <a> tags).
    var fullMessage = linkify(responseText);

    if (responseHtml) {
        // Only add line breaks if there's actual text content before the HTML
        if (fullMessage && fullMessage.trim()) {
            fullMessage += '<br><br>' + responseHtml;
        } else {
            fullMessage = responseHtml;
        }
    }

    if (images.length > 0) {
        fullMessage += '<div class="image-gallery" dir="auto">';
        images.forEach(img => {
            fullMessage += `
                <div style="margin-bottom: 10px;">
                    <strong>${img.title}</strong><br>
                    <a href="${img.image_url}" target="_blank">
                        <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
                    </a>
                </div>`;
        });
        fullMessage += '</div>';
    }

    if (lastMessageDiv.length) {
        // Replace content immediately to prevent visual gap between thinking dots and response
        lastMessageDiv
            .html(fullMessage)
            .removeClass('bot-message user-message temporary-message')
            .addClass(messageClass)
            .attr('dir', 'auto');

        // Only apply inline colors if AI theme is not active (let CSS handle it)
        var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
        if (!skipColors) {
            lastMessageDiv.css({
                'background-color': bgColor,
                'color': fontColor,
            });
        }

        // Handle link tracking and scroll
        if (sender === "bot" || sender === "agent") {
            attachLinkTracking(lastMessageDiv, responseText, botId);

            const lastUserMessage = $chatBox.find('.user-message').last();
            if (lastUserMessage.length) {
                scrollElementToTop(lastUserMessage, botId);
            }
            // Show notification if chat is hidden
            var $floatingChatbot = getElement(botId, 'floating-chatbot');
            if ($floatingChatbot.hasClass('hidden')) {
                showNotification(botId);
            }
        }

        // Re-enable chat input after response is displayed
        enableChatInput(botId);

        if (sender === "bot" || sender === "agent") {
            if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
        }
    } else {
        appendMessage(sender, responseText, responseHtml, images, false, botId);
        // Re-enable chat input after response is displayed
        enableChatInput(botId);
    }
}


    function appendThinkingMessage(botId) {
        botId = botId || 'default';

        // Don't show thinking dots in live agent mode - message is just forwarded to a human
        var indicator = getElementDOM(botId, 'chat-mode-indicator');
        if (indicator && indicator.textContent === 'Live Agent') {
            return;
        }

        var $chatBox = getElement(botId, 'chat-box');

        // Remove any existing thinking dots in this bot's chat first
        $chatBox.find('.thinking-dots').remove();

        // Check if we should skip inline colors (AI theme is active)
        var skipColors = shouldSkipInlineColors(botId);

        // Retrieve the bot message font color and background color
        var botMessageFontColor = mxchatChat.bot_message_font_color;
        var botMessageBgColor = mxchatChat.bot_message_bg_color;

        // Build thinking dots HTML - skip inline colors if AI theme is active
        var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
        var thinkingHtml = '<div class="thinking-dots-container">' +
                           '<div class="thinking-dots">' +
                           '<span class="dot"' + dotStyle + '></span>' +
                           '<span class="dot"' + dotStyle + '></span>' +
                           '<span class="dot"' + dotStyle + '></span>' +
                           '</div>' +
                           '</div>';

        // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
        var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
        $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
        scrollToBottom(botId);
    }

    function removeThinkingDots(botId) {
        botId = botId || 'default';
        var $chatBox = getElement(botId, 'chat-box');
        // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
        $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
        $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
    }

    // ====================================
    // TEXT FORMATTING & PROCESSING
    // ====================================
    
    function linkify(inputText) {
    if (!inputText) {
        return '';
    }
    
    // Helper function to check if URL is already encoded
    function isUrlEncoded(url) {
        // Check for % followed by exactly 2 hex digits
        return /%[0-9a-fA-F]{2}/.test(url);
    }
    
    // Helper function to safely encode URLs only if needed
    function safeEncodeUrl(url) {
        // If URL already contains encoded characters, return as-is
        if (isUrlEncoded(url)) {
            return url;
        }
        // Otherwise, encode it
        return encodeURI(url);
    }
    
    // Process markdown headers FIRST
    let processedText = formatMarkdownHeaders(inputText);
    
    // Process text styling (bold, italic, strikethrough)
    processedText = formatTextStyling(processedText);
    
    // Process code blocks BEFORE processing links
    processedText = formatCodeBlocks(processedText);

    // Process markdown tables BEFORE converting newlines to paragraphs
    processedText = formatMarkdownTables(processedText);

    // NOW convert to paragraphs
    processedText = convertNewlinesToBreaks(processedText);
    
    // IMPORTANT: Handle citation-style brackets FIRST [URL] 
    // This prevents them from being processed as markdown links
    // Match [URL] where URL is a complete URL in square brackets (common in AI citations)
    processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
        // Clean the URL of any trailing punctuation
        let cleanUrl = url.replace(/[.,;!?]+$/, '');
        const safeUrl = safeEncodeUrl(cleanUrl);
        // Return as a proper link without the brackets
        return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
    });
    
    // Process markdown links: [text](url) and [](url)
    // Uses balanced parenthesis matching to handle URLs containing parens
    // (e.g. PDF filenames with dates like (2025-08-28).pdf)
    processedText = (function(input) {
        var result = '';
        var i = 0;
        while (i < input.length) {
            // Look for [ at current position
            if (input[i] === '[') {
                // Find closing ]
                var closeBracket = input.indexOf(']', i + 1);
                if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
                    result += input[i];
                    i++;
                    continue;
                }
                var linkText = input.substring(i + 1, closeBracket);
                // Check if URL starts with http
                var urlStart = closeBracket + 2;
                if (!input.substring(urlStart).match(/^https?:\/\//)) {
                    result += input[i];
                    i++;
                    continue;
                }
                // Find balanced closing paren
                var depth = 1;
                var j = urlStart;
                while (j < input.length && depth > 0) {
                    if (input[j] === '(') depth++;
                    else if (input[j] === ')') depth--;
                    if (depth > 0) j++;
                }
                if (depth !== 0) {
                    result += input[i];
                    i++;
                    continue;
                }
                var url = input.substring(urlStart, j);
                var cleanUrl = url.replace(/[\].,;!?]+$/, '');
                var encodedUrl = safeEncodeUrl(cleanUrl);
                if (!linkText || !linkText.trim()) {
                    result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
                } else {
                    var safeText = sanitizeUserInput(linkText);
                    result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
                }
                i = j + 1; // Skip past the closing )
            } else {
                result += input[i];
                i++;
            }
        }
        return result;
    })(processedText);
    
    // Process phone numbers: [text](tel:number)
    const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
    processedText = processedText.replace(phonePattern, (match, text, phone) => {
        const safePhone = safeEncodeUrl(phone);
        const safeText = sanitizeUserInput(text);
        return `<a href="${safePhone}">${safeText}</a>`;
    });
    
    // Process mailto links: [text](mailto:email)
    const mailtoPattern = /\[([^\]]+)\]\((mailto:[^\)]+)\)/g;
    processedText = processedText.replace(mailtoPattern, (match, text, mailto) => {
        const safeMailto = safeEncodeUrl(mailto);
        const safeText = sanitizeUserInput(text);
        return `<a href="${safeMailto}">${safeText}</a>`;
    });
    
    // Process standalone URLs - but NOT if they're already in <a> tags or brackets
    // Updated pattern to be more careful about what it matches
    const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
    processedText = processedText.replace(urlPattern, (match, prefix, url) => {
        // Extra check: make sure this isn't already linked
        if (match.includes('href=') || match.includes('</a>')) {
            return match;
        }
        
        // Clean trailing punctuation
        let cleanUrl = url.replace(/[.,;!?)]+$/, '');
        const safeUrl = safeEncodeUrl(cleanUrl);
        return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
    });
    
    // Process www. URLs - but NOT if they're already in <a> tags or brackets
    const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim;
    processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
        // Extra check: make sure this isn't already linked
        if (match.includes('href=') || match.includes('</a>')) {
            return match;
        }
        
        // Clean trailing punctuation
        let cleanUrl = url.replace(/[.,;!?)]+$/, '');
        const safeUrl = safeEncodeUrl(`http://${cleanUrl}`);
        return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
    });
    
    return processedText;
}
     
    function formatMarkdownHeaders(text) {
        // Handle h1 to h6 headers
        return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
            const level = hashes.length;
            return `<h${level} class="chat-heading chat-heading-${level}">${content.trim()}</h${level}>`;
        });
    }
    
function formatTextStyling(text) {
    // IMPORTANT: Protect BOTH HTML href and Markdown URLs from formatting
    const protectedSegments = [];
    let protectedText = text;
    
    // Step 1a: Protect HTML href="..." attributes
    protectedText = protectedText.replace(/href\s*=\s*["']([^"']+)["']/gi, function(match) {
        const placeholder = `__PROTECTED_${protectedSegments.length}__`;
        protectedSegments.push(match);
        return placeholder;
    });
    
    // Step 1b: Protect Markdown links [text](url)
    // This is crucial - we need to protect the URLs in markdown format
    protectedText = protectedText.replace(/\[([^\]]*)\]\(([^)]+)\)/g, function(match) {
        const placeholder = `__PROTECTED_${protectedSegments.length}__`;
        protectedSegments.push(match);
        return placeholder;
    });
    
    // Step 1c: Also protect bare URLs that might exist
    protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
        const placeholder = `__PROTECTED_${protectedSegments.length}__`;
        protectedSegments.push(match);
        return placeholder;
    });
    
    // Step 2: Now apply text styling to the protected text
    // Handle bold text (**text**)
    protectedText = protectedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');

    // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
    // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
    protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');

    // Handle underscores for italic - Safari-compatible (no lookbehind)
    // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
    protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
    
    // Handle strikethrough (~~text~~)
    protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
    
    // Step 3: Restore all protected segments
    protectedSegments.forEach((original, index) => {
        const placeholder = `__PROTECTED_${index}__`;
        protectedText = protectedText.replace(placeholder, original);
    });
    
    return protectedText;
}
    function formatBoldText(text) {
        // This function is kept for compatibility but now uses formatTextStyling
        return formatTextStyling(text);
    }
    
function convertNewlinesToBreaks(text) {
    // Split the text into paragraphs (marked by double newlines or multiple <br> tags)
    const paragraphs = text.split(/(?:\n\n|\<br\>\s*\<br\>)/g);
    
    // Filter out empty paragraphs and wrap each paragraph in <p> tags
    return paragraphs
        .map(para => para.trim())
        .filter(para => para.length > 0) // Remove empty paragraphs
        .map(para => `<p>${para}</p>`)
        .join('');
}
    function formatCodeBlocks(text) {
        // Handle fenced code blocks with language specification (```language)
        text = text.replace(/```(\w+)?\n?([\s\S]*?)```/g, (match, language, code) => {
            const lang = language || 'text';
            const escapedCode = escapeHtml(code.trim());
            return `<div class="mxchat-code-block-container">
                <div class="mxchat-code-header">
                    <span class="mxchat-code-language">${lang}</span>
                    <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
                </div>
                <pre class="mxchat-code-block"><code class="language-${lang}">${escapedCode}</code></pre>
            </div>`;
        });

        // Handle inline code with single backticks
        text = text.replace(/`([^`\n]+)`/g, '<code class="mxchat-inline-code">$1</code>');

        // Handle raw PHP tags (legacy support)
        text = text.replace(/(<\?php[\s\S]*?\?>)/g, (match) => {
            const escapedCode = escapeHtml(match);
            return `<div class="mxchat-code-block-container">
                <div class="mxchat-code-header">
                    <span class="mxchat-code-language">php</span>
                    <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
                </div>
                <pre class="mxchat-code-block"><code class="language-php">${escapedCode}</code></pre>
            </div>`;
        });

        return text;
    }

    function formatMarkdownTables(text) {
        var lines = text.split('\n');
        var result = [];
        var i = 0;

        while (i < lines.length) {
            // Check for a table: current line has pipes AND next line is a separator row
            if (i + 1 < lines.length &&
                lines[i].indexOf('|') !== -1 &&
                /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {

                var tableLines = [];
                var headerLine = lines[i];
                var separatorLine = lines[i + 1];
                tableLines.push(headerLine);
                tableLines.push(separatorLine);

                // Collect remaining table rows
                var j = i + 2;
                while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
                    tableLines.push(lines[j]);
                    j++;
                }

                // Parse alignment from separator row
                var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
                var alignments = sepCells.map(function(cell) {
                    var trimmed = cell.trim();
                    if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
                    if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
                    return 'left';
                });

                // Build HTML table
                var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';

                // Header row
                var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
                html += '<thead><tr>';
                headerCells.forEach(function(cell, idx) {
                    var align = alignments[idx] || 'left';
                    html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
                });
                html += '</tr></thead>';

                // Body rows
                html += '<tbody>';
                for (var r = 2; r < tableLines.length; r++) {
                    var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
                    html += '<tr>';
                    rowCells.forEach(function(cell, idx) {
                        var align = alignments[idx] || 'left';
                        html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
                    });
                    html += '</tr>';
                }
                html += '</tbody></table></div>';

                result.push(html);
                i = j;
            } else {
                result.push(lines[i]);
                i++;
            }
        }

        return result.join('\n');
    }

    function sanitizeUserInput(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    function escapeHtml(unsafe) {
        // Skip escaping if it's already escaped or contains HTML code block markup
        if (unsafe.includes('&lt;') || unsafe.includes('&gt;') || 
            unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
            return unsafe;
        }
        
        return unsafe
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
    }
    
    function decodeHTMLEntities(text) {
        var textArea = document.createElement('textarea');
        textArea.innerHTML = text;
        return textArea.value;
    }

    // ====================================
    // UI & SCROLLING CONTROLS
    // ====================================
    
    function scrollToBottom(botIdOrInstant, instant) {
        // Handle backward compatibility: scrollToBottom() or scrollToBottom(true/false)
        var botId = 'default';
        if (typeof botIdOrInstant === 'string') {
            botId = botIdOrInstant;
            instant = instant || false;
        } else if (typeof botIdOrInstant === 'boolean') {
            instant = botIdOrInstant;
        } else {
            instant = false;
        }

        var chatBox = getElement(botId, 'chat-box');
        if (instant) {
            // Instantly set the scroll position to the bottom
            chatBox.scrollTop(chatBox.prop("scrollHeight"));
        } else {
            // Use requestAnimationFrame for smoother scrolling if needed
            let start = null;
            const scrollHeight = chatBox.prop("scrollHeight");
            const initialScroll = chatBox.scrollTop();
            const distance = scrollHeight - initialScroll;
            const duration = 500; // Duration in ms

            function smoothScroll(timestamp) {
                if (!start) start = timestamp;
                const progress = timestamp - start;
                const currentScroll = initialScroll + (distance * (progress / duration));
                chatBox.scrollTop(currentScroll);

                if (progress < duration) {
                    requestAnimationFrame(smoothScroll);
                } else {
                    chatBox.scrollTop(scrollHeight); // Ensure it's exactly at the bottom
                }
            }

            requestAnimationFrame(smoothScroll);
        }
    }

    function scrollElementToTop(element, botId, topOffset) {
        botId = botId || 'default';
        topOffset = (typeof topOffset === 'number') ? topOffset : 2;
        var chatBox = getElement(botId, 'chat-box');
        var elementTop = element.position().top + chatBox.scrollTop();
        chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
    }

    function showChatWidget(botId) {
        botId = botId || 'default';
        var $button = getElement(botId, 'floating-chatbot-button');
        // First ensure display is set
        $button.css('display', 'flex');
        // Then handle the fade
        $button.fadeTo(500, 1);
        // Force visibility
        $button.removeClass('hidden');
    }

    function hideChatWidget(botId) {
        botId = botId || 'default';
        var $button = getElement(botId, 'floating-chatbot-button');
        $button.css('display', 'none');
        $button.addClass('hidden');
    }
    
    function disableScroll() {
        if (isMobile()) {
            $('body').css('overflow', 'hidden');
        }
    }
    
    function enableScroll() {
        if (isMobile()) {
            $('body').css('overflow', '');
        }
    }
    
    function isMobile() {
        // This can be a simple check, or more sophisticated detection of mobile devices
        return window.innerWidth <= 768; // Example threshold for mobile devices
    }
    
    function setFullHeight() {
        var vh = $(window).innerHeight() * 0.01;
        $(':root').css('--vh', vh + 'px');
    }


    // ====================================
    // NOTIFICATION SYSTEM
    // ====================================
    
    function createNotificationBadge() {
        const chatButton = document.getElementById('floating-chatbot-button');

        if (!chatButton) return;

        // Remove any existing badge first
        const existingBadge = chatButton.querySelector('.chat-notification-badge');
        if (existingBadge) {
            existingBadge.remove();
        }
    
        notificationBadge = document.createElement('div');
        notificationBadge.className = 'chat-notification-badge';
        notificationBadge.style.cssText = `
            display: none;
            position: absolute;
            top: -5px;
            right: -5px;
            background-color: red;
            color: white;
            border-radius: 50%;
            padding: 4px 8px;
            font-size: 12px;
            font-weight: bold;
            z-index: 10001;
        `;
        chatButton.style.position = 'relative';
        chatButton.appendChild(notificationBadge);
        
    }
    
    function showNotification(botId) {
        botId = botId || 'default';
        const badge = getElementDOM(botId, 'chat-notification-badge');
        var $floatingChatbot = getElement(botId, 'floating-chatbot');
        if (badge && $floatingChatbot.hasClass('hidden')) {
            badge.style.display = 'block';
            badge.textContent = '1';
        }
    }

    function hideNotification(botId) {
        botId = botId || 'default';
        const badge = getElementDOM(botId, 'chat-notification-badge');
        if (badge) {
            badge.style.display = 'none';
        }
    }

    function startNotificationChecking(botId) {
        botId = botId || 'default';
        const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
        if (!chatPersistenceEnabled) return;

        createNotificationBadge(botId);
        var instance = MxChatInstances.get(botId);
        instance.notificationCheckInterval = setInterval(function() {
            checkForNewMessages(botId);
        }, 30000); // Check every 30 seconds
    }

    function stopNotificationChecking(botId) {
        botId = botId || 'default';
        var instance = MxChatInstances.get(botId);
        if (instance.notificationCheckInterval) {
            clearInterval(instance.notificationCheckInterval);
        }
    }
    
    function checkForNewMessages() {
        const sessionId = getChatSession();
        const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
        
        if (!chatPersistenceEnabled) return;
    
        $.ajax({
            url: mxchatChat.ajax_url,
            type: 'POST',
            data: {
                action: 'mxchat_check_new_messages',
                session_id: sessionId,
                last_seen_id: lastSeenMessageId,
                nonce: mxchatChat.nonce
            },
            success: function(response) {
                if (response.success && response.data.hasNewMessages) {
                    showNotification();
                }
            }
        });
    }


// ====================================
// LIVE AGENT FUNCTIONALITY
// ====================================

function startPolling(botId) {
    botId = botId || 'default';
    var instance = MxChatInstances.get(botId);
    // Clear any existing interval first
    stopPolling(botId);
    instance.pollingInterval = setInterval(function() {
        checkForAgentMessages(botId);
    }, 5000);
}

function stopPolling(botId) {
    botId = botId || 'default';
    var instance = MxChatInstances.get(botId);
    if (instance.pollingInterval) {
        clearInterval(instance.pollingInterval);
        instance.pollingInterval = null;
    }
}

function checkForAgentMessages(botId) {
    botId = botId || 'default';
    var instance = MxChatInstances.get(botId);
    const sessionId = getChatSession(botId);
    $.ajax({
        url: mxchatChat.ajax_url,
        type: 'POST',
        dataType: 'json',
        data: {
            action: 'mxchat_fetch_new_messages',
            session_id: sessionId,
            last_seen_id: instance.lastSeenMessageId,
            persistence_enabled: 'true',
            nonce: mxchatChat.nonce
        },
        success: function (response) {
            if (response.success && response.data?.new_messages) {
                let hasNewMessage = false;

                response.data.new_messages.forEach(function (message) {
                    if (message.role === "agent" && !instance.processedMessageIds.has(message.id)) {
                        hasNewMessage = true;
                        appendMessage("agent", message.content, '', [], false, botId);
                        instance.lastSeenMessageId = message.id;
                        instance.processedMessageIds.add(message.id);
                    }
                });

                if (hasNewMessage) {
                    enableChatInput(botId);
                }

                var $floatingChatbot = getElement(botId, 'floating-chatbot');
                if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
                    showNotification(botId);
                }

                scrollToBottom(botId, true);
            }

            // Handle chat mode transitions (e.g. agent ended chat via !endchat)
            if (response.success && response.data?.chat_mode) {
                updateChatModeIndicator(response.data.chat_mode, botId);
            }
        },
        error: function (xhr, status, error) {
            // Polling error - silently continue
        }
    });
}

    // ====================================
    // CHAT HISTORY & PERSISTENCE
    // ====================================

function loadChatHistory(botId, onComplete) {
    botId = botId || 'default';
    var instance = MxChatInstances.get(botId);

    // Prevent duplicate loading
    if (instance.chatHistoryLoaded) {
        if (onComplete) onComplete();
        return;
    }

    // Use getChatSession which returns null if no session exists (does NOT create one)
    var sessionId = getChatSession(botId);
    var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';

    // No session yet — nothing to load. History will load after first message via ensureSession.
    if (!sessionId) {
        instance.chatHistoryLoaded = true;
        if (onComplete) onComplete();
        return;
    }

    if (chatPersistenceEnabled && sessionId) {
        $.ajax({
            url: mxchatChat.ajax_url,
            type: 'POST',
            dataType: 'json',
            data: {
                action: 'mxchat_fetch_conversation_history',
                session_id: sessionId
            },
            success: function(response) {
                // Handle session reset (IP changed while user was away)
                if (response.success === false && response.data && response.data.action === 'reset_session') {
                    // Silent reset — new session but don't clear UI
                    MxChatInstances.silentResetSession(botId);
                    instance.chatHistoryLoaded = true; // Prevent retry loop
                    if (onComplete) onComplete();
                    return;
                }

                // Check if the response indicates success
                if (response.success) {
                    // Handle case where conversation data exists and is an array
                    if (response.data && Array.isArray(response.data.conversation)) {
                        var $chatBox = getElement(botId, 'chat-box');
                        var $fragment = $(document.createDocumentFragment());
                        let highestMessageId = instance.lastSeenMessageId;

                        // Update chat mode if provided
                        if (response.data.chat_mode) {
                            updateChatModeIndicator(response.data.chat_mode, botId);
                        }

                        // Only process if there are actual messages
                        if (response.data.conversation.length > 0) {
                            // IMPORTANT: Clear existing messages before loading history
                            $chatBox.empty();

                            $.each(response.data.conversation, function(index, message) {
                                // Skip agent messages if persistence is off
                                if (!chatPersistenceEnabled && message.role === 'agent') {
                                    return;
                                }

                                var messageClass, messageBgColor, messageFontColor;

                                switch (message.role) {
                                    case 'user':
                                        messageClass = 'user-message';
                                        messageBgColor = userMessageBgColor;
                                        messageFontColor = userMessageFontColor;
                                        break;
                                    case 'agent':
                                        messageClass = 'agent-message';
                                        messageBgColor = liveAgentMessageBgColor;
                                        messageFontColor = liveAgentMessageFontColor;
                                        break;
                                    default:
                                        messageClass = 'bot-message';
                                        messageBgColor = botMessageBgColor;
                                        messageFontColor = botMessageFontColor;
                                        break;
                                }

                                var messageElement = $('<div>').addClass(messageClass)
                                    .css({
                                        'background': messageBgColor,
                                        'color': messageFontColor
                                    });

                                var content = message.content;
                                content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
                                content = decodeHTMLEntities(content);

                                // Skip linkify for messages containing structured HTML
                                // (forms, product cards, galleries, etc.) to avoid
                                // markdown formatting corrupting HTML attributes
                                // (e.g. underscores in name="field_name" becoming <em> tags)
                                if (content.includes("mxchat-product-card") ||
                                    content.includes("mxchat-image-gallery") ||
                                    content.includes("mxchat-featured-products") ||
                                    content.includes("<form") ||
                                    content.includes("<input") ||
                                    content.includes("<select") ||
                                    content.includes("<textarea")) {
                                    messageElement.html(content);
                                } else {
                                    var formattedContent = linkify(content);
                                    messageElement.html(formattedContent);
                                }

                                $fragment.append(messageElement);

                                // Track message IDs
                                if (message.id) {
                                    highestMessageId = Math.max(highestMessageId, message.id);
                                    instance.processedMessageIds.add(message.id);
                                }
                            });

                            // Only append messages and scroll if we have content
                            $chatBox.append($fragment);
                            scrollToBottom(botId, true);

                            // Collapse quick questions if we have conversation history
                            // BUT skip auto-collapse for embedded bots (they should stay expanded)
                            if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
                                collapseQuickQuestions(botId);
                            }

                            // Update lastSeenMessageId after history loads
                            instance.lastSeenMessageId = highestMessageId;

                            // Only update chat mode if persistence is enabled and we have messages
                            if (chatPersistenceEnabled) {
                                var lastMessage = response.data.conversation[response.data.conversation.length - 1];
                                if (lastMessage.role === 'agent') {
                                    updateChatModeIndicator('agent', botId);
                                }
                            }

                            // Mark as loaded ONLY after successful load
                            instance.chatHistoryLoaded = true;
                        }
                    }
                }
                if (onComplete) onComplete();
            },
            error: function(xhr, status, error) {
                // Error loading chat history - silently continue
                if (onComplete) onComplete();
            }
        });
    } else {
        if (onComplete) onComplete();
    }
}


    // ====================================
    // FILE UPLOAD FUNCTIONALITY
    // ====================================
    
    function addSafeEventListener(elementId, eventType, handler) {
        const element = document.getElementById(elementId);
        if (element) {
            element.addEventListener(eventType, handler);
        }
    }
    
    function showActivePdf(filename, botId) {
        botId = botId || 'default';
        const container = getElementDOM(botId, 'active-pdf-container');
        const nameElement = getElementDOM(botId, 'active-pdf-name');

        if (!container || !nameElement) {
            return;
        }

        nameElement.textContent = filename;
        container.style.display = 'flex';
    }

    function showActiveWord(filename, botId) {
        botId = botId || 'default';
        const container = getElementDOM(botId, 'active-word-container');
        const nameElement = getElementDOM(botId, 'active-word-name');

        if (!container || !nameElement) {
            return;
        }

        nameElement.textContent = filename;
        container.style.display = 'flex';
    }

    function removeActivePdf(botId) {
        botId = botId || 'default';
        var instance = MxChatInstances.get(botId);
        const container = getElementDOM(botId, 'active-pdf-container');
        const nameElement = getElementDOM(botId, 'active-pdf-name');

        if (!container || !nameElement || !instance.activePdfFile) return;

        fetch(mxchatChat.ajax_url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                'action': 'mxchat_remove_pdf',
                'session_id': getChatSession(botId),
                'nonce': mxchatChat.nonce
            })
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                container.style.display = 'none';
                nameElement.textContent = '';
                instance.activePdfFile = null;
                appendMessage('bot', 'PDF removed.', '', [], false, botId);
            }
        })
        .catch(error => {
            // Error removing PDF - silently continue
        });
    }

    function removeActiveWord(botId) {
        botId = botId || 'default';
        var instance = MxChatInstances.get(botId);
        const container = getElementDOM(botId, 'active-word-container');
        const nameElement = getElementDOM(botId, 'active-word-name');

        if (!container || !nameElement || !instance.activeWordFile) return;

        fetch(mxchatChat.ajax_url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                'action': 'mxchat_remove_word',
                'session_id': getChatSession(botId),
                'nonce': mxchatChat.nonce
            })
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                container.style.display = 'none';
                nameElement.textContent = '';
                instance.activeWordFile = null;
                appendMessage('bot', 'Word document removed.', '', [], false, botId);
            }
        })
        .catch(error => {
            // Error removing Word document - silently continue
        });
    }

    // ====================================
    // CONSENT & COMPLIANCE (GDPR)
    // ====================================

    function initializeChatVisibility(botId) {
        botId = botId || 'default';
        const complianzEnabled = mxchatChat.complianz_toggle === 'on' ||
                                mxchatChat.complianz_toggle === '1' ||
                                mxchatChat.complianz_toggle === 1;

        if (complianzEnabled && typeof cmplz_has_consent === "function" && typeof complianz !== 'undefined') {
            // Initial check
            checkConsentAndShowChat(botId);

            // Listen for consent changes
            $(document).on('cmplz_status_change', function(event) {
                checkConsentAndShowChat(botId);
            });
        } else {
            // If Complianz is not enabled, always show
            getElement(botId, 'floating-chatbot-button')
                .css('display', 'flex')
                .removeClass('hidden no-consent')
                .fadeTo(500, 1);

            // Also check pre-chat message when Complianz is not enabled
            checkPreChatDismissal(botId);
        }
    }


    function checkConsentAndShowChat(botId) {
        botId = botId || 'default';
        var consentStatus = cmplz_has_consent('marketing');
        var consentType = complianz.consenttype;

        let $widget = getElement(botId, 'floating-chatbot-button');
        let $chatbot = getElement(botId, 'floating-chatbot');
        let $preChat = getElement(botId, 'pre-chat-message');

        if (consentStatus === true) {
            $widget
                .removeClass('no-consent')
                .css('display', 'flex')
                .removeClass('hidden')
                .fadeTo(500, 1);
            $chatbot.removeClass('no-consent');

            // Show pre-chat message if not dismissed
            checkPreChatDismissal(botId);
        } else {
            $widget
                .addClass('no-consent')
                .fadeTo(500, 0, function() {
                    $(this)
                        .css('display', 'none')
                        .addClass('hidden');
                });
            $chatbot.addClass('no-consent');

            // Hide pre-chat message when no consent
            $preChat.hide();
        }
    }


    // ====================================
    // PRE-CHAT MESSAGE HANDLING
    // ====================================

    function checkPreChatDismissal(botId) {
        botId = botId || 'default';
        try {
            var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
            if (dismissedAt) {
                // Re-show after 24 hours
                var elapsed = Date.now() - parseInt(dismissedAt, 10);
                if (elapsed < 86400000) {
                    getElement(botId, 'pre-chat-message').hide();
                    return;
                }
                // Expired — clear and show again
                localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
            }
            getElement(botId, 'pre-chat-message').fadeIn(250);
        } catch (e) {
            // localStorage unavailable — show the message
            getElement(botId, 'pre-chat-message').fadeIn(250);
        }
    }

    function handlePreChatDismissal(botId) {
        botId = botId || 'default';
        getElement(botId, 'pre-chat-message').fadeOut(200);
        try {
            localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
        } catch (e) {
            // localStorage unavailable — dismissal won't persist
        }
    }


    // ====================================
    // UTILITY FUNCTIONS
    // ====================================
    
    function copyToClipboard(text) {
        var tempInput = $('<input>');
        $('body').append(tempInput);
        tempInput.val(text).select();
        document.execCommand('copy');
        tempInput.remove();
    }

    
    function isImageHtml(str) {
        return str.startsWith('<img') && str.endsWith('>');
    }


    // ====================================
    // EVENT HANDLERS & INITIALIZATION
    // ====================================

$(document).on('click', '.mxchat-popular-question', function () {
    var question = $(this).text();
    var botId = getBotIdFromElement(this);

    // Append the question as if the user typed it
    appendMessage("user", question, '', [], false, botId);

    // Only collapse if there are questions
    if (hasQuickQuestions(botId)) {
        collapseQuickQuestions(botId);
    }

    // Send the question to the server
    sendMessageToChatbot(question, botId);
});

$(document).on('click', '.questions-toggle-btn', function(e) {
    e.preventDefault();
    e.stopPropagation();
    var botId = getBotIdFromElement(this);
    expandQuickQuestions(botId);
});

$(document).on('click', '.questions-collapse-btn', function(e) {
    e.preventDefault();
    e.stopPropagation();
    var botId = getBotIdFromElement(this);
    collapseQuickQuestions(botId);
});
    
    // Chatbot visibility toggle handlers - use class selector for multi-instance support
    // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
    $(document).on('click keydown', '.floating-chatbot-button', function(e) {
        if (e.type === 'keydown') {
            if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
            e.preventDefault();
        }
        var botId = getBotIdFromElement(this);
        var $chatbot = getElement(botId, 'floating-chatbot');
        var $badge = getElement(botId, 'chat-notification-badge');
        var $preChat = getElement(botId, 'pre-chat-message');

        if ($chatbot.hasClass('hidden')) {
            $chatbot.removeClass('hidden').addClass('visible')
                    .attr('aria-modal', 'true').attr('role', 'dialog');
            $(this).addClass('hidden').attr('aria-expanded', 'true');
            $badge.hide(); // Hide notification when opening chat
            disableScroll();
            $preChat.fadeOut(250);

            // First open per page load: re-fetch behavior settings in case
            // this page's inline values came from a stale full-page cache
            // (plan-32db95). Idempotent — later opens are a no-op.
            mxchatRefreshDynamicSettings();

            // Load chat history for returning visitors (persistence)
            var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
            if (chatPersistenceEnabled) {
                MxChatInstances.ensureSession(botId);
            }

            // Deferred email check — only on first widget open
            var emailBlocker = getElementDOM(botId, 'email-blocker');
            var instance = MxChatInstances.get(botId);
            if (emailBlocker && !instance.emailCheckDone) {
                instance.emailCheckDone = true;
                resolveEmailState(botId);
            } else if (!emailBlocker) {
                // No email collection — still route through showChatContainerForBot
                // so the loader is shown while chat history loads
                showChatContainerForBot(botId);
            }

            // Move keyboard focus into the message input after the open transition.
            setTimeout(function() {
                var chatInput = getElementDOM(botId, 'chat-input');
                if (chatInput && !chatInput.disabled) {
                    try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
                }
            }, 300);
        } else {
            $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
            $(this).removeClass('hidden').attr('aria-expanded', 'false');
            enableScroll();
            checkPreChatDismissal(botId);
        }
    });

    // Allow clicking anywhere on the title bar to close the chatbot.
    // Returns keyboard focus to the launcher so keyboard users don't get
    // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
    // heuristic-based so mouse-triggered close won't show a focus ring.
    $(document).on('click', '.chatbot-top-bar', function() {
        var botId = getBotIdFromElement(this);
        getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
        var $launcher = getElement(botId, 'floating-chatbot-button');
        $launcher.removeClass('hidden').attr('aria-expanded', 'false');
        enableScroll();
        try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
    });

    // Global Escape-key handler — closes any visible chat widget and
    // returns focus to its launcher. Standard modal-dismissal pattern;
    // pairs with aria-modal="true" set on the widget when it opens.
    $(document).on('keydown', function(e) {
        if (e.key !== 'Escape' && e.key !== 'Esc') return;
        var $visible = $('.floating-chatbot.visible');
        if (!$visible.length) return;
        e.preventDefault();
        $visible.each(function() {
            var botId = getBotIdFromElement(this);
            $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
            var $launcher = getElement(botId, 'floating-chatbot-button');
            $launcher.removeClass('hidden').attr('aria-expanded', 'false');
            try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
        });
        enableScroll();
    });

    $(document).on('click', '.close-pre-chat-message', function(e) {
        e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
        var botId = getBotIdFromElement(this);
        handlePreChatDismissal(botId);
    });


    // PDF upload button handlers - use class selector
    $(document).on('click', '.pdf-upload-btn', function() {
        var botId = getBotIdFromElement(this);
        var pdfInput = getElementDOM(botId, 'pdf-upload');
        if (pdfInput) pdfInput.click();
    });

    // Word upload button handlers - use class selector
    $(document).on('click', '.word-upload-btn', function() {
        var botId = getBotIdFromElement(this);
        var wordInput = getElementDOM(botId, 'word-upload');
        if (wordInput) wordInput.click();
    });
    
    // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
    $(document).on('change', '.pdf-upload', async function(e) {
        var botId = getBotIdFromElement(this);
        var instance = MxChatInstances.get(botId);
        const file = this.files[0];
        const sessionId = MxChatInstances.ensureSession(botId);

        if (!file || file.type !== 'application/pdf') {
            alert('Please select a valid PDF file.');
            return;
        }

        if (!sessionId) {
            alert('Error: No session ID found');
            return;
        }

        if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
            alert('Error: Ajax configuration missing');
            return;
        }

        // Disable buttons and show loading state
        const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
        const sendBtn = getElementDOM(botId, 'send-button');
        if (!uploadBtn) return;
        const originalBtnContent = uploadBtn.innerHTML;

        try {
            // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
            await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
            const formData = new FormData();
            formData.append('action', 'mxchat_upload_pdf');
            formData.append('pdf_file', file);
            formData.append('session_id', sessionId);
            formData.append('nonce', mxchatChat.nonce);

            uploadBtn.disabled = true;
            if (sendBtn) sendBtn.disabled = true;
            uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
                <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
            </svg>`;

            const response = await fetch(mxchatChat.ajax_url, {
                method: 'POST',
                body: formData
            });

            const data = await response.json();

            if (data.success) {
                // Hide popular questions if they exist
                if (hasQuickQuestions(botId)) {
                    collapseQuickQuestions(botId);
                }

                // Show the active PDF name
                showActivePdf(data.data.filename, botId);

                appendMessage('bot', data.data.message, '', [], false, botId);
                scrollToBottom(botId);
                instance.activePdfFile = data.data.filename;
            } else {
                alert('Failed to upload PDF. Please try again.');
            }
        } catch (error) {
            alert('Error uploading file. Please try again.');
        } finally {
            uploadBtn.disabled = false;
            if (sendBtn) sendBtn.disabled = false;
            uploadBtn.innerHTML = originalBtnContent;
            this.value = ''; // Reset file input
        }
    });
    
    // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
    $(document).on('change', '.word-upload', async function(e) {
        var botId = getBotIdFromElement(this);
        var instance = MxChatInstances.get(botId);
        const file = this.files[0];
        const sessionId = MxChatInstances.ensureSession(botId);

        if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
            alert('Please select a valid Word document (.docx).');
            return;
        }

        if (!sessionId) {
            alert('Error: No session ID found');
            return;
        }

        if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
            alert('Error: Ajax configuration missing');
            return;
        }

        // Disable buttons and show loading state
        const uploadBtn = getElementDOM(botId, 'word-upload-btn');
        const sendBtn = getElementDOM(botId, 'send-button');
        if (!uploadBtn) return;
        const originalBtnContent = uploadBtn.innerHTML;

        try {
            // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
            await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
            const formData = new FormData();
            formData.append('action', 'mxchat_upload_word');
            formData.append('word_file', file);
            formData.append('session_id', sessionId);
            formData.append('nonce', mxchatChat.nonce);

            uploadBtn.disabled = true;
            if (sendBtn) sendBtn.disabled = true;
            uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
                <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
            </svg>`;

            const response = await fetch(mxchatChat.ajax_url, {
                method: 'POST',
                body: formData
            });

            const data = await response.json();

            if (data.success) {
                // Hide popular questions if they exist
                if (hasQuickQuestions(botId)) {
                    collapseQuickQuestions(botId);
                }

                // Show the active Word document name
                showActiveWord(data.data.filename, botId);

                appendMessage('bot', data.data.message, '', [], false, botId);
                scrollToBottom(botId);
                instance.activeWordFile = data.data.filename;
            } else {
                alert('Failed to upload Word document. Please try again.');
            }
        } catch (error) {
            alert('Error uploading file. Please try again.');
        } finally {
            uploadBtn.disabled = false;
            if (sendBtn) sendBtn.disabled = false;
            uploadBtn.innerHTML = originalBtnContent;
            this.value = ''; // Reset file input
        }
    });
    
    // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
    $(document).on('click', '.remove-pdf-btn', function(e) {
        e.preventDefault();
        e.stopPropagation();
        removeActivePdf(getBotIdFromElement(this));
    });

    $(document).on('click', '.remove-word-btn', function(e) {
        e.preventDefault();
        e.stopPropagation();
        removeActiveWord(getBotIdFromElement(this));
    });
    
    // Window resize handlers
    $(window).on('resize orientationchange', function() {
        setFullHeight();
    });


    // ====================================
    // TOOLBAR & STYLING SETUP
    // ====================================
    
    // Apply toolbar settings
    if (mxchatChat.chat_toolbar_toggle === 'on') {
        $('.chat-toolbar').show();
    } else {
        $('.chat-toolbar').hide();
    }
    
    // Apply toolbar icon colors
    const toolbarElements = [
        '#mxchat-chatbot .toolbar-btn svg',
        '#mxchat-chatbot .active-pdf-name',
        '#mxchat-chatbot .active-word-name',
        '#mxchat-chatbot .remove-pdf-btn svg',
        '#mxchat-chatbot .remove-word-btn svg',
        '#mxchat-chatbot .toolbar-perplexity svg'
    ];
    
    toolbarElements.forEach(selector => {
        $(selector).css({
            'fill': toolbarIconColor,
            'stroke': toolbarIconColor,
            'color': toolbarIconColor
        });
    });


// ====================================
//  INIT LOADER & CHAT CONTAINER HELPERS
// ====================================
// These must be outside the email collection block so they're always available
// (used by persistence loading even when email collection is off)

function showInitLoader(botId) {
    var loader = getElementDOM(botId, 'mxchat-init-loader');
    if (loader) loader.style.display = 'flex';
}

function hideInitLoader(botId) {
    var loader = getElementDOM(botId, 'mxchat-init-loader');
    if (loader) loader.style.display = 'none';
}

function showEmailFormForBot(botId) {
    hideInitLoader(botId);
    var emailBlocker = getElementDOM(botId, 'email-blocker');
    var chatContainer = getElementDOM(botId, 'chat-container');
    if (emailBlocker) emailBlocker.style.display = 'flex';
    if (chatContainer) chatContainer.style.display = 'none';
}

function showChatContainerForBot(botId) {
    var emailBlocker = getElementDOM(botId, 'email-blocker');
    var chatContainer = getElementDOM(botId, 'chat-container');
    if (emailBlocker) emailBlocker.style.display = 'none';

    var instance = MxChatInstances.get(botId);
    var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';

    // If persistence is on and history hasn't loaded yet, show loader
    // while history loads to prevent flash of empty chat
    if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
        if (chatContainer) chatContainer.style.display = 'none';
        showInitLoader(botId);
        loadChatHistory(botId, function() {
            hideInitLoader(botId);
            if (chatContainer) chatContainer.style.display = 'flex';
            scrollToBottom(botId, true);
        });
    } else {
        hideInitLoader(botId);
        if (chatContainer) chatContainer.style.display = 'flex';
        if (typeof loadChatHistory === 'function') {
            loadChatHistory(botId);
        }
    }
}

// ====================================
//  EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
// ====================================
// Only run email collection setup if it's enabled
if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {

    // Track submitting state per bot
    const emailSubmittingState = {};

    // Add CSS animations for email form (once globally)
    if (!document.getElementById('email-error-styles')) {
        const style = document.createElement('style');
        style.id = 'email-error-styles';
        style.textContent = `
            @keyframes fadeInError {
                from { opacity: 0; transform: translateY(-5px); }
                to { opacity: 1; transform: translateY(0); }
            }
            .email-input-shake {
                animation: shake 0.5s ease-in-out;
            }
            @keyframes shake {
                0%, 100% { transform: translateX(0); }
                25% { transform: translateX(-5px); }
                75% { transform: translateX(5px); }
            }
            @keyframes spin {
                from { transform: rotate(0deg); }
                to { transform: rotate(360deg); }
            }
            .email-spinner {
                display: inline-block;
                vertical-align: middle;
            }
        `;
        document.head.appendChild(style);
    }

    function isValidEmailAddress(email) {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email.trim()) && email.length <= 254;
    }

    function isValidNameInput(name) {
        return name && name.trim().length >= 2 && name.trim().length <= 100;
    }

    /**
     * Replace {visitor_name} placeholder in intro message with actual visitor name
     * @param {string} botId - The bot instance ID
     * @param {string} visitorName - The visitor's name to insert
     */
    function replaceVisitorNamePlaceholder(botId, visitorName) {
        var chatBox = getElementDOM(botId, 'chat-box');
        if (!chatBox) return;

        // Find the first bot message (intro message)
        var introMessage = chatBox.querySelector('.bot-message');
        if (!introMessage) return;

        var messageContent = introMessage.querySelector('div[dir="auto"]');
        if (!messageContent) return;

        var html = messageContent.innerHTML;

        // Replace {visitor_name} placeholder (case-insensitive)
        if (visitorName && visitorName.trim()) {
            // Escape HTML to prevent XSS
            var safeName = $('<div>').text(visitorName.trim()).html();
            html = html.replace(/\{visitor_name\}/gi, safeName);
        } else {
            // Remove placeholder and clean up spacing if no name provided
            html = html.replace(/\{visitor_name\}/gi, '');
            // Clean up any double spaces that might result
            html = html.replace(/\s{2,}/g, ' ').trim();
        }

        messageContent.innerHTML = html;
    }

    function setEmailSubmissionState(botId, loading) {
        var submitButton = getElementDOM(botId, 'email-submit-button');
        var emailInput = getElementDOM(botId, 'user-email');
        var nameInput = getElementDOM(botId, 'user-name');

        if (loading) {
            emailSubmittingState[botId] = true;
            if (submitButton) submitButton.disabled = true;
            if (emailInput) emailInput.disabled = true;
            if (nameInput) nameInput.disabled = true;

            if (submitButton && !submitButton.getAttribute('data-original-html')) {
                submitButton.setAttribute('data-original-html', submitButton.innerHTML);
                const originalText = submitButton.textContent;
                submitButton.innerHTML = `
                    <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
                        <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
                            <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
                            <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
                        </circle>
                    </svg>
                    ${originalText}
                `;
                submitButton.style.opacity = '0.8';
            }
        } else {
            emailSubmittingState[botId] = false;
            if (submitButton) submitButton.disabled = false;
            if (emailInput) emailInput.disabled = false;
            if (nameInput) nameInput.disabled = false;

            if (submitButton) {
                const originalHtml = submitButton.getAttribute('data-original-html');
                if (originalHtml) {
                    submitButton.innerHTML = originalHtml;
                }
                submitButton.style.opacity = '1';
            }
        }
    }

    function showEmailError(botId, message) {
        clearEmailError(botId);

        var emailForm = getElementDOM(botId, 'email-collection-form');
        if (!emailForm) return;

        const errorDiv = document.createElement('div');
        errorDiv.className = 'email-error';
        errorDiv.style.cssText = `
            color: #e74c3c;
            font-size: 12px;
            margin-top: 8px;
            padding: 4px 0;
            animation: fadeInError 0.3s ease;
        `;
        errorDiv.textContent = message;
        emailForm.appendChild(errorDiv);

        // Add shake animation to inputs
        var emailInput = getElementDOM(botId, 'user-email');
        var nameInput = getElementDOM(botId, 'user-name');

        if (emailInput) {
            emailInput.classList.add('email-input-shake');
            setTimeout(() => emailInput.classList.remove('email-input-shake'), 500);
        }
        if (nameInput) {
            nameInput.classList.add('email-input-shake');
            setTimeout(() => nameInput.classList.remove('email-input-shake'), 500);
        }
    }

    function clearEmailError(botId) {
        var emailForm = getElementDOM(botId, 'email-collection-form');
        if (emailForm) {
            const existingErrors = emailForm.querySelectorAll('.email-error');
            existingErrors.forEach(error => error.remove());
        }
    }

    // Resolve email state using server-side data when available, AJAX fallback otherwise
    function resolveEmailState(botId) {
        if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
            if (mxchatChat.initial_email_state.show_email_form) {
                showEmailFormForBot(botId);
            } else {
                showChatContainerForBot(botId);
            }
        } else {
            checkSessionAndEmailForBot(botId);
        }
    }

    function checkSessionAndEmailForBot(botId) {
        const sessionId = MxChatInstances.ensureSession(botId);

        // Hide both panels while we check — show loader instead
        var emailBlocker = getElementDOM(botId, 'email-blocker');
        var chatContainer = getElementDOM(botId, 'chat-container');
        if (emailBlocker) emailBlocker.style.display = 'none';
        if (chatContainer) chatContainer.style.display = 'none';
        showInitLoader(botId);

        fetch(mxchatChat.ajax_url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                action: 'mxchat_check_email_provided',
                session_id: sessionId,
                nonce: mxchatChat.nonce,
            })
        })
        .then((response) => {
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json();
        })
        .then((data) => {
            if (data.success) {
                if (data.data.logged_in || data.data.email) {
                    showChatContainerForBot(botId);
                } else {
                    showEmailFormForBot(botId);
                }
            } else {
                showEmailFormForBot(botId);
            }
        })
        .catch((error) => {
            showEmailFormForBot(botId);
        });
    }

    // Event delegation for email form submission
    $(document).on('submit', '.email-collection-form', function(e) {
        e.preventDefault();
        e.stopPropagation();

        var botId = getBotIdFromElement(this);

        // Prevent double submission
        if (emailSubmittingState[botId]) {
            return false;
        }

        var emailInput = getElementDOM(botId, 'user-email');
        var nameInput = getElementDOM(botId, 'user-name');
        var userEmail = emailInput ? emailInput.value.trim() : '';
        var userName = nameInput ? nameInput.value.trim() : '';
        var sessionId = MxChatInstances.ensureSession(botId);

        // Validate email
        if (!userEmail) {
            showEmailError(botId, 'Please enter your email address.');
            return false;
        }

        if (!isValidEmailAddress(userEmail)) {
            showEmailError(botId, 'Please enter a valid email address.');
            return false;
        }

        // Validate name if field exists and has content
        if (nameInput && userName && !isValidNameInput(userName)) {
            showEmailError(botId, 'Please enter a valid name (2-100 characters).');
            return false;
        }

        clearEmailError(botId);
        setEmailSubmissionState(botId, true);

        // Prepare form data
        const formData = new URLSearchParams({
            action: 'mxchat_handle_save_email_and_response',
            email: userEmail,
            session_id: sessionId,
            nonce: mxchatChat.nonce,
        });

        if (userName) {
            formData.append('name', userName);
        }

        fetch(mxchatChat.ajax_url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: formData
        })
        .then((response) => {
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json();
        })
        .then((data) => {
            setEmailSubmissionState(botId, false);

            if (data.success) {
                showChatContainerForBot(botId);

                // Replace {visitor_name} placeholder in intro message with actual name
                if (userName) {
                    replaceVisitorNamePlaceholder(botId, userName);
                } else {
                    // Remove placeholder if no name provided
                    replaceVisitorNamePlaceholder(botId, '');
                }

                if (data.message && typeof appendMessage === 'function') {
                    setTimeout(() => {
                        appendMessage('bot', data.message, '', [], false, botId);
                        if (typeof scrollToBottom === 'function') {
                            scrollToBottom(botId);
                        }
                    }, 100);
                }
            } else {
                showEmailError(botId, data.message || 'Failed to save email. Please try again.');
            }
        })
        .catch((error) => {
            setEmailSubmissionState(botId, false);
            showEmailError(botId, 'An error occurred. Please try again.');
        });

        return false;
    });

    // Real-time email validation using event delegation
    $(document).on('input', '.mxchat-email-input', function() {
        var botId = getBotIdFromElement(this);
        var $input = $(this);

        // Clear previous timeout
        clearTimeout($input.data('validationTimeout'));

        // Debounce validation
        var timeout = setTimeout(() => {
            var email = this.value.trim();
            clearEmailError(botId);

            if (email && !isValidEmailAddress(email)) {
                showEmailError(botId, 'Please enter a valid email address.');
            }
        }, 500);

        $input.data('validationTimeout', timeout);
    });

    // Handle Enter key in email input
    $(document).on('keypress', '.mxchat-email-input', function(e) {
        if (e.key === 'Enter') {
            e.preventDefault();
            var botId = getBotIdFromElement(this);
            if (!emailSubmittingState[botId]) {
                $(this).closest('.email-collection-form').submit();
            }
        }
    });

    // Handle Enter key in name input
    $(document).on('keypress', '.mxchat-name-input', function(e) {
        if (e.key === 'Enter') {
            e.preventDefault();
            var botId = getBotIdFromElement(this);
            if (!emailSubmittingState[botId]) {
                $(this).closest('.email-collection-form').submit();
            }
        }
    });

    // Initialize email check for all bot instances
    // For floating bots: defer until widget is opened (zero passive AJAX)
    // For embedded bots: check immediately since the form is visible
    $('.mxchat-chatbot-wrapper').each(function() {
        var botId = $(this).data('bot-id') || 'default';
        var emailBlocker = getElementDOM(botId, 'email-blocker');

        if (emailBlocker) {
            if (isEmbeddedBot(botId)) {
                // Embedded bots are always visible — check now
                resolveEmailState(botId);
            }
            // Floating bots: handled in the widget open handler
        } else if (isEmbeddedBot(botId)) {
            // Embedded bot, no email collection — load history with loader
            var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
            if (chatPersistenceEnabled) {
                MxChatInstances.ensureSession(botId);
                showChatContainerForBot(botId);
            }
        }
    });
}

    // Open chatbot when pre-chat message is clicked - use class selector for multi-instance
    $(document).on('click', '.pre-chat-message', function() {
        var botId = getBotIdFromElement(this);
        var $chatbot = getElement(botId, 'floating-chatbot');
        if ($chatbot.hasClass('hidden')) {
            $chatbot.removeClass('hidden').addClass('visible');
            getElement(botId, 'floating-chatbot-button').addClass('hidden');
            handlePreChatDismissal(botId);
            disableScroll(); // Disable scroll when chatbot opens

            // Load chat history for returning visitors (persistence)
            var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
            if (chatPersistenceEnabled) {
                MxChatInstances.ensureSession(botId);
            }

            // Deferred email check — only on first widget open
            var emailBlocker = getElementDOM(botId, 'email-blocker');
            var instance = MxChatInstances.get(botId);
            if (emailBlocker && !instance.emailCheckDone) {
                instance.emailCheckDone = true;
                resolveEmailState(botId);
            } else if (!emailBlocker) {
                showChatContainerForBot(botId);
            }
        }
    });

    // Legacy duplicate close handler removed — handled by single event delegation above


function hasQuickQuestions(botId) {
    botId = botId || 'default';
    var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
    if (!questionsContainer) return false;
    const questionButtons = questionsContainer.querySelectorAll('.mxchat-popular-question');
    return questionButtons.length > 0;
}

/**
 * Check if a bot is embedded (not floating)
 * Embedded bots don't have a .floating-chatbot wrapper
 */
function isEmbeddedBot(botId) {
    botId = botId || 'default';
    var floatingWrapper = document.getElementById('floating-chatbot-' + botId);
    return !floatingWrapper;
}

function collapseQuickQuestions(botId) {
    botId = botId || 'default';
    const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
    if (questionsContainer && hasQuickQuestions(botId)) {
        questionsContainer.classList.add('collapsed');
        questionsContainer.classList.add('has-been-collapsed');
        try {
            sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'true');
            sessionStorage.setItem('mxchat_questions_has_been_collapsed_' + botId, 'true');
        } catch (e) {
            // Ignore if sessionStorage is not available
        }
    }
}

function expandQuickQuestions(botId) {
    botId = botId || 'default';
    const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
    if (questionsContainer && hasQuickQuestions(botId)) {
        questionsContainer.classList.remove('collapsed');
        try {
            sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'false');
        } catch (e) {
            // Ignore if sessionStorage is not available
        }
    }
}

function checkQuickQuestionsState(botId) {
    botId = botId || 'default';
    if (!hasQuickQuestions(botId)) {
        return; // Don't do anything if no questions exist
    }

    // Skip restoring collapsed state for embedded bots - they should always start expanded
    if (isEmbeddedBot(botId)) {
        return;
    }

    try {
        const isCollapsed = sessionStorage.getItem('mxchat_questions_collapsed_' + botId);
        const hasBeenCollapsed = sessionStorage.getItem('mxchat_questions_has_been_collapsed_' + botId);

        const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
        if (questionsContainer) {
            if (hasBeenCollapsed === 'true') {
                questionsContainer.classList.add('has-been-collapsed');
            }
            if (isCollapsed === 'true') {
                questionsContainer.classList.add('collapsed');
            }
        }
    } catch (e) {
        // Ignore if sessionStorage is not available
    }
}

//   Global delegation for dynamically added links as fallback
// Use class selector for multi-instance support
$(document).on('click', '.chat-box a[href]:not([data-tracked])', function(e) {
    const $link = $(this);
    const messageDiv = $link.closest('.bot-message, .agent-message');

    // Only process bot/agent message links
    if (messageDiv.length > 0) {
        const originalHref = $link.attr('href');

        if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
            e.preventDefault();
            e.stopPropagation();

            // Mark as tracked
            $link.attr('data-tracked', 'true');

            // Get bot ID from the chat box context
            var botId = getBotIdFromElement(this);

            // Get message context from the message div
            const messageText = messageDiv.text().substring(0, 200);

            $.ajax({
                url: mxchatChat.ajax_url,
                type: 'POST',
                data: {
                    action: 'mxchat_track_url_click',
                    session_id: getChatSession(botId),
                    url: originalHref,
                    message_context: messageText,
                    nonce: mxchatChat.nonce
                },
                complete: function() {
                    if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
                        window.open(originalHref, '_blank');
                    } else {
                        window.location.href = originalHref;
                    }
                }
            });

            return false;
        }
    }
});

    // ====================================
    // MAIN INITIALIZATION
    // ====================================

    // Initialize all chatbot instances on the page
    initializeAllInstances();

    // Legacy initialization for single bot compatibility
    $('.floating-chatbot.hidden').each(function() {
        var botId = getBotIdFromElement(this);
        getElement(botId, 'floating-chatbot-button').removeClass('hidden');
    });

    // Initialize when document is ready
    setFullHeight();

    // Note: trackOriginatingPage() and loadChatHistory() are now deferred
    // until the user's first interaction via MxChatInstances.ensureSession()

    // Initialize chat visibility for all instances
    $('.mxchat-chatbot-wrapper').each(function() {
        var botId = $(this).data('bot-id') || 'default';
        initializeChatVisibility(botId);
    });

    // Make functions globally available for add-ons
    window.hasQuickQuestions = hasQuickQuestions;
    window.collapseQuickQuestions = collapseQuickQuestions;
    window.appendMessage = appendMessage;
    window.appendThinkingMessage = appendThinkingMessage;
    window.scrollToBottom = scrollToBottom;
    window.scrollElementToTop = scrollElementToTop;
    window.replaceLastMessage = replaceLastMessage;
    window.callMxChat = callMxChat;
    window.callMxChatStream = callMxChatStream;
    window.shouldUseStreaming = shouldUseStreaming;
    window.getChatSession = getChatSession;
    window.getPageContext = getPageContext;
    window.updateStreamingMessage = updateStreamingMessage;
    window.MxChatInstances = MxChatInstances;
    window.getElement = getElement;
    window.getElementDOM = getElementDOM;
    window.getBotIdFromElement = getBotIdFromElement;

}); // End of jQuery ready


// ====================================
// GLOBAL EVENT LISTENERS (Outside jQuery)
// ====================================

// Event listener for copy button (code blocks)
document.addEventListener("click", (e) => {
    if (e.target.classList.contains("mxchat-copy-button")) {
        const copyButton = e.target;
        const codeBlock = copyButton
            .closest(".mxchat-code-block-container")
            .querySelector(".mxchat-code-block code");

        if (codeBlock) {
            // Preserve formatting using innerText
            navigator.clipboard.writeText(codeBlock.innerText).then(() => {
                copyButton.textContent = "Copied!";
                copyButton.setAttribute("aria-label", "Copied to clipboard");

                setTimeout(() => {
                    copyButton.textContent = "Copy";
                    copyButton.setAttribute("aria-label", "Copy to clipboard");
                }, 2000);
            });
        }
    }
});

// ============================================================================
// SATISFACTION RATING (v3.2.6)
// ============================================================================
// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
// inactivity following a bot reply. One prompt per session, deduped via
// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
// the option (default off) is authoritative.
jQuery(function($) {
    if (typeof mxchatChat === 'undefined') return;
    // wp_localize_script stringifies scalars: a PHP boolean false arrives as
    // '' and true as '1', so this must be an explicit-enable allowlist — the
    // old "disabled when exactly false/'off'" check let '' through and the
    // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
    // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
    // (boolean-true localizations) working.
    // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
    // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
    // merges fresh values over mxchatChat (that merge fires on first widget
    // open, after this module has already decided). Re-evaluating would mean
    // restructuring the whole module to late-bind its listeners — not worth it
    // for a prompt that is at worst stale for one page load on a cached page.
    var sre = mxchatChat.satisfaction_rating_enabled;
    if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;

    // wp_localize_script stringifies ints, so accept both number and numeric string.
    var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
    var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
    if (!isFinite(idleSeconds)) idleSeconds = 60;
    if (idleSeconds < 5) idleSeconds = 5;
    if (idleSeconds > 600) idleSeconds = 600;
    var IDLE_MS = idleSeconds * 1000;
    var MIN_BOT_REPLIES = 2;
    var ratingState = {};

    function getState(botId) {
        if (!ratingState[botId]) {
            ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
        }
        return ratingState[botId];
    }

    function getSessionId(botId) {
        if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
            return MxChatInstances.getChatSession(botId);
        }
        return null;
    }

    function isAlreadyRated(sessionId) {
        if (!sessionId) return false;
        try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
    }

    function markRated(sessionId) {
        if (!sessionId) return;
        try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
    }

    function esc(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
    }

    // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
    function ratingSkipInlineColors(botId) {
        if (mxchatChat.skip_inline_colors) return true;
        var botAssignments = mxchatChat.bot_theme_assignments || {};
        return botAssignments.hasOwnProperty(botId);
    }

    function botBubbleStyleAttr(botId) {
        if (ratingSkipInlineColors(botId)) return '';
        var bg = mxchatChat.bot_message_bg_color;
        var fg = mxchatChat.bot_message_font_color;
        if (!bg && !fg) return '';
        return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
    }

    // Reads the rating bubble's actual computed fg+bg (whatever paints it —
    // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
    // and paints the filled "Send" pill so it fills with the bot font color and
    // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
    // We paint the submit button DIRECTLY (inline longhand) rather than relying
    // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
    // unreliably inside a descendant's `background`, so a bubble-level var would
    // silently fall back to the literal (white-block bug all over again). Inline
    // longhand always wins. Same transparent-guard as the menu so we never paint
    // a see-through value — in that case the CSS literal fallbacks keep it legible.
    function syncRatingBubbleColors(botId) {
        var $chatBox = getChatBoxByBotId(botId);
        if (!$chatBox || !$chatBox.length) return;
        var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
        if (!bubbleEl) return;
        var cs = window.getComputedStyle(bubbleEl);
        var fg = cs.color;
        var bg = cs.backgroundColor;
        var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
        var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
        // Expose on the bubble too, for any inheriting styles / future use.
        if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
        if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
        // Paint the Send pill directly — the part that actually fixes the bug.
        var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
        if (submitEl) {
            if (hasFg) submitEl.style.backgroundColor = fg; // fill  = bot font color
            if (hasBg) submitEl.style.color = bg;           // label = bubble background
        }
    }

    function copy(key) {
        var c = mxchatChat.satisfaction_rating_copy || {};
        var d = {
            question: 'Was this helpful?',
            helpful: 'Helpful',
            not_helpful: 'Not helpful',
            dismiss: 'Dismiss',
            thanks: 'Thanks! Anything we should improve? (optional)',
            placeholder: 'Tell us what could be better…',
            send: 'Send',
            skip: 'Skip',
            saved: 'Thanks for the feedback.'
        };
        return c[key] || d[key];
    }

    function thumbUpSvg() {
        return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
    }
    function thumbDownSvg() {
        return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
    }

    function buildPromptHtml(botId) {
        var styleAttr = botBubbleStyleAttr(botId);
        return ''
            + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
            +   '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
            +     '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
            +     '<div class="mxchat-rating-actions">'
            +       '<span class="mxchat-rating-buttons">'
            +         '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
            +         '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
            +       '</span>'
            +       '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
            +     '</div>'
            +   '</div>'
            + '</div>';
    }

    function buildFeedbackHtml(botId, rating) {
        var styleAttr = botBubbleStyleAttr(botId);
        return ''
            + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
            +   '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
            +     '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
            +     '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
            +     '<div class="mxchat-rating-feedback-actions">'
            +       '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
            +       '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
            +     '</div>'
            +   '</div>'
            + '</div>';
    }

    function buildSavedHtml(botId) {
        var styleAttr = botBubbleStyleAttr(botId);
        return ''
            + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
            +   '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
            + '</div>';
    }

    function getChatBoxByBotId(botId) {
        var $byId = $('#chat-box-' + botId);
        if ($byId.length) return $byId.first();
        return $('.chat-box').first();
    }

    function scrollChatBoxToBottom($chatBox) {
        if (!$chatBox || !$chatBox.length) return;
        $chatBox.scrollTop($chatBox[0].scrollHeight);
    }

    function showPrompt(botId) {
        var s = getState(botId);
        if (s.promptShown || s.dismissed) return;
        var sessionId = getSessionId(botId);
        if (!sessionId) return;
        if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
        var $chatBox = getChatBoxByBotId(botId);
        if (!$chatBox.length) return;
        if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
        $chatBox.append(buildPromptHtml(botId));
        syncRatingBubbleColors(botId);
        s.promptShown = true;
        scrollChatBoxToBottom($chatBox);
    }

    function submitRating(botId, rating, feedback) {
        var sessionId = getSessionId(botId);
        if (!sessionId) return;
        $.post(mxchatChat.ajax_url, {
            action: 'mxchat_save_rating',
            session_id: sessionId,
            bot_id: botId,
            rating: rating,
            feedback: feedback || ''
        });
        markRated(sessionId);
    }

    function onBotReply(botId) {
        var s = getState(botId);
        s.botReplies += 1;
        if (s.promptShown || s.dismissed) return;
        var sessionId = getSessionId(botId);
        if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
        if (s.botReplies < MIN_BOT_REPLIES) return;
        if (s.idleTimer) clearTimeout(s.idleTimer);
        s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
    }

    function onUserMessage(botId) {
        var s = getState(botId);
        if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
    }

    function botIdFromChatBox(el) {
        var id = el && el.id ? el.id : '';
        return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
    }

    function setupObserver(chatBox) {
        var botId = botIdFromChatBox(chatBox);
        try {
            var observer = new MutationObserver(function(mutations) {
                mutations.forEach(function(m) {
                    for (var i = 0; i < m.addedNodes.length; i++) {
                        var node = m.addedNodes[i];
                        if (!node || node.nodeType !== 1) continue;
                        var $n = $(node);
                        if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
                        if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
                        else if ($n.hasClass('user-message')) onUserMessage(botId);
                    }
                });
            });
            observer.observe(chatBox, { childList: true });
        } catch (e) { /* noop */ }
    }

    $('.chat-box').each(function() { setupObserver(this); });

    $(document).on('click', '.mxchat-rating-btn', function(e) {
        e.preventDefault();
        var $btn = $(this);
        var $prompt = $btn.closest('.mxchat-rating-prompt');
        var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
        var botId = $prompt.data('bot-id') || 'default';
        var rating = parseInt($btn.attr('data-rating'), 10);
        if (rating !== 1 && rating !== -1) return;
        submitRating(botId, rating, '');
        ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
        syncRatingBubbleColors(botId);
        scrollChatBoxToBottom(getChatBoxByBotId(botId));
    });

    $(document).on('click', '.mxchat-rating-dismiss', function(e) {
        e.preventDefault();
        var $prompt = $(this).closest('.mxchat-rating-prompt');
        var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
        var botId = $prompt.data('bot-id') || 'default';
        var s = getState(botId);
        s.dismissed = true;
        markRated(getSessionId(botId));
        ($wrap.length ? $wrap : $prompt).remove();
    });

    function closeFeedback($fb) {
        var botId = $fb.data('bot-id') || 'default';
        var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
        ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
        syncRatingBubbleColors(botId);
        scrollChatBoxToBottom(getChatBoxByBotId(botId));
    }

    $(document).on('click', '.mxchat-rating-skip', function(e) {
        e.preventDefault();
        closeFeedback($(this).closest('.mxchat-rating-feedback'));
    });

    $(document).on('click', '.mxchat-rating-submit', function(e) {
        e.preventDefault();
        var $fb = $(this).closest('.mxchat-rating-feedback');
        var botId = $fb.data('bot-id') || 'default';
        var rating = parseInt($fb.attr('data-rating'), 10);
        if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
        var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
        if (text !== '') {
            submitRating(botId, rating, text);
        }
        closeFeedback($fb);
    });
});


````
