/**
 * Chat component for conversational research.
 *
 * Reuses:
 * - window.socket.subscribeToResearch() for research progress
 * - window.ui.renderMarkdown() for message rendering
 * - CSRF token handling from api.js pattern
 *
 * Security: This component only uses internal API endpoints (/api/chat/*)
 * and internal navigation paths (/chat/{id}). No external URLs are handled,
 * so URLValidator is not required. All fetch() calls go to same-origin APIs.
 */

(function() {
    'use strict';

    // Safe logger fallback — chat.js is Vite-bundled and may execute before safe-logger.js loads
    const _log = (typeof SafeLogger !== 'undefined') ? SafeLogger : console;

    // State
    let sessionId = null;
    let currentResearchId = null;
    let isProcessing = false;
    let completionHandled = false;  // Prevent duplicate completion handling
    let suspendedHandled = false;   // Prevent duplicate suspend handling (socket+poll race)
    // Stable progress-callback reference. Socket.js dedup is by-reference
    // (Array.includes), so passing a fresh arrow each time accumulates
    // handlers on every reconnect. By dispatching through this single
    // reference (which reads the current research/element from closure
    // state) we let dedup actually fire.
    let _stableProgressCb = null;
    let streamingMessageEl = null;  // Element for streaming response
    let streamedContent = '';  // Accumulated streamed content
    let renderTimer = null;  // Debounce timer for streaming renders
    let renderPending = false;  // Whether a render is queued
    let streamingComplete = false;  // Set when is_final chunk is processed
    let pollTimerId = null;  // Timer ID for pollForCompletion, allows cancellation on session switch
    let subscribeRetryTimerId = null;  // Timer ID for trySubscribe socket-availability retry, allows cancellation on session switch
    let liveAccordion = null;  // Live accordion element for growing step messages
    // Pagination — composite cursor (created_at, id) + flag for "load
    // older messages" trigger. Including the id lets the server
    // disambiguate same-millisecond rows at the page boundary instead
    // of silently dropping them.
    let oldestLoadedCreatedAt = null;
    let oldestLoadedId = null;
    let hasMoreMessages = false;
    let isLoadingOlderMessages = false;

    // Research step config
    const STEP_ICONS = {
        init: 'fa-play', setup: 'fa-gear',
        search: 'fa-magnifying-glass', search_planning: 'fa-magnifying-glass',
        observation: 'fa-eye', output_generation: 'fa-file-pen',
        report_generation: 'fa-file-pen',
        synthesis_error: 'fa-triangle-exclamation', synthesis_fallback: 'fa-rotate',
        report_complete: 'fa-check',
        error: 'fa-circle-xmark', _tool: 'fa-wrench', _default: 'fa-circle-info',
    };
    // "complete" excluded — fires after response is written (broken sequence ordering)
    const STEP_PHASES = new Set(Object.keys(STEP_ICONS).filter(k => !k.startsWith('_')));
    const MAX_LIVE_STEPS = 8;
    let lastStepPhase = null;
    // Cap client-side accumulation of streamed tokens. Mirrors the
    // server's _MAX_PARTIAL_BUFFER_BYTES (256 KB). A well-behaved LLM
    // never reaches this, but a model with no max_tokens (or a hostile
    // one) would otherwise grow streamedContent into a multi-MB JS
    // string and OOM the tab. Past the cap we stop appending and show a
    // one-time truncation notice.
    const MAX_STREAM_BUFFER_CHARS = 256 * 1024;
    let streamTruncated = false;

    // DOM elements
    let chatMessages = null;
    let chatInput = null;
    let sendBtn = null;
    let welcomeScreen = null;
    let chatTitle = null;
    let editTitleBtn = null;
    let exportBtn = null;
    let progressWrapper = null;
    let currentTaskText = null;
    let stopResearchBtn = null;
    // Tracks the elapsed-seconds timer started after a Stop click so the
    // UI can show "Stopping research... (Ns)" while the server waits for
    // the worker thread to exit (can be up to ~30s when the LLM is mid
    // <think> block in thinking-mode models like Qwen/DeepSeek). Cleared
    // by any terminal state handler (suspended/completed/error/reset).
    let _stopElapsedTimer = null;

    // ── Shared helpers ──────────────────────────────────────────────

    function createMessageElement(classes, iconClass) {
        const template = document.getElementById('message-template');
        if (!template) return null;
        const el = template.content.cloneNode(true).firstElementChild;
        classes.forEach(c => el.classList.add(c));
        const icon = el.querySelector('.ldr-chat-message-avatar i');
        if (icon) icon.className = 'fas ' + iconClass;
        return el;
    }

    function resetStreamingVars() {
        streamingMessageEl = null;
        streamedContent = '';
        streamTruncated = false;
        streamingComplete = false;
        currentResearchId = null;
        liveAccordion = null;
        // Tear down any lingering stop-elapsed timer; once we leave the
        // active-research state, the "Stopping… (Ns)" counter is moot.
        if (_stopElapsedTimer) {
            clearInterval(_stopElapsedTimer);
            _stopElapsedTimer = null;
        }
    }

    function showWelcomeScreen() { if (welcomeScreen) welcomeScreen.style.display = 'flex'; }
    function hideWelcomeScreen() { if (welcomeScreen) welcomeScreen.style.display = 'none'; }

    /**
     * Render or remove the "Load older messages" button at the top of
     * the message list. Idempotent — call after any pagination state
     * change.
     */
    function renderLoadOlderButton() {
        const existing = document.getElementById('ldr-chat-load-older-btn');
        if (existing) existing.remove();
        if (!hasMoreMessages || !chatMessages) return;

        const btn = document.createElement('button');
        btn.id = 'ldr-chat-load-older-btn';
        btn.type = 'button';
        btn.className = 'ldr-chat-load-older-btn';
        btn.textContent = 'Load older messages';
        btn.addEventListener('click', loadOlderMessages);
        chatMessages.insertBefore(btn, chatMessages.firstChild);
    }

    /**
     * Fetch the next older batch of messages and prepend them to the
     * top of the message list. Preserves the user's scroll position so
     * the currently-viewed content does not jump.
     */
    async function loadOlderMessages() {
        if (isLoadingOlderMessages || !sessionId || !oldestLoadedCreatedAt) return;
        isLoadingOlderMessages = true;

        const btn = document.getElementById('ldr-chat-load-older-btn');
        if (btn) {
            btn.disabled = true;
            btn.textContent = 'Loading…';
        }

        // Anchor scroll to the first visible content so it stays in
        // place after we prepend older entries above it.
        const scrollHeightBefore = chatMessages.scrollHeight;
        const scrollTopBefore = chatMessages.scrollTop;

        try {
            let url = `/api/chat/sessions/${encodeURIComponent(sessionId)}/messages` +
                `?before_created_at=${encodeURIComponent(oldestLoadedCreatedAt)}`;
            if (oldestLoadedId) {
                url += `&before_id=${encodeURIComponent(oldestLoadedId)}`;
            }
            const data = await apiGet(url);

            if (!data || !Array.isArray(data.messages) || data.messages.length === 0) {
                hasMoreMessages = false;
                renderLoadOlderButton();
                return;
            }

            // Build a fragment of older entries, grouping consecutive
            // step rows into accordions exactly like the initial render.
            const fragment = document.createDocumentFragment();
            let pendingSteps = [];
            const flushSteps = () => {
                if (pendingSteps.length === 0) return;
                const accordion = wrapStepsInAccordion(pendingSteps);
                if (accordion) fragment.appendChild(accordion);
                pendingSteps = [];
            };
            data.messages.forEach(msg => {
                if (msg.message_type === 'step') {
                    const el = createStepElement(getStepIconForContent(msg.content), msg.content || '');
                    if (el) pendingSteps.push(el);
                } else {
                    flushSteps();
                    const bubble = buildMessageBubble(msg.role, msg.content, msg.created_at, msg.research_id);
                    if (bubble) fragment.appendChild(bubble);
                }
            });
            flushSteps();

            // Prepend below the load-older button (or at top if button
            // was removed).
            const insertAfter = document.getElementById('ldr-chat-load-older-btn');
            if (insertAfter && insertAfter.nextSibling) {
                chatMessages.insertBefore(fragment, insertAfter.nextSibling);
            } else if (insertAfter) {
                chatMessages.appendChild(fragment);
            } else {
                chatMessages.insertBefore(fragment, chatMessages.firstChild);
            }

            // Update pagination state — safe because the leading
            // ``isLoadingOlderMessages`` flag prevents reentrancy while
            // this function is mid-await.
            // eslint-disable-next-line require-atomic-updates
            oldestLoadedCreatedAt = data.messages[0].created_at;
            // eslint-disable-next-line require-atomic-updates
            oldestLoadedId = data.messages[0].id || null;
            hasMoreMessages = !!data.has_more;
            renderLoadOlderButton();
        } catch (e) {
            _log.error('Error loading older messages:', e);
            if (btn) {
                btn.disabled = false;
                btn.textContent = 'Load older messages';
            }
        } finally {
            // Preserve scroll position so the user's current view is
            // not displaced by the prepended content.
            const scrollHeightAfter = chatMessages.scrollHeight;
            chatMessages.scrollTop = scrollTopBefore + (scrollHeightAfter - scrollHeightBefore);
            // eslint-disable-next-line require-atomic-updates
            isLoadingOlderMessages = false;
        }
    }

    /**
     * Initialize the chat component.
     */
    async function init() {
        // Get DOM elements
        chatMessages = document.getElementById('chat-messages');
        chatInput = document.getElementById('chat-input');
        sendBtn = document.getElementById('send-btn');
        welcomeScreen = document.getElementById('chat-welcome');
        chatTitle = document.getElementById('chat-title');
        editTitleBtn = document.getElementById('edit-title-btn');
        exportBtn = document.getElementById('export-chat-btn');
        progressWrapper = document.getElementById('chat-progress-wrapper');
        currentTaskText = document.getElementById('chat-current-task');
        stopResearchBtn = document.getElementById('chat-stop-research-btn');

        if (!chatMessages || !chatInput || !sendBtn) {
            _log.error('Chat: Required DOM elements not found');
            return;
        }

        // Wire up input/keyboard listeners SYNCHRONOUSLY, before any
        // awaited network work below. The textarea, send button and
        // suggestion chips exist in static HTML from first paint, so
        // binding here makes the input interactive immediately. Doing
        // this after the awaited loadSession()/loadMostRecentSession()
        // left a multi-second window (slow connection, or CI under
        // load) where typing, Enter-to-send, auto-resize and chip
        // clicks silently did nothing because no handler was attached.
        setupEventListeners();
        setupTextareaAutoResize();

        // Check for existing session ID from meta tag
        const sessionMeta = document.querySelector('meta[name="chat-session-id"]');
        if (sessionMeta && sessionMeta.content) {
            sessionId = sessionMeta.content;
            await loadSession(sessionId);
        } else {
            // No session ID in URL - try to load the most recent session
            await loadMostRecentSession();
        }

        // Re-subscribe to in-flight research after a websocket
        // reconnect. Socket.IO loses listeners across reconnects, so
        // without this hook a transport drop mid-research silently
        // stops streaming and recovery only happens through the 1s
        // poll fallback.
        setupSocketReconnectHandler();

        // Auto-send initial query from URL parameter (e.g., from main page chat mode)
        const urlParams = new URLSearchParams(window.location.search);
        const initialQuery = urlParams.get('q');
        if (initialQuery && sessionId && !isProcessing) {
            // Clean the URL without reloading
            window.history.replaceState({}, '', window.location.pathname);
            // eslint-disable-next-line require-atomic-updates -- one-shot init flow
            chatInput.value = initialQuery;
            handleSend();
        }

        // Readiness signal: init is fully settled, including the async
        // session restore above (listeners themselves are bound
        // synchronously, well before this). Callers that need a
        // deterministic post-restore state — e.g. "is the welcome screen
        // still showing or did a prior session load?" — wait on this
        // instead of racing the session-load network calls. Consumed by
        // the chat E2E tests' gotoChat() helpers.
        // eslint-disable-next-line require-atomic-updates -- one-shot init flow
        chatInput.dataset.initComplete = 'true';
    }

    /**
     * Set up event listeners.
     */
    function setupEventListeners() {
        // Send button
        sendBtn.addEventListener('click', handleSend);

        // Enter key to send
        chatInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                handleSend();
            }
        });

        // Enable/disable send button based on input
        chatInput.addEventListener('input', () => {
            sendBtn.disabled = !chatInput.value.trim() || isProcessing;
        });

        // New chat button
        const newChatBtn = document.getElementById('new-chat-btn');
        if (newChatBtn) {
            newChatBtn.addEventListener('click', startNewChat);
        }

        // Stop research button — terminates the in-flight research and
        // preserves the partial bubble. No confirm dialog: stopping is
        // non-destructive.
        if (stopResearchBtn) {
            stopResearchBtn.addEventListener('click', handleStopResearch);
        }

        // Suggestion buttons (guard against double-fire)
        document.querySelectorAll('.ldr-chat-suggestion').forEach(btn => {
            btn.addEventListener('click', () => {
                if (isProcessing) return;
                const query = btn.dataset.query;
                if (query) {
                    chatInput.value = query;
                    handleSend();
                }
            });
        });

        // Edit title button
        if (editTitleBtn) {
            editTitleBtn.addEventListener('click', handleEditTitle);
        }

        // Export button
        if (exportBtn) {
            exportBtn.addEventListener('click', handleExport);
        }

        // Per-message action bar (Copy / Retry / Delete). Delegated from
        // #chat-messages so a single listener covers every bubble,
        // including ones added later by the streaming path.
        if (chatMessages) {
            chatMessages.addEventListener('click', handleMessageActionClick);
        }
    }

    /**
     * Delegated click handler for the per-message action buttons.
     *
     * Routes by ``data-action`` to the right handler, passing the
     * message's ``data-research-id`` (for retry/delete) and the
     * ``data-copy-content`` (for copy). Clicks inside the streaming
     * bubble or step accordion are ignored — those don't carry the
     * ``.ldr-chat-msg-action`` class.
     */
    function handleMessageActionClick(event) {
        const btn = event.target.closest('.ldr-chat-msg-action');
        if (!btn || !chatMessages.contains(btn)) return;

        const messageEl = btn.closest('.ldr-chat-message');
        if (!messageEl) return;

        const action = btn.dataset.action;
        const actionsEl = messageEl.querySelector('.ldr-chat-message-actions');
        const researchId = messageEl.dataset.researchId || null;
        const copyContent = actionsEl?.dataset.copyContent || '';

        if (action === 'copy') {
            handleCopyMessage(copyContent, btn);
        } else if (action === 'retry') {
            if (!researchId || isProcessing) return;
            handleRetryAttempt(researchId);
        } else if (action === 'delete') {
            if (!researchId) return;
            handleDeleteAttempt(researchId);
        }
    }

    /**
     * Copy message content to the clipboard. Falls back to a hidden
     * textarea + execCommand when ``navigator.clipboard`` is unavailable
     * (older browsers, insecure context). Shows a brief "Copied" label
     * on the clicked button so the click registers.
     */
    async function handleCopyMessage(content, btn) {
        const text = String(content || '');
        let ok;
        try {
            if (navigator.clipboard && window.isSecureContext) {
                await navigator.clipboard.writeText(text);
                ok = true;
            } else {
                const ta = document.createElement('textarea');
                ta.value = text;
                ta.setAttribute('readonly', '');
                ta.style.position = 'absolute';
                ta.style.left = '-9999px';
                document.body.appendChild(ta);
                ta.select();
                ok = document.execCommand('copy');
                document.body.removeChild(ta);
            }
        } catch (e) {
            _log.error('Chat: copy failed', e);
            ok = false;
        }

        // Visual feedback regardless of path: briefly swap the icon for
        // a checkmark. The label change also nudges the aria-live region
        // (the parent #chat-messages is role="log") to announce the
        // result for screen readers.
        if (btn) {
            const icon = btn.querySelector('i');
            // If a previous copy's transient icon is still showing, cancel
            // its restore timer and put the real icon back BEFORE capturing
            // the "original" below. Without this, a second click within the
            // 1.2s window captures the checkmark as the original and the
            // button is left stuck on it.
            if (btn._ldrCopyRestoreTimer) {
                clearTimeout(btn._ldrCopyRestoreTimer);
                if (icon && btn._ldrCopyIconClass != null) {
                    icon.className = btn._ldrCopyIconClass;
                }
                if (btn._ldrCopyLabel != null) {
                    btn.setAttribute('aria-label', btn._ldrCopyLabel);
                }
            }
            const originalClass = icon ? icon.className : '';
            const originalLabel = btn.getAttribute('aria-label');
            btn._ldrCopyIconClass = originalClass;
            btn._ldrCopyLabel = originalLabel;
            if (icon) icon.className = ok ? 'fas fa-check' : 'fas fa-xmark';
            btn.setAttribute('aria-label', ok ? 'Copied' : 'Copy failed');
            btn._ldrCopyRestoreTimer = setTimeout(() => {
                if (icon) icon.className = originalClass;
                if (originalLabel) btn.setAttribute('aria-label', originalLabel);
                btn._ldrCopyRestoreTimer = null;
            }, 1200);
        }
    }

    /**
     * Delete a single chat attempt (user msg + research + assistant msg
     * + steps). Confirms via native ``confirm()`` — the stakes are
     * bounded (one turn, recoverable by re-typing) and bringing the
     * library's Bootstrap modal markup into chat.html is too much
     * weight for one dialog. On success, every DOM node tagged with the
     * research id is removed.
     */
    async function handleDeleteAttempt(researchId) {
        if (!researchId || !sessionId) return;
        // Default to the "Re-run / replace" wording which is safe for
        // both failed and completed attempts — the client doesn't
        // always know the prior status cheaply.
        const ok = window.confirm(
            'Delete this attempt? The user message, research, and any '
            + 'response will be permanently removed from this chat.'
        );
        if (!ok) return;

        try {
            await apiPost(
                `/api/chat/sessions/${encodeURIComponent(sessionId)}/attempts/${encodeURIComponent(researchId)}`,
                null,
                'DELETE',
            );
        } catch (e) {
            _log.error('Chat: delete attempt failed', e);
            const raw = (e && e.message) ? e.message : '';
            const display = raw && !/^Request failed \(\d+\)$/.test(raw)
                ? raw
                : 'Failed to delete attempt. Please try again.';
            // Surface inline rather than via alert() — the chat already
            // uses inline assistant messages for error feedback
            // (handleSend's catch block).
            addMessageToUI('assistant', display);
            return;
        }

        // Remove every DOM node tagged with this research id: the user
        // message, any assistant response(s), and the step accordion.
        // The step accordion is built by ensureLiveAccordion/wrap-
        // StepsInAccordion and doesn't carry data-research-id, so a
        // full session reload is the simplest correct path. Cheaper
        // alternative would be a sibling walk, but the reload also
        // fixes any message_count drift in the sidebar.
        await loadSession(sessionId);
    }

    /**
     * Retry a chat attempt: delete the failed/prior turn, then re-submit
     * the same content as a fresh research run. Server returns the new
     * research_id + message_id; the client adopts the same live-update
     * subscription path as handleSend.
     *
     * The confirm copy uses "Re-run / replace" wording to make it
     * explicit that the prior assistant response is destroyed — even
     * for a previously-completed attempt this is the right mental model
     * (Retry always replaces).
     */
    async function handleRetryAttempt(researchId) {
        if (!researchId || !sessionId || isProcessing) return;
        const ok = window.confirm(
            'Re-run this attempt? The previous response will be '
            + 'replaced and the same query will be submitted again.'
        );
        if (!ok) return;

        // If a different research is in-flight (e.g. user started a new
        // turn after the failed one), tear down its listeners before
        // subscribing to the new id — otherwise step events from the
        // old research would interleave into the new bubble.
        if (currentResearchId && currentResearchId !== researchId) {
            cleanupSocketListeners(currentResearchId);
        } else if (currentResearchId === researchId) {
            cleanupSocketListeners(researchId);
        }
        cancelPendingTimers();

        isProcessing = true;
        sendBtn.disabled = true;

        // Show the thinking indicator BEFORE the await so the user sees
        // immediate feedback. The old attempt's DOM stays in place
        // until the new research is dispatched, then we reload — that
        // way a failed retry leaves the original turn intact.
        const thinkingEl = showThinking('Retrying attempt…');

        try {
            const data = await apiPost(
                `/api/chat/sessions/${encodeURIComponent(sessionId)}/attempts/${encodeURIComponent(researchId)}/retry`,
                {},
            );
            if (!data.research_id) {
                throw new Error('Server did not return a new research_id');
            }
            // eslint-disable-next-line require-atomic-updates -- handleRetryAttempt checks isProcessing at entry; no concurrent call can reach here
            currentResearchId = data.research_id;
            completionHandled = false;
            suspendedHandled = false;
            setThinkingLabel(thinkingEl, null);
            showProgress();
            // Reload from DB so the old attempt's bubbles are gone and
            // the new user message is in place. loadSession() owns the
            // resubscription: it resets active research state (tearing
            // down the pre-await thinkingEl + any timers) and, because the
            // freshly spawned research is IN_PROGRESS, re-subscribes with a
            // fresh thinkingEl via its in_progress_research_id branch.
            // Subscribing again here would leak loadSession's poll timer
            // (pollTimerId is module-level) and attach handlers to the
            // detached thinkingEl — so we deliberately don't. Mirrors
            // handleDeleteAttempt. Use the captured sessionId to avoid
            // races with a concurrent startNewChat().
            const sidAtCall = sessionId;
            await loadSession(sidAtCall);
        } catch (e) {
            _log.error('Chat: retry attempt failed', e);
            removeThinking(thinkingEl);
            const raw = (e && e.message) ? e.message : '';
            const display = raw && !/^Request failed \(\d+\)$/.test(raw)
                ? raw
                : 'Failed to retry attempt. Please try again.';
            addMessageToUI('assistant', display);
            // eslint-disable-next-line require-atomic-updates -- handleRetryAttempt checks isProcessing at entry; no concurrent call can reach here
            isProcessing = false;
            sendBtn.disabled = !chatInput.value.trim();
        }
    }

    /**
     * Set up textarea auto-resize.
     */
    function setupTextareaAutoResize() {
        chatInput.addEventListener('input', () => {
            chatInput.style.height = 'auto';
            chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
        });
    }

    /**
     * Get CSRF token. Thin wrapper over window.api.getCsrfToken with a
     * meta-tag fallback for the (rare) case where services/api.js hasn't
     * loaded yet — keeping a single fallback prevents bootstrap-order
     * races that would otherwise leave fetches without a token.
     */
    function getCsrfToken() {
        if (window.api && window.api.getCsrfToken) {
            return window.api.getCsrfToken();
        }
        const metaTag = document.querySelector('meta[name="csrf-token"]');
        return metaTag ? metaTag.getAttribute('content') : '';
    }

    async function apiGet(url) {
        const r = await fetch(url, { headers: { 'X-CSRFToken': getCsrfToken() } });
        if (!r.ok) {
            const err = await r.json().catch(() => ({}));
            throw new Error(err.error || `Request failed (${r.status})`);
        }
        const data = await r.json();
        if (!data.success) throw new Error(data.error || 'Request failed');
        return data;
    }

    async function apiPost(url, body, method = 'POST') {
        const options = {
            method,
            headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
        };
        // Skip body for null/undefined so DELETE and GET-style POSTs
        // don't send a literal "null" or "undefined" string. Empty
        // body is fine for endpoints that don't @require_json_body.
        if (body !== null && body !== undefined) {
            options.body = JSON.stringify(body);
        }
        const r = await fetch(url, options);
        if (!r.ok) {
            const err = await r.json().catch(() => ({}));
            throw new Error(err.error || `Request failed (${r.status})`);
        }
        const data = await r.json();
        if (!data.success) throw new Error(data.error || 'Request failed');
        return data;
    }

    /**
     * Handle sending a message.
     */
    async function handleSend() {
        const content = chatInput.value.trim();
        if (!content || isProcessing) return;

        isProcessing = true;
        sendBtn.disabled = true;
        chatInput.value = '';
        chatInput.style.height = 'auto';

        hideWelcomeScreen();

        // A follow-up (the conversation already has an assistant reply) builds
        // prior-turn context — an LLM summary by default — during THIS request,
        // before research is dispatched. On slow models that's a multi-second
        // wait, so label the indicator instead of showing blank dots.
        const isFollowUp = !!chatMessages.querySelector(
            '.ldr-chat-message-assistant',
        );

        // Add user message to UI
        addMessageToUI('user', content);

        // Show thinking indicator
        const thinkingEl = showThinking(
            isFollowUp ? 'Summarizing previous conversation…' : null,
        );

        try {
            // Create session if needed. Capture the returned id locally so a
            // concurrent startNewChat()+second-Send race can't reassign the
            // module-level sessionId before our POST URL is built.
            let targetSessionId = sessionId;
            if (!targetSessionId) {
                targetSessionId = await createSession(content);
            }

            // Send message
            const data = await apiPost(`/api/chat/sessions/${targetSessionId}/messages`, {
                content,
                trigger_research: true,
            });

            // If research was triggered, subscribe to updates
            if (data.research_id) {
                currentResearchId = data.research_id;
                completionHandled = false;  // Reset for new research
                suspendedHandled = false;
                // Prior-context build is finished now that research is
                // dispatched; clear the "Summarizing…" label so the live
                // reasoning preview / progress steps own the indicator.
                setThinkingLabel(thinkingEl, null);
                showProgress();
                subscribeToResearch(data.research_id, thinkingEl);
            } else {
                // Server saved the message but declined to spawn research
                // (trigger_research=false in the request, or the server
                // suppressed it). Clear the thinking indicator and reset
                // input state — don't inject a fake assistant bubble
                // pretending to be a model reply, since no LLM was called.
                removeThinking(thinkingEl);
                // eslint-disable-next-line require-atomic-updates -- handleSend early-returns if already processing
                isProcessing = false;
                sendBtn.disabled = false;
            }

        } catch (error) {
            _log.error('Error sending message:', error);
            removeThinking(thinkingEl);
            // Surface the server-provided error so the user can tell
            // a rate limit (429 "Too many requests"), a duplicate-research
            // 409, a "Session not found" 404, etc. apart from a generic
            // 500. apiPost stuffs the response's `error` field into
            // Error.message; only fall back to the generic copy when
            // the message is missing or is the literal "Request failed (NNN)"
            // sentinel apiPost emits when no JSON body was returned.
            const raw = (error && error.message) ? error.message : '';
            const isOpaqueStatus = /^Request failed \(\d+\)$/.test(raw);
            const display = (raw && !isOpaqueStatus)
                ? raw
                : 'Sorry, there was an error processing your request. Please try again.';
            addMessageToUI('assistant', display);
            // eslint-disable-next-line require-atomic-updates -- handleSend early-returns if already processing
            isProcessing = false;
            sendBtn.disabled = false;
        }
    }

    /**
     * Create a new chat session.
     */
    async function createSession(initialQuery) {
        const data = await apiPost('/api/chat/sessions', {
            initial_query: initialQuery,
        });

        sessionId = data.session_id;

        // Update URL without reload
        window.history.pushState({}, '', `/chat/${sessionId}`);
        // Return the newly created id so callers can use it WITHOUT going
        // through the module-level `sessionId` — which a concurrent
        // startNewChat()+createSession() pair could overwrite before
        // the caller's POST URL is built (race manifests as messages
        // landing in the wrong session).
        const _createdId = data.session_id;

        // Update title with the immediate fallback title from the server.
        if (data.session && data.session.title) {
            updateTitle(data.session.title);
        }

        // Fire-and-forget: request an LLM-generated title in the background.
        // If chat.llm_title_generation is disabled server-side the response
        // is a no-op; if it succeeds, the UI title upgrades to the LLM one.
        if (initialQuery) {
            const createdSessionId = sessionId;
            // Snapshot the title we're about to upgrade. If the user manually
            // renames the session while the LLM call is in flight, the DOM
            // title will diverge from this snapshot — and we must not clobber
            // the user's edit with the LLM result.
            const titleAtRequest = chatTitle?.textContent;
            apiPost(`/api/chat/sessions/${createdSessionId}/generate-title`, {
                query: initialQuery,
            })
                .then((resp) => {
                    if (
                        resp &&
                        resp.success &&
                        resp.title &&
                        sessionId === createdSessionId &&
                        chatTitle?.textContent === titleAtRequest
                    ) {
                        updateTitle(resp.title);
                    }
                })
                .catch((e) => {
                    // Network/LLM failure leaves the fallback title in place.
                    // Log it so the swallowed failure is visible in the
                    // browser console during debugging instead of vanishing.
                    _log.warn('Chat: title generation request failed', e);
                });
        }

        // Return the id THIS call created, not the (possibly-overwritten)
        // module-level `sessionId`. Critical for the handleSend race.
        return _createdId;
    }

    /**
     * Load the most recent session if available.
     */
    async function loadMostRecentSession() {
        try {
            const data = await apiGet('/api/chat/sessions?limit=1');

            if (data.sessions && data.sessions.length > 0) {
                const recentSession = data.sessions[0];
                sessionId = recentSession.id;

                // Update URL without reload
                window.history.replaceState({}, '', `/chat/${sessionId}`);

                await loadSession(sessionId);
            } else {
                showWelcomeScreen();
            }
        } catch (error) {
            _log.error('Error loading recent session:', error);
            showWelcomeScreen();
        }
    }

    /**
     * Load an existing session.
     */
    async function loadSession(id) {
        try {
            completionHandled = true;
            resetActiveResearchState();

            // Load session info
            const sessionData = await apiGet(`/api/chat/sessions/${id}`);

            // Update title
            if (sessionData.session && sessionData.session.title) {
                updateTitle(sessionData.session.title);
            }

            // Load messages
            const messagesData = await apiGet(`/api/chat/sessions/${id}/messages`);

            if (messagesData.messages.length > 0) {
                hideWelcomeScreen();

                // Track pagination cursor + flag. The server fetches the
                // LATEST limit messages (DESC slice, reversed to ASC). If
                // has_more is true, there are older entries below the
                // cursor — the user can request them via the "Load older
                // messages" trigger we render at the top.
                oldestLoadedCreatedAt = messagesData.messages[0].created_at;
                oldestLoadedId = messagesData.messages[0].id || null;
                hasMoreMessages = !!messagesData.has_more;

                // Render messages, grouping consecutive step messages into accordions
                let pendingSteps = [];
                const flushSteps = () => {
                    if (pendingSteps.length === 0) return;
                    const accordion = wrapStepsInAccordion(pendingSteps);
                    if (accordion) {
                        chatMessages.appendChild(accordion);
                    }
                    pendingSteps = [];
                };

                messagesData.messages.forEach(msg => {
                    if (msg.message_type === 'step') {
                        const el = createStepElement(getStepIconForContent(msg.content), msg.content || '');
                        if (el) pendingSteps.push(el);
                    } else {
                        flushSteps();
                        addMessageToUI(msg.role, msg.content, msg.created_at, msg.research_id);
                    }
                });
                flushSteps();  // Flush trailing steps (e.g., orphaned steps from failed research)

                renderLoadOlderButton();
                showSessionButtons();

                // Restore the live "thinking" indicator if research is
                // currently running for this chat. The server tells us
                // authoritatively via in_progress_research_id (queried
                // via the partial-unique index on ResearchHistory) so we
                // don't need to infer it from message metadata — which
                // failed during the follow-up wrapper-strategy
                // preprocessing window (no step persisted yet).
                const inProgressResearchId = messagesData.in_progress_research_id;
                if (inProgressResearchId) {
                    // Adopt the trailing DB accordion as the live
                    // accordion so ensureLiveAccordion() won't create a
                    // duplicate (no-op when no steps have persisted yet).
                    const trailingAccordion = chatMessages.querySelector('.ldr-chat-steps-group:last-child');
                    if (trailingAccordion) {
                        liveAccordion = trailingAccordion;
                        trailingAccordion.dataset.live = 'true';
                        trailingAccordion.classList.remove('ldr-chat-steps-collapsed');
                        trailingAccordion.querySelector('.ldr-chat-steps-header')
                            ?.setAttribute('aria-expanded', 'true');
                    }

                    currentResearchId = inProgressResearchId;
                    completionHandled = false;
                    suspendedHandled = false;
                    isProcessing = true;
                    sendBtn.disabled = true;
                    const thinkingEl = showThinking();
                    showProgress();
                    subscribeToResearch(inProgressResearchId, thinkingEl);
                }
            } else {
                showWelcomeScreen();
            }

            // Land focus on the chat input after the session swap so
            // keyboard-only users don't have to tab through the header
            // every time they switch sessions. startNewChat() already
            // does this; loadSession() omitted it.
            if (chatInput) {
                chatInput.focus({ preventScroll: true });
            }

        } catch (error) {
            _log.error('Error loading session:', error);
            sessionId = null;
            showWelcomeScreen();
        }
    }

    /**
     * Clean up socket listeners for a research session.
     */
    function cleanupSocketListeners(researchId) {
        if (!researchId) return;

        const socket = window.socket?.getSocketInstance();
        if (socket) {
            socket.off(`response_chunk_${researchId}`);
        }

        // Also unsubscribe from progress updates
        if (window.socket) {
            window.socket.unsubscribeFromResearch(researchId);
        }
    }

    /**
     * Cancel all pending render and poll timers.
     */
    function cancelPendingTimers() {
        if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; renderPending = false; }
        if (pollTimerId) { clearTimeout(pollTimerId); pollTimerId = null; }
        if (subscribeRetryTimerId) { clearTimeout(subscribeRetryTimerId); subscribeRetryTimerId = null; }
    }

    /**
     * Reset active research state — timers, listeners, streaming, DOM.
     * Does NOT reset completionHandled (callers decide) or session-specific state.
     */
    function resetActiveResearchState() {
        if (currentResearchId) cleanupSocketListeners(currentResearchId);
        cancelPendingTimers();
        isProcessing = false;
        resetStreamingVars();
        if (chatMessages) {
            chatMessages.querySelectorAll('.ldr-chat-message, .ldr-chat-steps-group').forEach(el => el.remove());
            // Tear down the load-older button too — its cursor state is
            // about to be re-derived from a fresh /messages response.
            const olderBtn = document.getElementById('ldr-chat-load-older-btn');
            if (olderBtn) olderBtn.remove();
        }
        oldestLoadedCreatedAt = null;
        oldestLoadedId = null;
        hasMoreMessages = false;
        lastStepPhase = null;
        hideProgress();
    }

    /**
     * Show/hide session action buttons (edit title, export).
     */
    function showSessionButtons() {
        if (editTitleBtn) editTitleBtn.style.display = 'inline-block';
        if (exportBtn) exportBtn.style.display = 'inline-block';
    }
    function hideSessionButtons() {
        if (editTitleBtn) editTitleBtn.style.display = 'none';
        if (exportBtn) exportBtn.style.display = 'none';
    }

    /**
     * Insert element before thinking indicator, or append at end.
     */
    function appendBeforeThinking(el) {
        const thinkingEl = chatMessages.querySelector('.ldr-chat-message-thinking');
        if (thinkingEl) {
            chatMessages.insertBefore(el, thinkingEl);
        } else {
            chatMessages.appendChild(el);
        }
    }

    /**
     * Render content to a text element using markdown (with DOMPurify) or plain text fallback.
     */
    function renderToElement(textEl, content) {
        if (window.ui && window.ui.renderMarkdown) {
            try {
                // bearer:disable javascript_lang_dangerous_insert_html
                // eslint-disable-next-line no-unsanitized/property -- audited: renderMarkdown() sanitizes via DOMPurify (app.js bundle), with textContent fallback on error
                textEl.innerHTML = window.ui.renderMarkdown(content);
            } catch (e) {
                textEl.textContent = content;
            }
        } else {
            textEl.textContent = content;
        }
    }

    /**
     * Fetch the formatted assistant message for a research from the messages API.
     * Returns the message object or null.
     *
     * Takes ``sid`` explicitly rather than closing over module-level
     * ``sessionId``: handleResearchComplete may retry for up to 3.5s
     * during which the user could start a new chat (which mutates
     * ``sessionId``). Without the snapshot, the retry would hit the
     * new session's API and the ghost-message fallback path would
     * insert "Research completed but no report available." into the
     * NEW session's DOM.
     */
    async function fetchFormattedMessage(researchId, sid) {
        const response = await fetch(`/api/chat/sessions/${sid}/messages`, {
            headers: { 'X-CSRFToken': getCsrfToken() }
        });
        if (!response.ok) return null;
        const data = await response.json();
        if (!data.success || !data.messages) return null;
        // message_type='step' rows ALSO carry role=assistant + research_id
        // (they're the per-iteration progress milestones written by
        // research_service._save_chat_message_and_context). Without the
        // explicit filter, .find() returns the first such step
        // ("Starting research process") instead of the actual response.
        return data.messages.find(m =>
            m.role === 'assistant'
            && m.research_id === researchId
            && m.content
            && m.message_type !== 'step'
        ) || null;
    }

    async function fetchFormattedMessageWithRetry(researchId, sid) {
        const delays = [0, 500, 1000, 2000];
        for (const delay of delays) {
            if (delay > 0) await new Promise(r => setTimeout(r, delay));
            try {
                const msg = await fetchFormattedMessage(researchId, sid);
                if (msg && msg.content) return msg;
            } catch (e) {
                _log.error('Error fetching formatted message:', e);
            }
        }
        return null;
    }

    /**
     * Start a new chat.
     */
    function startNewChat() {
        completionHandled = true;
        resetActiveResearchState();

        sessionId = null;
        // Reflect the actual input state rather than force-enabling: a
        // fresh chat has an empty textarea, so send should be disabled
        // until the user types (matches the input listener's gate).
        // isProcessing was just cleared by resetActiveResearchState().
        sendBtn.disabled = !chatInput.value.trim() || isProcessing;

        showWelcomeScreen();
        updateTitle('New Chat');
        hideSessionButtons();
        window.history.pushState({}, '', '/chat/');
        chatInput.focus();
    }

    /**
     * Add a message to the UI.
     */
    /**
     * Build a finalized message bubble element without inserting it
     * into the DOM. Used by both ``addMessageToUI`` (which appends) and
     * the "load older messages" pagination path (which prepends).
     */
    function buildMessageBubble(role, content, timestamp, researchId) {
        const messageEl = createMessageElement(
            [`ldr-chat-message-${role}`],
            role === 'user' ? 'fa-user' : 'fa-robot'
        );
        if (!messageEl) {
            _log.error('Message template not found');
            return null;
        }
        // Tag the bubble with its research id so the post-completion
        // safety check in refreshSessionMessages can detect whether the
        // assistant response actually landed in the DOM (vs missing due
        // to a streaming-swap race) and auto-recover by reloading.
        // Also used by the per-attempt Retry/Delete action handlers to
        // locate every element of the attempt (user msg + assistant msg
        // + step accordion) without walking siblings.
        if (researchId) {
            messageEl.dataset.researchId = researchId;
        }

        const textEl = messageEl.querySelector('.ldr-chat-message-text');
        if (textEl) {
            if (role === 'assistant') {
                renderToElement(textEl, content);
            } else {
                textEl.textContent = content;
            }
        }

        if (role === 'assistant' && researchId) {
            _appendResearchLink(messageEl, researchId);
        }

        const timeEl = messageEl.querySelector('.ldr-chat-message-time');
        if (timeEl && timestamp) {
            const date = new Date(timestamp);
            timeEl.textContent = date.toLocaleTimeString();
        }

        _appendMessageActions(messageEl, role, content, researchId);

        return messageEl;
    }

    /**
     * Build the per-message hover-action row (Copy / Retry / Delete).
     *
     * Mirrors the ChatGPT/Claude.ai pattern: a small icon row below the
     * bubble's content, revealed on hover AND keyboard focus (a11y —
     * the keyboard path is the focus-within branch in chat.css).
     *
     * Retry and Delete are only attached when the message has a
     * ``researchId``: they operate on the whole attempt (user msg +
     * research + assistant response + steps), so a no-research message
     * (e.g. one sent with trigger_research=false) has nothing to
     * retry or delete at the attempt level. Copy is always available.
     *
     * Click handling is delegated from #chat-messages in
     * setupEventListeners so a single listener covers every bubble,
     * including ones added later by the streaming path.
     */
    function _appendMessageActions(messageEl, role, content, researchId) {
        const contentEl = messageEl.querySelector('.ldr-chat-message-content');
        if (!contentEl) return;

        const actionsEl = document.createElement('div');
        actionsEl.className = 'ldr-chat-message-actions';
        // Store content for the copy handler. TextContent of a user
        // message is the raw input; for an assistant bubble, the
        // rendered markdown is what the user sees and would expect on
        // the clipboard. Both are retrievable via the .ldr-chat-message-
        // text child.
        actionsEl.dataset.copyContent = content || '';

        // Copy — always available.
        actionsEl.appendChild(_buildActionButton({
            action: 'copy',
            iconClass: 'fa-copy',
            label: role === 'user' ? 'Copy message' : 'Copy response',
        }));

        // Retry / Delete — only when this message belongs to a research
        // attempt. The endpoint keys off researchId.
        if (researchId) {
            actionsEl.appendChild(_buildActionButton({
                action: 'retry',
                iconClass: 'fa-rotate-right',
                label: 'Retry this attempt',
            }));
            actionsEl.appendChild(_buildActionButton({
                action: 'delete',
                iconClass: 'fa-trash-can',
                label: 'Delete this attempt',
            }));
        }

        contentEl.appendChild(actionsEl);
    }

    function _buildActionButton({action, iconClass, label}) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = `ldr-chat-msg-action ldr-chat-msg-action-${action}`;
        btn.dataset.action = action;
        btn.setAttribute('aria-label', label);
        btn.setAttribute('title', label);
        const icon = document.createElement('i');
        icon.className = `fas ${iconClass}`;
        btn.appendChild(icon);
        return btn;
    }

    function addMessageToUI(role, content, timestamp, researchId) {
        const messageEl = buildMessageBubble(role, content, timestamp, researchId);
        if (!messageEl) return;
        appendBeforeThinking(messageEl);
        // Always scroll for finalized messages (user just sent or got a reply).
        scrollToBottom(true);
    }

    /**
     * Append a "View full research" link to a message element.
     */
    function _appendResearchLink(messageEl, researchId) {
        const contentEl = messageEl.querySelector('.ldr-chat-message-content');
        if (!contentEl) return;

        // Build the link via DOM APIs rather than innerHTML so the
        // researchId can never escape the href attribute even if a
        // future change to URLBuilder.resultsPage starts including
        // user-controlled query params. setAttribute('href', url) also
        // makes the browser reject `javascript:` schemes that an
        // unsanitized template literal would not.
        const linkEl = document.createElement('div');
        linkEl.className = 'ldr-chat-research-link';
        const url = typeof URLBuilder !== 'undefined'
            ? URLBuilder.resultsPage(researchId)
            : `/results/${encodeURIComponent(researchId)}`;
        const anchor = document.createElement('a');
        anchor.setAttribute('href', url);
        anchor.className = 'ldr-chat-research-link-btn';
        const icon = document.createElement('i');
        icon.className = 'fas fa-flask';
        anchor.appendChild(icon);
        anchor.appendChild(document.createTextNode(' View full research'));
        anchor.addEventListener('click', function(e) {
            e.stopPropagation();
        });
        linkEl.appendChild(anchor);
        contentEl.appendChild(linkEl);
    }

    /**
     * Set (or clear) the thinking indicator's status label. Reuses the same
     * `.ldr-chat-thinking-text` slot the live reasoning preview later writes
     * to. Passing a falsy text hides the slot.
     */
    function setThinkingLabel(thinkingEl, text) {
        if (!thinkingEl) return;
        const el = thinkingEl.querySelector('.ldr-chat-thinking-text');
        if (!el) return;
        el.textContent = text || '';
        el.hidden = !text;
    }

    /**
     * Show thinking indicator, optionally with a status label (e.g.
     * "Summarizing previous conversation…" while a follow-up's prior-context
     * is being built during the send request).
     */
    function showThinking(label = null) {
        const template = document.getElementById('thinking-template');
        if (!template) return null;

        const thinkingEl = template.content.cloneNode(true).firstElementChild;
        setThinkingLabel(thinkingEl, label);
        chatMessages.appendChild(thinkingEl);
        // Always scroll — user just sent a message and expects to see feedback.
        scrollToBottom(true);
        return thinkingEl;
    }

    /**
     * Remove thinking indicator.
     */
    function removeThinking(thinkingEl) {
        if (thinkingEl && thinkingEl.parentNode) {
            thinkingEl.remove();
        }
    }

    /**
     * Create a step message element (used both live and from DB).
     */
    function createStepElement(iconClass, text) {
        const el = createMessageElement(['ldr-chat-message-assistant', 'ldr-chat-message-step'], iconClass);
        if (!el) return null;
        const textEl = el.querySelector('.ldr-chat-message-text');
        if (textEl) textEl.textContent = text;
        const metaEl = el.querySelector('.ldr-chat-message-meta');
        if (metaEl) metaEl.style.display = 'none';
        return el;
    }

    /**
     * Ensure a live accordion exists in the chat, creating one if needed.
     * Returns the inner wrapper element where step elements are appended.
     */
    function ensureLiveAccordion() {
        if (liveAccordion) {
            return liveAccordion.querySelector('.ldr-chat-steps-content > div');
        }
        const group = document.createElement('div');
        group.className = 'ldr-chat-steps-group';
        group.dataset.live = 'true';
        // DOM element id for aria-controls wiring only; not security-sensitive
        // (a collision is at most a duplicate id) -- Bearer false positive.
        // bearer:disable javascript_lang_insufficiently_random_values
        const contentId = `ldr-chat-steps-content-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
        const header = document.createElement('button');
        header.className = 'ldr-chat-steps-header';
        header.setAttribute('aria-expanded', 'true');
        header.setAttribute('aria-controls', contentId);
        header.type = 'button';
        // static literal markup only, no user/LLM input -- Bearer false positive.
        // bearer:disable javascript_lang_dangerous_insert_html
        header.innerHTML =
            '<i class="fas fa-chevron-right ldr-chat-steps-chevron"></i>' +
            '<i class="fas fa-flask"></i>' +
            '<span class="ldr-chat-steps-label">Research Steps (0)</span>';
        header.addEventListener('click', () => {
            const isCollapsed = group.classList.toggle('ldr-chat-steps-collapsed');
            header.setAttribute('aria-expanded', String(!isCollapsed));
        });
        group.appendChild(header);
        const content = document.createElement('div');
        content.className = 'ldr-chat-steps-content';
        content.id = contentId;
        const inner = document.createElement('div');
        content.appendChild(inner);
        group.appendChild(content);
        liveAccordion = group;
        appendBeforeThinking(group);
        return inner;
    }

    /**
     * Update the step count label in the live accordion header.
     */
    function updateLiveAccordionCount() {
        if (!liveAccordion) return;
        const inner = liveAccordion.querySelector('.ldr-chat-steps-content > div');
        if (!inner) return;
        const count = inner.children.length;
        const label = liveAccordion.querySelector('.ldr-chat-steps-label');
        if (label) label.textContent = 'Research Steps (' + count + ')';
    }

    /**
     * Add a live step message inside the growing accordion.
     */
    function addLiveStepMessage(iconClass, text) {
        if (!chatMessages || !text) return;
        const el = createStepElement(iconClass, text);
        if (!el) return;
        el.classList.add('ldr-chat-step-live');
        el.addEventListener('click', () => el.classList.toggle('ldr-step-expanded'));
        const inner = ensureLiveAccordion();
        if (!inner) return;
        inner.appendChild(el);
        // Remove oldest if over limit
        while (inner.children.length > MAX_LIVE_STEPS) {
            inner.removeChild(inner.firstChild);
        }
        updateLiveAccordionCount();
        scrollToBottom();
    }

    /**
     * Update the text of the last live step message (same-phase dedup).
     */
    function updateLastLiveStep(text) {
        if (!liveAccordion || !text) return;
        const inner = liveAccordion.querySelector('.ldr-chat-steps-content > div');
        if (!inner || inner.children.length === 0) return;
        const lastStep = inner.lastElementChild;
        const textEl = lastStep?.querySelector('.ldr-chat-message-text');
        if (textEl) textEl.textContent = text;
    }

    /**
     * Remove all live step messages (called on error/navigation).
     */
    function removeLiveStepMessages() {
        if (liveAccordion && liveAccordion.parentNode) {
            liveAccordion.remove();
        }
        liveAccordion = null;
        lastStepPhase = null;
    }

    /**
     * Infer step icon from message content (best-effort for DB-loaded steps).
     */
    function getStepIconForContent(content) {
        const c = (content || '').toLowerCase();
        if (c.includes('starting research')) return STEP_ICONS.init;
        if (c.includes('using') && (c.includes('model') || c.includes('search tool'))) return STEP_ICONS.setup;
        if (c.includes('search_plan') || c.includes('planning')) return STEP_ICONS.search_planning;
        if (c.includes('result from') || c.includes('title:')) return STEP_ICONS.observation;
        if (c.includes('search') || c.includes('engine')) return STEP_ICONS.search;
        if (c.includes('report')) return STEP_ICONS.report_generation;
        if (c.includes('generat') || c.includes('summary')) return STEP_ICONS.output_generation;
        if (c.includes('error') || c.includes('fail')) return STEP_ICONS.error;
        if (c.includes('fallback')) return STEP_ICONS.synthesis_fallback;
        return STEP_ICONS._default;
    }

    /**
     * Wrap step message elements in a collapsible accordion group.
     */
    function wrapStepsInAccordion(stepElements) {
        if (!stepElements || stepElements.length === 0) return null;
        const group = document.createElement('div');
        group.className = 'ldr-chat-steps-group ldr-chat-steps-collapsed';
        // Generate a unique id so the header's aria-controls can point
        // at the content panel. Date.now alone collides if two
        // accordions are wrapped in the same millisecond (rare but
        // possible during fast reload), so combine with a counter
        // suffix via Math.random.
        // DOM element id for aria-controls wiring only; not security-sensitive
        // (a collision is at most a duplicate id) -- Bearer false positive.
        // bearer:disable javascript_lang_insufficiently_random_values
        const contentId = `ldr-chat-steps-content-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
        // Header — button for keyboard accessibility
        const header = document.createElement('button');
        header.className = 'ldr-chat-steps-header';
        header.setAttribute('aria-expanded', 'false');
        header.setAttribute('aria-controls', contentId);
        header.type = 'button';
        // static literals + integer stepElements.length, no user/LLM input --
        // Bearer false positive. (Bare rule id on its own line; the eslint
        // directive below must stay adjacent to the innerHTML assignment.)
        // bearer:disable javascript_lang_dangerous_insert_html
        // eslint-disable-next-line no-unsanitized/property -- audited: all content is static strings, no user input
        header.innerHTML =
            '<i class="fas fa-chevron-right ldr-chat-steps-chevron"></i>' +
            '<i class="fas fa-flask"></i>' +
            '<span class="ldr-chat-steps-label">Research Steps (' + stepElements.length + ')</span>';
        header.addEventListener('click', () => {
            const isCollapsed = group.classList.toggle('ldr-chat-steps-collapsed');
            header.setAttribute('aria-expanded', String(!isCollapsed));
        });
        group.appendChild(header);
        // Content with inner wrapper for grid-template-rows animation
        const content = document.createElement('div');
        content.className = 'ldr-chat-steps-content';
        content.id = contentId;
        const inner = document.createElement('div');
        stepElements.forEach(el => {
            el.classList.remove('ldr-chat-step-live');
            // Click to expand/collapse individual step text
            el.addEventListener('click', () => {
                el.classList.toggle('ldr-step-expanded');
            });
            inner.appendChild(el);
        });
        content.appendChild(inner);
        group.appendChild(content);
        return group;
    }

    /**
     * Wire up a one-shot 'connect' handler so we re-subscribe to
     * the active research's channel after a websocket reconnect.
     *
     * Socket.IO fires the `connect` event both on the initial
     * connection and on every reconnect. The handler is registered
     * once at init time on the underlying socket instance — it
     * persists across reconnects because the wrapping Socket object
     * itself is reused.
     *
     * The handler needs ``thinkingEl``, but at reconnect time we
     * don't have the original DOM element. We pass the live
     * ``streamingMessageEl`` (which is what the chunk handler
     * actually appends into); ``handleProgressUpdate`` and
     * ``handleResponseChunk`` both tolerate a null thinkingEl, so
     * passing null when no streaming bubble exists yet is fine too.
     */
    function setupSocketReconnectHandler() {
        if (!window.socket) return;
        const socket = window.socket.getSocketInstance && window.socket.getSocketInstance();
        if (!socket) return;
        socket.on('connect', () => {
            if (!currentResearchId) return;
            _log.info('Chat: socket reconnected, re-attaching listeners for research', currentResearchId);
            // Re-attach listeners only — preserve streamingMessageEl
            // and streamedContent so partial content already in the
            // DOM survives the reconnect. Future chunks land on the
            // existing bubble; chunks emitted during the disconnect
            // window are lost (but handleResearchComplete fetches the
            // final formatted answer from the DB on completion).
            attachResearchListeners(currentResearchId, streamingMessageEl);
        });
    }

    /**
     * Internal: register the progress + chunk listeners for a
     * research. Does NOT touch streaming state — safe to call from
     * both subscribeToResearch (initial subscribe) and the
     * reconnect handler (re-attach without losing partial content).
     *
     * Captures researchId at call time and bails out if the user
     * has since switched sessions, so a late-firing retry doesn't
     * subscribe a stale handler.
     */
    function attachResearchListeners(researchId, thinkingEl, retryCount = 0) {
        const maxRetries = 10;
        const retryDelay = 500;

        if (currentResearchId !== researchId) {
            return;
        }

        if (!window.socket) {
            if (retryCount < maxRetries) {
                subscribeRetryTimerId = setTimeout(
                    () => attachResearchListeners(researchId, thinkingEl, retryCount + 1),
                    retryDelay,
                );
                return;
            }
            _log.warn('Chat: Socket not available after retries, using polling only');
            return;
        }

        // Subscribe to progress updates. Wrap the callback in try/catch so
        // an unhandled exception inside handleProgressUpdate doesn't get
        // swallowed by socket.io and leave isProcessing=true forever.
        //
        // CRITICAL: pass the SAME reference each time so socket.js's
        // by-reference dedup (Array.includes) actually fires. A fresh
        // arrow function each call would accumulate one handler per
        // reconnect, multiplying progress callbacks N+1 times after
        // N reconnects.
        if (_stableProgressCb === null) {
            _stableProgressCb = (data) => {
                // Always read current research/element from closure state —
                // a stale `thinkingEl` captured at subscribe time would be
                // wrong after session switches.
                if (data && data.research_id && data.research_id !== currentResearchId) {
                    return;  // Late event for an old research; ignore.
                }
                try {
                    handleProgressUpdate(data, streamingMessageEl);
                } catch (err) {
                    _log.error('Chat: progress handler threw', err);
                    handleResearchError(streamingMessageEl, err && err.message ? err.message : 'Internal error');
                }
            };
        }
        window.socket.subscribeToResearch(researchId, _stableProgressCb);

        // Subscribe to streaming response chunks. Same try/catch wrapper —
        // socket.io.on() registration bypasses the socket.js wrapper that
        // protects subscribeToResearch, so we add our own here.
        const socket = window.socket.getSocketInstance();
        if (socket) {
            // Remove any existing listener to prevent duplicates on retry/reconnect
            socket.off(`response_chunk_${researchId}`);
            socket.on(`response_chunk_${researchId}`, (data) => {
                try {
                    handleResponseChunk(data, thinkingEl);
                } catch (err) {
                    _log.error('Chat: response_chunk handler threw', err);
                    handleResearchError(thinkingEl, err && err.message ? err.message : 'Internal error');
                }
            });
        } else if (retryCount < maxRetries) {
            // Socket service exists but instance not ready yet
            subscribeRetryTimerId = setTimeout(
                () => attachResearchListeners(researchId, thinkingEl, retryCount + 1),
                retryDelay,
            );
        }
    }

    /**
     * Subscribe to research progress updates for a NEW research.
     *
     * Resets streaming state then attaches listeners. The reconnect
     * handler uses ``attachResearchListeners`` directly so it can
     * preserve the partial-content state.
     */
    function subscribeToResearch(researchId, thinkingEl) {
        // Reset streaming and step state
        streamingMessageEl = null;
        streamedContent = '';
        streamTruncated = false;
        streamingComplete = false;
        lastStepPhase = null;

        attachResearchListeners(researchId, thinkingEl);

        // Also set up polling as backup
        pollForCompletion(researchId, thinkingEl);
    }

    /**
     * Handle progress update from socket.
     */
    function handleProgressUpdate(data, thinkingEl) {
        // Extract message from data - can be in data.message or data.log_entry.message
        const message = data.message || data.log_entry?.message;

        // Determine if this is a milestone event
        let isMilestone = false;

        if (data.type === 'milestone' || data.type === 'MILESTONE') {
            isMilestone = true;
        } else if (data.log_entry?.type === 'milestone' || data.log_entry?.type === 'MILESTONE') {
            isMilestone = true;
        } else if (data.metadata?.phase) {
            isMilestone = true;
        }

        // Update current task display with milestone messages
        if (message && isMilestone) {
            updateCurrentTask(message);
        }

        // Live activity preview in the thinking bubble: surface what
        // the agent is currently doing (search query, observation, or
        // intermediate reasoning text) inside the thinking indicator so
        // the dots aren't sitting there mute. Only the CURRENT step is
        // shown — each event overwrites the prior.
        //
        // We can't rely solely on phase='agent_reasoning' because most
        // tool-calling LLMs (Ollama/OpenAI/Anthropic in tool-call mode)
        // emit AIMessages with EITHER content OR tool_calls, rarely
        // both — so the dedicated reasoning branch on the backend
        // almost never fires. Instead, any progress message with a
        // meaningful phase populates the text area. Suppress the noisy
        // low-level traces (raw search-engine debug lines) by checking
        // for a phase tag, which the strategy adds for user-facing
        // events (tool_call, observation, agent_reasoning, synthesis,
        // milestone) but not for internal log spam.
        if (message && !streamingMessageEl) {
            const phaseTag = data.phase || data.metadata?.phase;
            const isMilestoneEvent = isMilestone || !!phaseTag;
            if (isMilestoneEvent) {
                const thinkingBubble = chatMessages?.querySelector('.ldr-chat-message-thinking');
                const thinkingTextEl = thinkingBubble?.querySelector('.ldr-chat-thinking-text');
                if (thinkingTextEl) {
                    thinkingTextEl.textContent = message;
                    thinkingTextEl.hidden = false;
                }
            }
        }

        // Add live step message for significant phases (dedup by phase)
        if (message && !streamingMessageEl) {
            const phase = data.phase;
            if (STEP_PHASES.has(phase)) {
                if (phase !== lastStepPhase || phase === 'observation') {
                    // Each observation is a distinct source — always create new bubble
                    addLiveStepMessage(STEP_ICONS[phase] || STEP_ICONS._default, message);
                    lastStepPhase = phase;
                } else {
                    updateLastLiveStep(message);
                }
            } else if (data.phase === 'tool_call') {
                // Tool calls — each is a distinct user-meaningful event
                // (the agent often issues multiple searches in one step,
                // e.g. "search for X" and "search for Y" emitted in the
                // same AIMessage). Previously consecutive tool_call events
                // were collapsed via updateLastLiveStep, hiding all but
                // the last query. Treat them like observation — always a
                // new row — so the full Searched→Got result→Searched
                // timeline is visible.
                addLiveStepMessage(STEP_ICONS._tool, message);
                lastStepPhase = 'tool_call';
            }
        }

        // Handle completion
        if (data.status === 'completed' || data.progress >= 100) {
            handleResearchComplete(thinkingEl);
        }

        // Handle suspension (user stopped the research). Preserve the
        // partial bubble — do NOT route through handleResearchError, which
        // would replace it with a generic error message.
        if (data.status === 'suspended') {
            handleResearchSuspended(thinkingEl);
        }

        // Handle error
        if (data.status === 'failed' || data.status === 'error') {
            handleResearchError(thinkingEl, data.error || 'Research failed');
        }
    }

    /**
     * Render the current accumulated streamed content to the DOM.
     * Called on a debounce timer to avoid excessive re-renders.
     */
    function renderStreamedContent() {
        if (!streamingMessageEl || !streamedContent) return;

        const textEl = streamingMessageEl.querySelector('.ldr-chat-message-text');
        if (textEl) {
            renderToElement(textEl, streamedContent);
        }
        scrollToBottom();
    }

    /**
     * Handle streaming response chunks.
     * Supports both true LLM streaming (is_streaming=true) and post-generation chunking.
     */
    function handleResponseChunk(data, thinkingEl) {
        const { chunk, is_final } = data;

        // Create streaming message element on the FIRST response_chunk event,
        // regardless of whether `chunk` itself is empty. Otherwise an
        // empty-first-chunk-then-final sequence (which happens when the
        // backend's stream_callback fires with no text and only the final
        // empty signal arrives) leaves no element for is_final to populate.
        // is_final's render path falls back to streamedContent when chunk
        // is empty, so we don't lose any text by creating early.
        if (!streamingMessageEl) {
            // Remove thinking indicator
            removeThinking(thinkingEl);

            // Create a new message element for streaming
            streamingMessageEl = createMessageElement(
                ['ldr-chat-message-assistant', 'ldr-chat-message-streaming'], 'fa-robot'
            );
            if (streamingMessageEl) {
                // Tag with the active research id so the post-completion
                // safety check (refreshSessionMessages) can detect whether
                // this bubble actually carries the final response. Without
                // this, a transient swap failure would never auto-recover.
                if (currentResearchId) {
                    streamingMessageEl.dataset.researchId = currentResearchId;
                }
                // Do NOT set role="status"/aria-live on the streaming
                // bubble: it is appended into #chat-messages, which is
                // already role="log" aria-live="polite". A nested live
                // region would make screen readers announce every streamed
                // chunk twice. The parent log region already covers
                // announcements. (A future refinement could suppress
                // per-token chatter and announce only the finalized
                // message, but that needs assistive-tech testing.)
                if (chatMessages) {
                    chatMessages.appendChild(streamingMessageEl);
                } else {
                    _log.error('Chat: chatMessages element not found');
                }
            } else {
                _log.error('Chat: Message template not found');
            }
        }

        // Accumulate content (bounded — see MAX_STREAM_BUFFER_CHARS).
        if (chunk) {
            if (streamedContent.length < MAX_STREAM_BUFFER_CHARS) {
                streamedContent += chunk;
            } else if (!streamTruncated) {
                // Cross the cap exactly once: append a visible notice and
                // stop accumulating so a runaway stream can't OOM the tab.
                streamTruncated = true;
                streamedContent +=
                    '\n\n_(Response truncated — exceeded display limit.)_';
            }

            // Debounced render: update the DOM at most every 100ms during streaming
            // to avoid re-parsing markdown 1000+ times for token-level chunks.
            if (!is_final && !renderPending) {
                renderPending = true;
                renderTimer = setTimeout(() => {
                    renderStreamedContent();
                    renderPending = false;
                }, 100);
            }
        }

        // Handle final chunk — render immediately
        if (is_final) {
            // Cancel any pending debounced render
            if (renderTimer) {
                clearTimeout(renderTimer);
                renderTimer = null;
                renderPending = false;
            }

            streamingComplete = true;

            if (streamingMessageEl) {
                // Use the final chunk content if provided, otherwise use accumulated.
                // Guard against a partial / truncated final chunk silently
                // replacing the fully-accumulated streamed text: if `chunk` is
                // shorter than half of what's already been streamed, treat it
                // as a partial-delivery anomaly and keep the accumulated buffer.
                let finalContent;
                if (chunk && chunk.length >= streamedContent.length * 0.5) {
                    finalContent = chunk;
                    streamedContent = chunk;
                } else if (chunk && streamedContent) {
                    _log.warn('Chat: is_final chunk shorter than 50% of streamed content; keeping accumulated buffer', { chunkLen: chunk.length, streamedLen: streamedContent.length });
                    finalContent = streamedContent;
                } else {
                    finalContent = chunk || streamedContent;
                    if (chunk) streamedContent = chunk;
                }
                const textEl = streamingMessageEl.querySelector('.ldr-chat-message-text');
                if (textEl) {
                    renderToElement(textEl, finalContent);
                }

                streamingMessageEl.classList.remove('ldr-chat-message-streaming');

                // Add timestamp
                const timeEl = streamingMessageEl.querySelector('.ldr-chat-message-time');
                if (timeEl) {
                    timeEl.textContent = new Date().toLocaleTimeString();
                }

                // Final chunk — assistant message is complete; always scroll.
                scrollToBottom(true);
            }

            // NOTE: Do NOT cleanup listeners or reset state here.
            // handleResearchComplete will handle all cleanup when progress=100 arrives.
            // Cleaning up here would remove the progress handler, preventing
            // handleResearchComplete from ever running (which handles hideProgress,
            // isProcessing=false, sendBtn enable, etc.).
        }
    }

    /**
     * Poll for research completion as backup.
     */
    function pollForCompletion(researchId, thinkingEl) {
        let pollCount = 0;
        const maxPolls = 600; // 10 minutes max
        const pollInterval = 1000;

        // Capture the researchId this closure was created for. If the user
        // switches sessions mid-research, currentResearchId moves to the
        // new research; the old timer should NOT continue completing the
        // old research onto the new session's UI.
        const ownResearchId = researchId;

        const poll = async () => {
            if (
                pollCount >= maxPolls
                || !currentResearchId
                || currentResearchId !== ownResearchId
            ) {
                pollTimerId = null;
                return;
            }

            try {
                const response = await fetch(`/api/research/${researchId}/status`, {
                    headers: { 'X-CSRFToken': getCsrfToken() }
                });
                if (!response.ok) throw new Error(`Status check failed (${response.status})`);
                const data = await response.json();

                if (data.status === 'completed') {
                    pollTimerId = null;
                    handleResearchComplete(thinkingEl);
                    return;
                }

                if (data.status === 'suspended') {
                    pollTimerId = null;
                    handleResearchSuspended(thinkingEl);
                    return;
                }

                if (data.status === 'failed' || data.status === 'error') {
                    pollTimerId = null;
                    // The /api/research/<id>/status endpoint nests the
                    // failure reason under metadata.error_info.message. The
                    // legacy `data.error` field is not part of that shape, so
                    // we read the canonical path and fall back to a generic
                    // string if it is absent.
                    const errMsg =
                        (data.metadata
                            && data.metadata.error_info
                            && data.metadata.error_info.message)
                        || data.error
                        || 'Research failed';
                    handleResearchError(thinkingEl, errMsg);
                    return;
                }

                // Continue polling
                pollCount++;
                pollTimerId = setTimeout(poll, pollInterval);

            } catch (error) {
                _log.error('Polling error:', error);
                pollCount++;
                pollTimerId = setTimeout(poll, pollInterval * 2);
            }
        };

        pollTimerId = setTimeout(poll, pollInterval);
    }

    /**
     * Handle research completion.
     */
    async function handleResearchComplete(thinkingEl) {
        // Prevent duplicate calls
        if (completionHandled) {
            return;
        }
        // Cross-guard: if a suspension was already handled (user stopped),
        // do NOT also run the completion path. A stray 'completed' signal
        // can still arrive (e.g. the server cleanup emit) after a stop;
        // honoring it would render an answer on top of the stopped state.
        if (suspendedHandled) {
            return;
        }
        if (!currentResearchId) {
            return;
        }

        completionHandled = true;  // Mark as handled
        const researchIdToFetch = currentResearchId;
        // Snapshot sessionId BEFORE any await. handleResearchComplete may
        // run for up to 3.5s while the user navigates away (startNewChat /
        // loadSession both reassign module-level sessionId). Without this
        // snapshot, post-await DOM writes land in the wrong session's view.
        const sessionIdAtComplete = sessionId;

        try {
            // Remove thinking indicator
            removeThinking(thinkingEl);

            // If streaming started but final chunk hasn't arrived yet, wait briefly
            if (!streamingComplete && streamingMessageEl && streamedContent) {
                await new Promise(resolve => {
                    let waited = 0;
                    const check = () => {
                        if (streamingComplete || waited >= 5000) {
                            resolve();
                        } else {
                            waited += 200;
                            setTimeout(check, 200);
                        }
                    };
                    check();
                });
            }

            if (streamingComplete && streamingMessageEl) {
                // Streaming showed raw LLM tokens. Replace with the formatted
                // report (which has citations/links) from the database.
                streamingMessageEl.classList.remove('ldr-chat-message-streaming');
                try {
                    const formatted = await fetchFormattedMessageWithRetry(
                        researchIdToFetch,
                        sessionIdAtComplete,
                    );
                    // Guard against session switch during the await — if the
                    // user navigated away, don't write into the new session's
                    // streamingMessageEl (which would be a different element
                    // or null, but also conceptually wrong).
                    if (sessionId !== sessionIdAtComplete) {
                        return;
                    }
                    if (formatted) {
                        const textEl = streamingMessageEl.querySelector('.ldr-chat-message-text');
                        if (textEl) renderToElement(textEl, formatted.content);
                    }
                } catch (e) {
                    _log.error('Failed to fetch formatted report:', e);
                }
                if (researchIdToFetch && sessionId === sessionIdAtComplete) {
                    _appendResearchLink(streamingMessageEl, researchIdToFetch);
                }
            } else {
                // Streaming didn't complete — but if we have a partial
                // bubble already on screen (carry-buffer flush emitted
                // some content before the socket dropped, then the
                // is_final sentinel was lost), reuse it via in-place
                // swap instead of removing and reinserting. The
                // remove+addMessageToUI path produces a visible 5-8.5s
                // vanish-then-refetch flicker. Mirrors the
                // streamingComplete=true branch above. (Kept as else{if}
                // rather than `else if` so the comment above stays attached
                // to this branch; folding it would force a 40+ line reindent
                // of the delicate completion handler for a pure style rule.)
                // eslint-disable-next-line no-lonely-if
                if (streamingMessageEl && streamingMessageEl.parentNode) {
                    streamingMessageEl.classList.remove('ldr-chat-message-streaming');
                    try {
                        const msg = await fetchFormattedMessageWithRetry(
                            researchIdToFetch,
                            sessionIdAtComplete,
                        );
                        if (sessionId !== sessionIdAtComplete) {
                            return;
                        }
                        if (msg) {
                            const textEl = streamingMessageEl.querySelector('.ldr-chat-message-text');
                            if (textEl) renderToElement(textEl, msg.content);
                            if (msg.research_id) {
                                _appendResearchLink(streamingMessageEl, msg.research_id);
                            }
                        } else {
                            const textEl = streamingMessageEl.querySelector('.ldr-chat-message-text');
                            if (textEl) renderToElement(textEl, 'Research completed — the chat copy of the answer could not be loaded. Open the full report below.');
                            _appendResearchLink(streamingMessageEl, researchIdToFetch);
                        }
                    } catch (e) {
                        if (sessionId === sessionIdAtComplete) {
                            const textEl = streamingMessageEl.querySelector('.ldr-chat-message-text');
                            if (textEl) renderToElement(textEl, 'Research completed — the chat copy of the answer could not be loaded. Open the full report below.');
                            _appendResearchLink(streamingMessageEl, researchIdToFetch);
                        }
                    }
                } else {
                    // No partial bubble on screen — original fetch-and-insert
                    // path (no flicker to avoid since there's nothing to remove).
                    try {
                        const msg = await fetchFormattedMessageWithRetry(
                            researchIdToFetch,
                            sessionIdAtComplete,
                        );
                        if (sessionId !== sessionIdAtComplete) {
                            return;
                        }
                        if (msg) {
                            addMessageToUI('assistant', msg.content, null, msg.research_id);
                        } else {
                            addMessageToUI('assistant', 'Research completed — the chat copy of the answer could not be loaded. Open the full report below.', null, researchIdToFetch);
                        }
                    } catch (e) {
                        if (sessionId === sessionIdAtComplete) {
                            addMessageToUI('assistant', 'Research completed — the chat copy of the answer could not be loaded. Open the full report below.', null, researchIdToFetch);
                        }
                    }
                }
            }

            // Update session title AND verify the assistant response
            // landed in the DOM. The second job — re-rendering from DB
            // when the streaming swap left the bubble missing — is the
            // safety net that removes the need for a manual page refresh
            // when the live-chunk path didn't end up with a visible
            // response (transient socket drop, race on session switch,
            // missed is_final, etc.).
            await refreshSessionMessages(researchIdToFetch);

        } catch (error) {
            _log.error('Error fetching research result:', error);
            if (!streamingComplete) {
                addMessageToUI('assistant', 'Research completed but there was an error loading the results.');
            }
        } finally {
            cancelPendingTimers();
            hideProgress();
            isProcessing = false;
            sendBtn.disabled = !chatInput.value.trim();

            // Collapse live accordion and clean up live classes
            if (liveAccordion) {
                liveAccordion.classList.add('ldr-chat-steps-collapsed');
                const header = liveAccordion.querySelector('.ldr-chat-steps-header');
                if (header) header.setAttribute('aria-expanded', 'false');
                liveAccordion.querySelectorAll('.ldr-chat-step-live').forEach(el => {
                    el.classList.remove('ldr-chat-step-live');
                });
                delete liveAccordion.dataset.live;
                liveAccordion = null;
            }
            lastStepPhase = null;

            cleanupSocketListeners(researchIdToFetch);
            resetStreamingVars();
            showSessionButtons();
        }
    }

    /**
     * Handle research error.
     */
    function handleResearchError(thinkingEl, errorMessage) {
        // Cross-guard mirroring handleResearchSuspended: once completion has
        // been claimed, a late 'failed'/'error' event — e.g. one arriving
        // during handleResearchComplete's multi-second fetch await — must not
        // run the error path. Doing so would call resetStreamingVars() and null
        // streamingMessageEl mid-await, throwing inside the completion handler
        // and replacing the rendered answer with a generic error.
        if (completionHandled) {
            return;
        }
        cancelPendingTimers();
        if (currentResearchId) {
            cleanupSocketListeners(currentResearchId);
        }

        removeThinking(thinkingEl);
        removeLiveStepMessages();
        // Discard the partial streamed bubble if one was being assembled,
        // so the error message stands alone instead of being orphaned next
        // to a half-rendered response.
        if (streamingMessageEl && streamingMessageEl.parentNode) {
            streamingMessageEl.remove();
        }
        addMessageToUI('assistant', errorMessage);
        hideProgress();
        isProcessing = false;
        sendBtn.disabled = !chatInput.value.trim();
        resetStreamingVars();
        // Restore the session-level controls (edit-title, export) that
        // hideSessionButtons() suppressed while research was running. Without
        // this, an error path leaves the bubble visible but the surrounding
        // actions invisible until the next page reload — the same gap fixed
        // for the completion handler's finally block.
        showSessionButtons();
    }

    /**
     * User clicked the Stop button. Calls the existing terminate API
     * and lets the SUSPENDED progress event drive the final UI state.
     * Mirrors progress.js:handleCancelResearch but without the confirm
     * dialog and global toast — chat shows the result inline.
     */
    async function handleStopResearch() {
        // Snapshot research_id before the await; module-scope element refs
        // are touched directly (matches progress.js cancel pattern).
        const rid = currentResearchId;
        if (!rid || !stopResearchBtn) return;

        // Build button content via DOM nodes — innerHTML assignment is
        // flagged as unsafe by eslint-plugin-no-unsanitized even for
        // literals.
        const _setBtnLabel = (iconClass, text) => {
            if (!stopResearchBtn) return;
            stopResearchBtn.replaceChildren();
            const icon = document.createElement('i');
            icon.className = `fas ${iconClass}`;
            stopResearchBtn.appendChild(icon);
            stopResearchBtn.appendChild(document.createTextNode(' ' + text));
        };

        stopResearchBtn.disabled = true;
        _setBtnLabel('fa-spinner fa-spin', 'Stopping...');

        try {
            if (!window.api || !window.api.terminateResearch) {
                throw new Error('Terminate API not available');
            }
            await window.api.terminateResearch(rid);
            // The terminate API just sets a flag and returns. The worker
            // thread can take 0-30s to actually exit if it's blocked
            // inside an LLM HTTP call (thinking-mode <think> blocks don't
            // yield chunks, so per-chunk termination checks can't fire).
            // Start an elapsed-seconds indicator so the user knows the
            // click registered and the system is still trying.
            const stopStartedAt = Date.now();
            _clearStopElapsedTimer();
            updateCurrentTask('Stopping research…');
            _stopElapsedTimer = setInterval(() => {
                const secs = Math.floor(
                    (Date.now() - stopStartedAt) / 1000
                );
                // Don't bother with "(0s)"; wait until 3s elapsed before
                // the counter appears — quick stops shouldn't show it.
                if (secs >= 3) {
                    updateCurrentTask(`Stopping research… (${secs}s)`);
                }
            }, 1000);
            // Hide button immediately; final UI is driven by the SUSPENDED
            // progress event arriving back through the socket.
            if (stopResearchBtn) {
                stopResearchBtn.style.display = 'none';
            }
            _setBtnLabel('fa-stop', 'Stop');
        } catch (error) {
            _clearStopElapsedTimer();
            _log.error('Failed to stop research:', error);
            if (stopResearchBtn) {
                stopResearchBtn.disabled = false;
            }
            _setBtnLabel('fa-stop', 'Stop');
            updateCurrentTask('Failed to stop — please try again.');
        }
    }

    function _clearStopElapsedTimer() {
        if (_stopElapsedTimer) {
            clearInterval(_stopElapsedTimer);
            _stopElapsedTimer = null;
        }
    }

    /**
     * Handle SUSPENDED status. Preserves the partial bubble (with text
     * already streamed in), appends a "Stopped by user" footer, and
     * tears down the in-flight UI. If Stop fired before any chunk arrived,
     * the helper inserts a fresh assistant bubble with the placeholder.
     */
    function handleResearchSuspended(thinkingEl) {
        // Idempotency guard — both socket and poll paths can deliver
        // status='suspended' within the same 1s window. Without this,
        // the second call adds a duplicate '[Stopped before any output
        // was generated.]' placeholder bubble (resetStreamingVars from
        // the first call has nulled streamingMessageEl, so we hit the
        // `else` branch). Mirrors completionHandled.
        if (suspendedHandled) {
            return;
        }
        // Cross-guard: if completion was already handled and rendered, a
        // late 'suspended' signal must not append a spurious '[Stopped]'
        // bubble below the finished answer.
        if (completionHandled) {
            return;
        }
        suspendedHandled = true;
        cancelPendingTimers();
        if (currentResearchId) {
            cleanupSocketListeners(currentResearchId);
        }
        removeThinking(thinkingEl);
        removeLiveStepMessages();

        if (streamingMessageEl) {
            streamingMessageEl.classList.remove('ldr-chat-message-streaming');
            const contentEl = streamingMessageEl.querySelector('.ldr-chat-message-content');
            if (contentEl && !contentEl.querySelector('.ldr-chat-stopped-footer')) {
                const footer = document.createElement('div');
                footer.className = 'ldr-chat-stopped-footer';
                footer.textContent = '— Stopped by user';
                contentEl.appendChild(footer);
            }
            const timeEl = streamingMessageEl.querySelector('.ldr-chat-message-time');
            if (timeEl && !timeEl.textContent) {
                timeEl.textContent = new Date().toLocaleTimeString();
            }
        } else {
            // Stop clicked before any chunk arrived. Render a placeholder
            // bubble so the conversation isn't left blank.
            addMessageToUI('assistant', '_[Stopped before any output was generated.]_');
        }

        hideProgress();
        isProcessing = false;
        sendBtn.disabled = !chatInput.value.trim();
        resetStreamingVars();
        // Same rationale as handleResearchError: restore session-level
        // controls after a user Stop so the next reload isn't required to
        // expose edit-title / export.
        showSessionButtons();
    }

    /**
     * Refresh session messages from server.
     */
    async function refreshSessionMessages(expectedResearchId) {
        if (!sessionId) return;

        try {
            // Refresh session title (may have been auto-generated from first message)
            const sessionResponse = await fetch(`/api/chat/sessions/${sessionId}`, {
                headers: { 'X-CSRFToken': getCsrfToken() },
            });
            if (sessionResponse.ok) {
                const sessionData = await sessionResponse.json();
                if (sessionData.success && sessionData.session && sessionData.session.title) {
                    updateTitle(sessionData.session.title);
                }
            }

            // Safety net: when the streaming bubble swap path didn't end
            // up with the assistant response in the DOM (transient socket
            // drop, race during session switch, missed is_final chunk),
            // re-render from the DB-authoritative /messages endpoint so
            // the user doesn't have to refresh the page manually.
            //
            // Only triggers when (a) we know which research_id we just
            // finished and (b) no assistant message with that research_id
            // is currently in the DOM. The happy path is a cheap DOM
            // query and a no-op.
            if (expectedResearchId && chatMessages) {
                const ridSelector = `.ldr-chat-message-assistant:not(.ldr-chat-message-step)[data-research-id="${CSS.escape(expectedResearchId)}"]`;
                const haveResponse = !!chatMessages.querySelector(ridSelector);
                if (!haveResponse) {
                    _log.warn('Chat: response bubble missing after completion; re-rendering from DB', { researchId: expectedResearchId });
                    await loadSession(sessionId);
                }
            }
        } catch (error) {
            _log.error('Error refreshing session:', error);
        }
    }

    /**
     * Show progress wrapper and initialize log panel.
     */
    function showProgress() {
        if (progressWrapper) {
            progressWrapper.style.display = 'block';
        }
        // Reset current task text
        if (currentTaskText) {
            currentTaskText.textContent = 'Starting research...';
        }
        // Reveal the Stop button — only meaningful while a research is in
        // flight, so showProgress is the right gate.
        if (stopResearchBtn) {
            stopResearchBtn.style.display = 'inline-flex';
            stopResearchBtn.disabled = false;
            stopResearchBtn.replaceChildren();
            const icon = document.createElement('i');
            icon.className = 'fas fa-stop';
            stopResearchBtn.appendChild(icon);
            stopResearchBtn.appendChild(document.createTextNode(' Stop'));
        }
        // Initialize the log panel if available
        if (window.logPanel && currentResearchId) {
            window.logPanel.initialize(currentResearchId);
        }
    }

    /**
     * Hide progress wrapper.
     */
    function hideProgress() {
        if (progressWrapper) {
            progressWrapper.style.display = 'none';
        }
        if (stopResearchBtn) {
            stopResearchBtn.style.display = 'none';
        }
    }

    /**
     * Update the current task/milestone display.
     */
    function updateCurrentTask(message) {
        if (currentTaskText && message) {
            currentTaskText.textContent = message;
        }
    }

    /**
     * Scroll chat to bottom — but only if the user is already near it.
     *
     * Prevents yanking the user away from earlier content they're reading
     * while research steps stream in or the assistant's response grows.
     * Pass `force=true` for events that should always scroll regardless
     * (e.g. the user just sent a message — they expect to see the response).
     */
    function scrollToBottom(force) {
        if (!chatMessages) return;
        if (!force) {
            const distanceFromBottom =
                chatMessages.scrollHeight -
                chatMessages.scrollTop -
                chatMessages.clientHeight;
            // Threshold ~150px ≈ 5–6 lines of text. If the user is further
            // up than this, they're reading and we leave them alone.
            if (distanceFromBottom > 150) return;
        }
        chatMessages.scrollTop = chatMessages.scrollHeight;
    }

    /**
     * Update chat title.
     */
    function updateTitle(title) {
        if (chatTitle) {
            chatTitle.textContent = title;
        }
        document.title = `${title} - Chat Research`;
    }

    /**
     * Handle edit title.
     */
    async function handleEditTitle() {
        const currentTitle = chatTitle?.textContent || 'Chat Session';
        const newTitle = prompt('Enter new title:', currentTitle);

        if (newTitle && newTitle !== currentTitle && sessionId) {
            try {
                const response = await fetch(`/api/chat/sessions/${sessionId}`, {
                    method: 'PATCH',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRFToken': getCsrfToken(),
                    },
                    body: JSON.stringify({ title: newTitle }),
                });

                if (!response.ok) {
                    _log.error(`Failed to update title (${response.status})`);
                    return;
                }
                const data = await response.json();
                if (data.success) {
                    updateTitle(newTitle);
                }
            } catch (error) {
                _log.error('Error updating title:', error);
            }
        }
    }

    /**
     * Handle export chat to markdown.
     */
    async function handleExport() {
        if (!sessionId) {
            alert('No chat session to export.');
            return;
        }

        try {
            // Fetch ALL messages by paging the cursor backwards. A bare
            // /messages call returns at most 50 (server default) or 100
            // (server max) — without paging, a long chat exports as a
            // silent stub. We loop on `has_more`, prepending older pages
            // so the final list stays oldest→newest.
            const PAGE_SIZE = 100;
            let allMessages = [];
            let cursorCreatedAt = null;
            let cursorId = null;
            // Hard cap on iterations as a defense-in-depth guard against
            // a server contract regression (e.g. ``has_more`` permanently
            // true). At 100 per page this caps export at 50k messages.
            const MAX_PAGES = 500;
            for (let i = 0; i < MAX_PAGES; i++) {
                let pageUrl = `/api/chat/sessions/${sessionId}/messages?limit=${PAGE_SIZE}`;
                if (cursorCreatedAt) {
                    pageUrl += `&before_created_at=${encodeURIComponent(cursorCreatedAt)}`;
                }
                if (cursorId) {
                    pageUrl += `&before_id=${encodeURIComponent(cursorId)}`;
                }
                const page = await apiGet(pageUrl);
                if (!page.messages || page.messages.length === 0) break;
                // Pages arrive ASC oldest→newest; older pages prepend.
                allMessages = page.messages.concat(allMessages);
                if (!page.has_more) break;
                cursorCreatedAt = page.messages[0].created_at;
                cursorId = page.messages[0].id || null;
            }

            if (allMessages.length === 0) {
                alert('No messages to export.');
                return;
            }

            // Build markdown content
            const title = chatTitle?.textContent || 'Chat Session';
            const exportDate = new Date().toLocaleString();

            // Escape title for Markdown-heading context. The user-controlled
            // title is otherwise interpolated raw into `# ${title}`, allowing
            // a crafted title to inject arbitrary Markdown structure into the
            // downloaded `.md` file (e.g. fake headings, links, code fences).
            // The set below covers Markdown's syntactic chars + newline
            // injection. Backslash must come first so we don't double-escape
            // the escapes we just added.
            const titleSafe = String(title).replace(
                /[\\`*_{}[\]()#+\-!>|~]/g,
                (c) => `\\${c}`,
            ).replace(/[\r\n]+/g, ' ');

            let markdown = `# ${titleSafe}\n\n`;
            markdown += `*Exported: ${exportDate}*\n\n---\n\n`;

            allMessages.forEach(msg => {
                // Skip progress-step rows entirely; rendering them as
                // assistant turns would be misleading in the export.
                if (msg.message_type === 'step') return;

                const role = msg.role === 'user' ? 'You' : 'Assistant';
                const timestamp = msg.created_at ? new Date(msg.created_at).toLocaleString() : '';

                markdown += `## ${role}`;
                if (timestamp) {
                    markdown += ` *(${timestamp})*`;
                }
                markdown += `\n\n`;
                if (msg.content) {
                    // NOTE: message content is written verbatim. Assistant
                    // turns are themselves Markdown, so escaping here would
                    // corrupt legitimate formatting — but that means a
                    // user-authored "You" turn can carry arbitrary Markdown
                    // (headings, links, raw HTML) into the .md file. This
                    // export is a human-readable archive only; the output is
                    // NOT safe to feed back into a Markdown renderer or HTML
                    // pipeline without escaping/sanitising first.
                    markdown += msg.content;
                } else if (msg.research_id) {
                    // Preserve the research link so the export is
                    // round-trip-useful — the user can navigate from the
                    // exported .md back to the source research page.
                    markdown += `*(Research response — see [/results/${msg.research_id}](/results/${msg.research_id}))*`;
                } else {
                    markdown += `*(Research response — see results page)*`;
                }
                markdown += `\n\n---\n\n`;
            });

            // Create and download file
            const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');
            if (typeof URLValidator !== 'undefined' && URLValidator.safeAssign) {
                URLValidator.safeAssign(link, 'href', url);
            } else {
                link.href = url;  // blob: URL is safe to assign directly
            }
            link.download = `${title.replace(/[^a-z0-9]/gi, '_')}_${sessionId.slice(0, 8)}.md`;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            URL.revokeObjectURL(url);

        } catch (error) {
            _log.error('Error exporting chat:', error);
            alert('Failed to export chat. Please try again.');
        }
    }

    // Initialize when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Export for external use if needed
    window.chatComponent = {
        startNewChat,
        loadSession,
    };

})();
