/**
 * VMO AI Voice Panel - Jarvis-Style Floating Chat
 * Full voice conversation with AI-powered speech recognition and text-to-speech
 */

class VMOVoicePanel {
    constructor() {
        // Single-instance guard. A duplicated panel instance can leave one WebRTC
        // transport/audio sink alive while the visible panel controls another.
        // That creates the "two Jarvises" problem and makes End Chat unreliable.
        try {
            if (window.__vmoVoicePanelInstance && window.__vmoVoicePanelInstance !== this && typeof window.__vmoVoicePanelInstance.endVoiceSession === 'function') {
                window.__vmoVoicePanelInstance.endVoiceSession('Voice chat stopped', false);
            }
            window.__vmoVoicePanelInstance = this;
        } catch (_) {}
        this.panel = document.getElementById('vmo-ai-voice');
        this.chatHistory = document.getElementById('vmo-voice-chat-history');
        this.statusText = document.getElementById('vmo-voice-status');
        this.collapseBtn = document.getElementById('vmo-voice-collapse');
        this.textBtn = document.getElementById('vmo-voice-text-btn');
        this.micBtn = document.getElementById('vmo-voice-mic-btn');
        this.uploadBtn = document.getElementById('vmo-voice-upload-btn'); // ✅ NEW: Upload button
        // ✅ REMOVED: this.speechBtn (button deleted - was duplicate)
        this.startBtn = document.getElementById('vmo-voice-start-btn');
    this.listeningModal = document.getElementById('vmo-listening-modal');
        this.listeningTranscript = document.getElementById('vmo-listening-transcript');
        this.listeningText = document.querySelector('#vmo-listening-modal .vmo-listening-text');
    // Optional close button inside listening modal
    this.listeningCloseBtn = document.getElementById('vmo-listening-close');
        this.muteBtn = document.getElementById('vmo-listening-mute');
    // Optional settings button in header
    this.settingsBtn = document.getElementById('vmo-voice-settings');
        
        this.isCollapsed = false;
        this.isActive = false;
        this.isListening = false;
    this.isSpeaking = false;
    this.isProcessing = false; // suppresses auto-listen while awaiting TTS
        this.isMicMuted = !!window.vmoMicMuted;
        this._autoRestartEnabled = true;
        this._micPermissionBlocked = false;
        this._voiceNoticeKeys = {};
        this._voiceSessionSerial = 0;
        this._voiceHardStopped = true;
        this.messages = [];
    this.stagedAttachments = [];
    this.uploadedFiles = [];
        this.voiceMode = this.getVoiceMode(); // 'default' = OpenAI TTS fallback; 'premium' = Realtime/WebRTC
        window.vmoJarvisVoiceMode = this.voiceMode;
        console.log('[VMOVoicePanel] Voice mode loaded:', this.voiceMode);
        
        // Speech Recognition (Web Speech API for listening to user)
        this.recognition = null;
        // Determine a sensible default language from page or browser
        this.defaultLang = (document && document.documentElement && document.documentElement.lang)
            ? document.documentElement.lang
            : (navigator.language || 'en-US');
        this.initSpeechRecognition();
        
        // OpenAI TTS for AI voice responses
        this.currentAudio = null;
        this.isMuted = !!window.vmoMuteTTS;
        
        this.init();

        // Listen for global mic revoke requests
        try {
            window.addEventListener('vmo:mic-revoke', (e) => {
                const from = e?.detail?.from;
                // If this module owns the mic, stop recognition immediately
                if (from === 'voice-panel' || (!from && this.isListening)) {
                    try { this.recognition && this.recognition.stop(); } catch(_) {}
                }
            });
        } catch(_) {}

        // Privacy: allow any VMO module/tab handler to hard-stop Jarvis completely.
        try {
            window.addEventListener('vmo:voice-hard-stop', () => {
                this.endVoiceSession('Voice chat stopped');
            });
        } catch (_) {}

        // Cross-tab privacy: ending Jarvis in one tab must stop every other open
        // VMO tab on the same site so no hidden/background tab keeps listening.
        try {
            window.addEventListener('storage', (e) => {
                if (e && e.key === 'vmoJarvisVoiceHardStopAt' && e.newValue) {
                    this.endVoiceSession('Voice chat stopped', false);
                }
                if (e && e.key === 'vmoJarvisVoiceStartAt' && e.newValue && this.isActive) {
                    // Another tab just started Jarvis. Only one tab may own the mic.
                    this.endVoiceSession('Voice chat moved to another tab', false);
                }
            });
        } catch (_) {}
    }
    
    initSpeechRecognition() {
        // Check if browser supports Web Speech API
        const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
        if (SpeechRecognition) {
            this.recognition = new SpeechRecognition();
            // Continuous mode to allow silence-based gating
            this.recognition.continuous = true;
            this.recognition.interimResults = true;
            // Use site/browser language as default
            try {
                this.recognition.lang = this.defaultLang || 'en-US';
            } catch (_) {
                this.recognition.lang = 'en-US';
            }
            this.recognition.maxAlternatives = 1;

            // Fast endpointing: once the user stops talking, Jarvis should answer quickly.
            // 5.5s felt like the AI was asleep; ~1s keeps a natural pause without the long dead air.
            this.silenceTimeoutMs = 1000;
            this._silenceTimer = null;
            this._transcriptBuffer = '';
            this._interimText = '';
            
            // ✅ SELF-LOOP PREVENTION: Track when TTS started to ignore any speech during playback
            this._ttsStartedAt = 0;
            this._ttsMinDurationMs = 500; // Minimum time to ignore speech after TTS starts
            this._ttsEndCooldownMs = 250; // Minimal fallback cooldown after TTS ends
            this._ttsEndedAt = 0;
            
            this.recognition.onstart = () => {
                console.log('🎤 Speech recognition started');
                this.isListening = true;
                this.updateStatus('Listening...');
                this.showListeningModal('listening');
                // Claim mic ownership
                try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.requestMic('voice-panel'); } catch(_) {}
            };
            
            this.recognition.onresult = (event) => {
                // ✅ CRITICAL SELF-LOOP PREVENTION: Ignore ALL speech while AI is speaking
                // This prevents the AI from hearing its own TTS output
                if (this.isSpeaking || this.isProcessing) {
                    console.log('🔇 Ignoring speech input - AI is speaking or processing');
                    return;
                }
                
                // ✅ COOLDOWN CHECK: Also ignore speech shortly after TTS ends
                // TTS audio may still be resonating/echoing
                if (this._ttsEndedAt && (Date.now() - this._ttsEndedAt < this._ttsEndCooldownMs)) {
                    console.log('🔇 Ignoring speech input - TTS cooldown period');
                    return;
                }
                
                // ✅ Check coordinator for global TTS state (with stale-state safety)
                if (window.VMOAudioCoordinator && window.VMOAudioCoordinator.isSpeaking) {
                    // Safety: if no audio is ACTUALLY playing, the flag is stale — reset it
                    const anyAudioPlaying = Array.from(document.querySelectorAll('audio')).some(a => !a.paused && !a.ended);
                    const synthSpeaking = window.speechSynthesis && window.speechSynthesis.speaking;
                    if (!anyAudioPlaying && !synthSpeaking && !this.currentAudio) {
                        console.warn('⚠️ Coordinator isSpeaking was stale — resetting');
                        window.VMOAudioCoordinator.isSpeaking = false;
                        if (window.VMOAudioCoordinator.onTTSStop) window.VMOAudioCoordinator.onTTSStop();
                    } else {
                        console.log('🔇 Ignoring speech input - VMOAudioCoordinator reports TTS active');
                        return;
                    }
                }
                
                // Only handle the current result; interim updates should not be permanently appended
                const res = event.results && event.results[event.resultIndex];
                if (!res) return;
                const t = (res[0] && res[0].transcript) ? res[0].transcript : '';

                // ✅ BARGE-IN: If AI is streaming, abort the stream so user can interrupt
                if (t.trim() && this._streamController) {
                    console.log('🗣️ Barge-in: user spoke while AI was streaming — aborting stream');
                    try { this._streamController.abort(); } catch(_) {}
                }

                if (res.isFinal) {
                    this._transcriptBuffer += t;
                    this._interimText = '';
                } else {
                    this._interimText = t;
                }

                // ✅ LIVE-TO-INPUT: Show transcription directly in the chat input box
                const fullText = (this._transcriptBuffer + (this._interimText || ''));
                this._populateInputBox(fullText);

                // ✅ LIVE TRANSCRIPT: Show user's words in real-time in the listening modal
                // (Same approach as vmo-intelligent-consultation.js uses)
                if (fullText.trim()) {
                    this.updateLiveTranscript(fullText, 'user');
                }

                // Reset silence timer — behavior depends on mode
                if (this._silenceTimer) clearTimeout(this._silenceTimer);
                const delay = this.silenceTimeoutMs;
                this._silenceTimer = setTimeout(() => {
                    const finalText = (this._transcriptBuffer + (this._interimText || '')).trim();
                    this._transcriptBuffer = '';
                    this._interimText = '';
                    this.clearLiveTranscript();
                    if (finalText) {
                        try { this.recognition.stop(); } catch(_) {}
                        if (this.isActive) {
                            // Voice conversation mode: auto-send (listen → think → respond → listen)
                            this.sendMessage(finalText);
                        } else {
                            // Speech-to-text mode: populate input for user review & manual send
                            this._populateInputBox(finalText);
                            this.hideListeningModal();
                            this.updateStatus('Ready — review & send');
                        }
                    }
                }, delay);
            };
            
            this.recognition.onerror = (event) => {
                console.error('Speech recognition error:', event.error);
                this.isListening = false;
                // Keep modal visible during active voice session to avoid flicker
                if (!this.isActive) this.hideListeningModal();
                if (event.error === 'no-speech') {
                    // Friendly retry for no-speech timeouts
                    this.updateStatus('Didn\'t catch that. Listening…');
                    if (this.isActive && this._autoRestartEnabled && !this.isSpeaking && !this.isProcessing) {
                        setTimeout(() => this.startVoiceInput(), 500);
                    }
                } else if (event.error === 'aborted') {
                    // Ignorable (often happens on quick restarts)
                    if (this.isActive && this._autoRestartEnabled && !this.isSpeaking && !this.isProcessing) {
                        setTimeout(() => this.startVoiceInput(), 300);
                    }
                } else if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
                    this.handleVoiceUnavailable(
                        'mic-permission',
                        'Mic permission blocked',
                        'Microphone permission was blocked. Click the microphone permission prompt and choose Allow. If the browser already blocked it, use the site permission icon in the address bar, allow microphone access, then reload.'
                    );
                } else {
                    this.updateStatus('Error: ' + event.error);
                    if (this.isActive && this._autoRestartEnabled && !this._micPermissionBlocked && !this.isSpeaking && !this.isProcessing) {
                        setTimeout(() => this.startVoiceInput(), 800);
                    }
                }
            };
            
            this.recognition.onend = () => {
                console.log('🎤 Speech recognition ended');
                this.isListening = false;
                // Keep modal visible during active voice session so UI remains stable
                if (!this.isActive) this.hideListeningModal();
                // Release mic ownership
                try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.releaseMic('voice-panel'); } catch(_) {}
                // Auto-restart if session active and we aren't speaking
                if (this.isActive && this._autoRestartEnabled && !this._micPermissionBlocked && this.isSecureVoiceContext() && !this.isSpeaking && !this.isProcessing) {
                    setTimeout(() => this.startVoiceInput(), 300);
                }
            };

            // Diagnostics: track audio lifecycle
            this.recognition.onaudiostart = () => console.log('🎤 Audio capturing started');
            this.recognition.onaudioend = () => console.log('🎤 Audio capturing ended');
            this.recognition.onsoundstart = () => console.log('🎤 Sound detected');
            this.recognition.onsoundend = () => console.log('🎤 Sound ended');
            
            console.log('✅ Speech recognition initialized');
        } else {
            console.warn('⚠️ Web Speech API not supported in this browser');
        }
    }

    // Warm up the microphone to trigger permission prompt and stabilize input
    async micWarmup() {
        if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
        try {
            const stream = await navigator.mediaDevices.getUserMedia({
                audio: {
                    echoCancellation: true,
                    noiseSuppression: true,
                    autoGainControl: true
                }
            });
            // Immediately stop tracks; this is only to unlock permissions and wake audio path
            setTimeout(() => stream.getTracks().forEach(t => t.stop()), 100);
        } catch (err) {
            console.warn('Mic warmup failed or permission denied:', err);
        }
    }
    
    async init() {
        console.log('🎤 VMO Voice Panel: Initializing...');
        
        // Set up event listeners
        this.collapseBtn?.addEventListener('click', () => this.toggleCollapse());
        this.textBtn?.addEventListener('click', () => this.activateTextChat());
        this.micBtn?.addEventListener('click', () => this.startVoiceInput());
        this.uploadBtn?.addEventListener('click', () => this.openFileUpload()); // ✅ NEW: Upload button handler
        // ✅ REMOVED: this.speechBtn (button deleted - was duplicate)
        this.startBtn?.addEventListener('click', () => this.startVoiceSession());
        // Close button inside listening modal ends the appropriate session
        this.listeningCloseBtn?.addEventListener('click', (e) => {
            e.stopPropagation();
            const owner = (this.listeningModal && this.listeningModal.dataset && this.listeningModal.dataset.owner) || 'voice-panel';
            if (owner === 'consultation') {
                // Consultation will handle this in its own handler; avoid double-processing
                return;
            }
            this.endVoiceSession();
        });
        // Settings button opens voice selection popover
        this.settingsBtn?.addEventListener('click', (e) => {
            e.stopPropagation();
            this.openSettingsPopover();
        });
        // Enable dragging the panel by its header
        this.enableDrag();
        // Load saved preferred Jarvis voice
        try {
            const params = new URLSearchParams(window.location.search || '');
            const forceAsh = params.get('vmo_quicklaunch_welcome') === '1' || params.get('vmo_first_entry') === '1';
            const saved = localStorage.getItem('vmoJarvisVoice');
            if (forceAsh) {
                window.vmoSelectedVoice = 'ash';
                try { localStorage.setItem('vmoJarvisVoice', 'ash'); } catch(_) {}
            } else if (saved) window.vmoSelectedVoice = saved;
            else if (!window.vmoSelectedVoice) window.vmoSelectedVoice = 'ash';
        } catch(_) { if (!window.vmoSelectedVoice) window.vmoSelectedVoice = 'ash'; }
        
        // Load chat history from database first, then show greeting if first time
        await this.loadChatHistory();
        
        // Enable buttons
    if (this.textBtn) this.textBtn.disabled = false;
    if (this.micBtn) this.micBtn.disabled = false;
    if (this.startBtn) this.startBtn.disabled = false;
        
        console.log('✅ VMO Voice Panel: Ready');
        // Mute toggle (Jarvis only; consultation intercepts when it owns the modal)
        // ✅ FIX: Mute now explicitly controls the Microphone (User Input)
        this.muteBtn?.addEventListener('click', (e) => {
            e.stopPropagation();
            const owner = (this.listeningModal && this.listeningModal.dataset && this.listeningModal.dataset.owner) || 'voice-panel';
            if (owner === 'consultation') return; // consultation owns this click

            this.setMicMuted(!window.vmoMicMuted, { restartFallback: true });
        });
        
        // ═══════════════════════════════════════════════════════════════════════════════
        // ✅ BARGE-IN ZONE: Clicking the circular animation or modal body stops AI speech
        // This gives users a large target to tap/click to interrupt
        // ═══════════════════════════════════════════════════════════════════════════════
        const modalContent = this.listeningModal?.querySelector('.vmo-listening-content');
        if (modalContent) {
            modalContent.addEventListener('click', (e) => {
                // Don't trigger on button clicks (they have their own handlers)
                if (e.target.tagName === 'BUTTON') return;
                
                // Only trigger if AI is speaking
                if (this.isSpeaking) {
                    console.log('🔊 BARGE-IN via modal tap');
                    this.stopAllSpeaking();
                    if (!this._realtimeSession) this.startVoiceInput(true); // Manual fallback barge-in
                }
            });
        }
        
        // ✅ KEYBOARD BARGE-IN: Press Escape to stop AI speech and start listening
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && this.isActive && this.isSpeaking) {
                console.log('🔊 BARGE-IN via Escape key');
                e.preventDefault();
                e.stopPropagation();
                this.stopAllSpeaking();
                if (!this._realtimeSession) this.startVoiceInput(true); // Manual fallback barge-in
            }
        });
        
        // ✅ GLOBAL BARGE-IN EVENT: Other code can trigger this
        window.addEventListener('vmo:voice-barge-in', () => {
            if (this.isSpeaking) {
                console.log('🔊 BARGE-IN via global event');
                this.stopAllSpeaking();
                if (!this._realtimeSession) this.startVoiceInput(true);
            }
        });
    }
    
    /**
     * ═══════════════════════════════════════════════════════════════════════════════
     * FORCE-STOP ALL SPEAKING - Guaranteed barge-in support
     * This method immediately stops all audio output from any source
     * ═══════════════════════════════════════════════════════════════════════════════
     */
    stopAllSpeaking() {
        console.log('🛑 FORCE STOPPING ALL AUDIO');

        // Realtime voice uses a live WebRTC sink, not the fallback audio element.
        // Canceling there preserves the active mic session for true barge-in.
        if (this._realtimeSession && typeof this._realtimeSession.cancelResponse === 'function') {
            try { this._realtimeSession.cancelResponse({ clearBuffer: false }); } catch(_) {}
        }
        
        // 1. Stop our own audio element
        if (this.currentAudio) {
            try {
                this.currentAudio._vmoIntentionalStop = true;
                this.currentAudio.onended = null;
                this.currentAudio.onerror = null;
                this.currentAudio.pause();
                this.currentAudio.currentTime = 0;
                this.currentAudio.removeAttribute('src');
                this.currentAudio.load && this.currentAudio.load();
            } catch(_) {}
            this.currentAudio = null;
        }
        
        // 2. Stop browser speech synthesis
        try {
            if (window.speechSynthesis) {
                window.speechSynthesis.cancel();
            }
        } catch(_) {}
        
        // 3. Stop any global audio via coordinator
        try {
            if (window.VMOAudioCoordinator) {
                if (window.VMOAudioCoordinator.stopAllTTS) {
                    window.VMOAudioCoordinator.stopAllTTS('barge-in', 'voice-panel');
                }
                if (window.VMOAudioCoordinator.currentAudio) {
                    try { window.VMOAudioCoordinator.currentAudio.pause(); } catch(_) {}
                }
                window.VMOAudioCoordinator.isSpeaking = false;
                if (window.VMOAudioCoordinator.onTTSStop) {
                    window.VMOAudioCoordinator.onTTSStop();
                }
            }
        } catch(_) {}
        
        // 4. Stop global vmoCurrentAudio
        try {
            if (window.vmoCurrentAudio) {
                window.vmoCurrentAudio.pause();
                window.vmoCurrentAudio = null;
            }
        } catch(_) {}
        
        // 5. Nuclear option: pause ALL audio elements on page
        try {
            document.querySelectorAll('audio').forEach(audio => {
                if (this._realtimeSession && audio.id === 'vmo-realtime-audio') return;
                try { audio.pause(); } catch(_) {}
            });
        } catch(_) {}
        
        // Reset state
        this.isSpeaking = false;
        this.isProcessing = false;
        this._ttsEndedAt = Date.now();
        this.clearLiveTranscript();
        this.updateStatus('Ready');
        if (this.isActive) this.setModalPhase('listening');
    }

    setMicMuted(muted, options = {}) {
        const shouldMute = !!muted;
        window.vmoMicMuted = shouldMute;
        this.isMicMuted = shouldMute;

        if (this._realtimeSession) {
            try {
                if (shouldMute && typeof this._realtimeSession.mute === 'function') this._realtimeSession.mute();
                if (!shouldMute && typeof this._realtimeSession.unmute === 'function') this._realtimeSession.unmute();
            } catch(_) {}
        }

        if (shouldMute) {
            console.log('🔇 Jarvis Mute: Microphone muted by user');
            if (this.recognition) {
                try { this.recognition.abort(); } catch(_) {}
                try { this.recognition.stop(); } catch(_) {}
                this.isListening = false;
            }
            if (this._silenceTimer) { try { clearTimeout(this._silenceTimer); } catch(_) {} this._silenceTimer = null; }
            this._transcriptBuffer = '';
            this._interimText = '';
            this.clearLiveTranscript();
            this.updateStatus('Microphone muted');
        } else {
            console.log('🔊 Jarvis Unmute: Microphone unmuted by user');
            this.updateStatus(this._realtimeSession ? 'Listening…' : 'Microphone active');
            if (options.restartFallback && !this._realtimeSession && this.isActive && !this.isSpeaking && !this.isProcessing) {
                this.startVoiceInput(false);
            }
        }

        this.applyMute();
    }

    applyMute() {
        // Sync button label/state (Jarvis context)
        if (this.muteBtn) {
            const muted = !!window.vmoMicMuted;
            this.muteBtn.textContent = muted ? 'Unmute Mic' : 'Mute Mic';
            this.muteBtn.setAttribute('aria-pressed', muted ? 'true' : 'false');
            this.muteBtn.title = muted ? 'Unmute Microphone' : 'Mute Microphone';
            // Optional: visual indication
            this.muteBtn.style.color = muted ? '#ff4444' : '#00ffff';
            this.muteBtn.style.borderColor = muted ? '#ff4444' : '#00ffff';
        }
        
        // Notify listeners (no owner tag here; consultation ignores these)
        try { window.dispatchEvent(new CustomEvent('vmo:mute-changed', { detail: { muted: !!window.vmoMicMuted } })); } catch(_) {}
    }

    isSecureVoiceContext() {
        try {
            if (window.isSecureContext) return true;
            const host = (window.location && window.location.hostname ? window.location.hostname : '').toLowerCase();
            return host === 'localhost' || host === '127.0.0.1' || host === '::1';
        } catch (_) {
            return false;
        }
    }

    showVoiceNoticeOnce(key, message, sender = 'system') {
        const noticeKey = String(key || 'voice-notice');
        if (!this._voiceNoticeKeys) this._voiceNoticeKeys = {};
        if (this._voiceNoticeKeys[noticeKey]) return;
        this._voiceNoticeKeys[noticeKey] = true;
        this.addMessage(message, sender, false);
    }

    stopVoiceCapture(status) {
        this._autoRestartEnabled = false;
        this.isActive = false;
        this.isListening = false;
        this.isProcessing = false;
        if (this._silenceTimer) { try { clearTimeout(this._silenceTimer); } catch(_) {} this._silenceTimer = null; }
        if (this._speakingWatchdog) { try { clearTimeout(this._speakingWatchdog); } catch(_) {} this._speakingWatchdog = null; }
        if (this.recognition) {
            try { this.recognition.abort(); } catch(_) {}
            try { this.recognition.stop(); } catch(_) {}
        }
        this._stopRealtimeSession();
        window.vmoMicMuted = true;
        this.isMicMuted = true;
        this.applyMute();
        this.hideListeningModal();
        if (this.startBtn) {
            this.startBtn.innerHTML = '<span class="vmo-voice-start-icon">🎤</span><span class="vmo-voice-start-text">Start Voice</span>';
        }
        this.updateStatus(status || 'Voice unavailable');
        try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.releaseMic('voice-panel'); } catch(_) {}
    }

    handleVoiceUnavailable(key, status, message) {
        this._micPermissionBlocked = true;
        this.stopVoiceCapture(status);
        if (message) {
            this.showVoiceNoticeOnce(key, message);
        }
    }
    
    toggleCollapse() {
        this.isCollapsed = !this.isCollapsed;
        this.panel?.classList.toggle('collapsed');
        this.collapseBtn.textContent = this.isCollapsed ? '+' : '−';
    }
    
    isClientMode() {
        try {
            const cfgA = window.vmo_ajax || {};
            const cfgB = window.vmoAjax || {};
            const quicklaunch = cfgA.quicklaunch_welcome || cfgB.quicklaunch_welcome || {};
            const pluginUrl = String(cfgA.pluginUrl || cfgB.pluginUrl || '');
            const path = String((window.location && window.location.href) || '');
            return !!(
                cfgA.is_client_mode ||
                cfgB.is_client_mode ||
                (quicklaunch && Object.keys(quicklaunch).length > 0) ||
                /vmo-web-business-consultant/i.test(pluginUrl) ||
                /vmo_quicklaunch_welcome=1/i.test(path)
            );
        } catch (_) { return false; }
    }

    getQuickLaunchWelcomeData() {
        try {
            return (window.vmo_ajax && window.vmo_ajax.quicklaunch_welcome) ||
                   (window.vmoAjax && window.vmoAjax.quicklaunch_welcome) || {};
        } catch (_) {
            return {};
        }
    }

    quickLaunchWelcomeRequiresServiceReady() {
        // If the hosted client has a direct Kinsta master voice proxy available,
        // do not gate first-entry orientation on the local client's admin-ajax/TTS
        // service probe. The local route may return Cloudflare 502 while the page
        // itself is usable; the direct master voice path can still speak.
        try {
            if (window.vmoClientProxy && window.vmoClientProxy.enabled && window.vmoClientProxy.direct_master_voice) {
                return false;
            }
        } catch (_) {}
        // Safety valve: the welcome must ALWAYS eventually greet the customer.
        // Once the readiness gate has been waited on past its cap (see
        // deferQuickLaunchWelcomeUntilServiceReady), stop gating so Jarvis speaks
        // anyway — the greeting's own TTS path has a browser-speech fallback, so
        // the customer is never left in silence even if the formal
        // tts_runtime_ready probe is still flapping.
        if (this._quickLaunchWelcomeServiceGateExpired) {
            return false;
        }
        const quicklaunch = this.getQuickLaunchWelcomeData();
        const urlWelcome = /[?&]vmo_quicklaunch_welcome=1/i.test(String((window.location && window.location.href) || ''));
        return !!(quicklaunch && quicklaunch.hosted_client && (urlWelcome || quicklaunch.pending || quicklaunch.spoken_message || quicklaunch.message));
    }

    isQuickLaunchServiceReady() {
        const quicklaunch = this.getQuickLaunchWelcomeData();
        if (!this.quickLaunchWelcomeRequiresServiceReady()) return true;
        return !(quicklaunch.service_ready === false || quicklaunch.service_ready === '0' || quicklaunch.service_ready === 0);
    }

    updateQuickLaunchWelcomeServiceReady(payload) {
        try {
            const cfgA = window.vmo_ajax || {};
            const cfgB = window.vmoAjax || {};
            const target = cfgA.quicklaunch_welcome || cfgB.quicklaunch_welcome || {};
            target.service_ready = !!(payload && payload.service_ready);
            target.service_ready_at = payload && payload.service_ready ? Math.floor(Date.now() / 1000) : (target.service_ready_at || 0);
            if (cfgA.quicklaunch_welcome) cfgA.quicklaunch_welcome = target;
            if (cfgB.quicklaunch_welcome) cfgB.quicklaunch_welcome = target;
        } catch (_) {}
    }

    async refreshQuickLaunchServiceReady() {
        if (!this.quickLaunchWelcomeRequiresServiceReady()) return true;
        if (this.isQuickLaunchServiceReady()) return true;

        try {
            const ajax = window.vmo_ajax || window.vmoAjax || {};
            if (!ajax.ajaxurl || !ajax.nonce) return false;
            const response = await fetch(ajax.ajaxurl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: new URLSearchParams({
                    action: 'vmo_ql_user_service_status',
                    security: ajax.nonce
                })
            });
            const data = await response.json().catch(() => null);
            const payload = data && data.data ? data.data : null;
            if (payload) {
                this.updateQuickLaunchWelcomeServiceReady(payload);
                return !!payload.service_ready;
            }
        } catch (error) {
            console.warn('[VMOVoicePanel] QuickLaunch service readiness refresh failed:', error?.message || error);
        }
        return false;
    }

    deferQuickLaunchWelcomeUntilServiceReady(messageCount) {
        if (this._quickLaunchWelcomeReadinessTimer) {
            try { clearTimeout(this._quickLaunchWelcomeReadinessTimer); } catch (_) {}
        }
        this._quickLaunchWelcomeReadinessRetries = (this._quickLaunchWelcomeReadinessRetries || 0) + 1;

        // Hard cap: never defer the first-run welcome forever. After ~10 retries
        // (a few minutes of waiting on the tts_runtime_ready probe) give up on the
        // gate and greet the customer anyway. _quickLaunchWelcomeServiceGateExpired
        // makes quickLaunchWelcomeRequiresServiceReady() return false on the next
        // pass, so the welcome proceeds and speaks via the TTS path (which itself
        // falls back to browser speech if the proxy TTS is not ready yet).
        const MAX_READINESS_RETRIES = 10;
        if (this._quickLaunchWelcomeReadinessRetries >= MAX_READINESS_RETRIES) {
            this._quickLaunchWelcomeServiceGateExpired = true;
            this.updateStatus('Ready');
            this._quickLaunchWelcomeReadinessTimer = setTimeout(() => {
                this.maybeShowAutomaticWelcome(messageCount).catch(() => {});
            }, 1500);
            return;
        }

        const delay = Math.min(30000, 5000 + (this._quickLaunchWelcomeReadinessRetries * 3000));
        this.updateStatus('Jarvis voice is finishing setup...');
        this._quickLaunchWelcomeReadinessTimer = setTimeout(() => {
            this.maybeShowAutomaticWelcome(messageCount).catch(() => {});
        }, delay);
    }

    getVoiceMode() {
        try {
            const saved = localStorage.getItem('vmoJarvisVoiceMode');
            if (saved === 'premium' || saved === 'default') return saved;
        } catch (_) {}
        return 'default';
    }

    setVoiceMode(mode) {
        const next = mode === 'premium' ? 'premium' : 'default';
        this.voiceMode = next;
        window.vmoJarvisVoiceMode = next;
        try { localStorage.setItem('vmoJarvisVoiceMode', next); } catch (_) {}
        console.log('[VMOVoicePanel] Voice mode saved:', next);
        return next;
    }

    getRealtimeDiagnostics() {
        const d = {
            usable: false,
            hasSessionClass: false,
            hasSupportFunction: false,
            browserSupported: false,
            secureContext: false,
            hostname: '',
            hasPeerConnection: !!window.RTCPeerConnection,
            hasMediaDevices: !!(navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
            disabled: !!this._realtimeDisabled,
            reason: 'unknown'
        };
        try {
            d.hostname = (window.location && window.location.hostname ? window.location.hostname : '').toLowerCase();
            d.secureContext = !!window.isSecureContext || d.hostname === 'localhost' || d.hostname === '127.0.0.1' || d.hostname === '::1';
            d.hasSessionClass = !!window.VMORealtimeSession;
            d.hasSupportFunction = !!(window.VMORealtimeSession && typeof window.VMORealtimeSession.isSupported === 'function');
            d.browserSupported = d.hasSupportFunction ? !!window.VMORealtimeSession.isSupported() : false;
            if (d.disabled) d.reason = 'realtime_disabled_after_previous_failure';
            else if (!d.secureContext) d.reason = 'insecure_context_requires_https';
            else if (!d.hasSessionClass) d.reason = 'vmo_realtime_script_not_loaded';
            else if (!d.hasSupportFunction) d.reason = 'vmo_realtime_support_probe_missing';
            else if (!d.browserSupported) d.reason = 'browser_webrtc_or_microphone_api_not_supported';
            else { d.usable = true; d.reason = 'ready'; }
        } catch (e) {
            d.reason = 'diagnostics_exception';
            d.error = e && e.message ? e.message : String(e);
        }
        return d;
    }

    isRealtimeVoiceReady() {
        try {
            return !!this.getRealtimeDiagnostics().usable;
        } catch (_) {
            return false;
        }
    }

    switchActiveVoiceSessionToSelectedMode(previousMode) {
        const selectedMode = this.getVoiceMode();
        const wasActive = !!this.isActive;
        const hadRealtime = !!this._realtimeSession;

        // Switching from fallback/default while a voice session is already running
        // was the reason Premium appeared selected but still used TTS. We now
        // restart the active session into the selected engine immediately.
        if (selectedMode === 'premium') {
            if (!this.isRealtimeVoiceReady()) {
                this.showVoiceNoticeOnce(
                    'premium-voice-unavailable',
                    'Premium Live Voice is selected, but the live Realtime/WebRTC connection is not available in this browser/session yet. I will use Default Chat Voice for now.'
                );
                console.warn('[VMOVoicePanel] Premium selected but Realtime is not usable yet', this.getRealtimeDiagnostics());
                return;
            }

            if (wasActive && !hadRealtime) {
                console.log('[VMOVoicePanel] Switching active fallback session to Premium Live Voice');
                this.addMessage('Switching to Premium Live Voice now…', 'system');
                try { if (this._silenceTimer) clearTimeout(this._silenceTimer); } catch (_) {}
                this._silenceTimer = null;
                try { if (this.recognition) this.recognition.abort(); } catch (_) {}
                try { if (this.recognition) this.recognition.stop(); } catch (_) {}
                this.isListening = false;
                this.isSpeaking = false;
                this.isProcessing = false;
                this._autoRestartEnabled = false;
                try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.releaseMic('voice-panel'); } catch (_) {}
                // _startRealtimeSession sets isActive=true. Drop the fallback active
                // flag first so the existing default loop cannot keep restarting.
                this.isActive = false;
                setTimeout(() => this._startRealtimeSession(), 120);
            } else if (!wasActive) {
                this.showVoiceNoticeOnce('premium-voice-ready', 'Premium Live Voice is ready. Press Start Voice to begin the instant Realtime conversation.');
            }
            return;
        }

        // User changed back to Default while Realtime is live.
        if (selectedMode === 'default' && hadRealtime) {
            console.log('[VMOVoicePanel] Switching active Realtime session to Default Chat Voice');
            this.addMessage('Switching to Default Chat Voice…', 'system');
            this._stopRealtimeSession();
            this.isActive = false;
            this.isSpeaking = false;
            this.isProcessing = false;
            this._autoRestartEnabled = true;
            if (wasActive) setTimeout(() => this.startVoiceSession(), 150);
        }
    }

    getVoiceModeLabel(mode) {
        return mode === 'premium' ? 'Premium Live Voice' : 'Default Chat Voice';
    }

    isPremiumVoiceSelected() {
        return this.getVoiceMode() === 'premium';
    }

    getPremiumVoiceHint() {
        return 'For near-instant responses, open the gear icon and switch from Default Chat Voice to Premium Live Voice. Premium uses live Realtime/WebRTC voice and may use more VMO Credits.';
    }

    getLocalDateKey() {
        const d = new Date();
        const y = d.getFullYear();
        const m = String(d.getMonth() + 1).padStart(2, '0');
        const day = String(d.getDate()).padStart(2, '0');
        return `${y}-${m}-${day}`;
    }

    getJarvisWelcomeStorageKey(kind) {
        const userId = (window.vmo_ajax?.userId || window.vmoAjax?.userId || 'guest');
        const hostKey = String((window.location && window.location.host) || 'site').replace(/[^a-z0-9_.:-]/gi, '_');
        // Include the QuickLaunch installation ID so a developer/tester who destroys
        // and recreates the same domain gets the true first-entry welcome again.
        // Real returning users on the same installed client still get daily greetings.
        const installKey = String((window.vmoClientProxy && window.vmoClientProxy.installation_id) || (window.vmo_ajax && window.vmo_ajax.installation_id) || (window.vmoAjax && window.vmoAjax.installation_id) || 'install').replace(/[^a-z0-9_.:-]/gi, '_');
        const dateKey = kind === 'daily' ? ':' + this.getLocalDateKey() : '';
        return `vmoJarvisWelcome:${kind}:${hostKey}:${userId}:${installKey}${dateKey}`;
    }

    async loadChatHistory() {
        console.log('📚 Loading chat history from database...');
        
        try {
            const response = await fetch(vmo_ajax.ajaxurl, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    action: 'vmo_load_chat_history',
                    chat_type: 'jarvis', // Only load Jarvis conversations
                    security: vmo_ajax.nonce
                })
            });
            
            const data = await response.json();
            
            if (data.success && data.data.messages) {
                const messages = data.data.messages;
                console.log(`✅ Loaded ${messages.length} messages from database`);
                
                // Clear any existing messages
                this.messages = [];
                
                // Add all messages to chat history
                messages.forEach(msg => {
                    const sender = msg.role === 'user' ? 'user' : 'ai';
                    this.addMessage(msg.content || '', sender, false); // false = don't save to DB (already there)
                    // Normalize internal message shape to { text, sender }
                    if (typeof msg.content === 'string' && msg.content.trim() !== '') {
                        this.messages.push({
                            text: msg.content,
                            sender: sender,
                            time: new Date()
                        });
                    }
                });
                
                await this.maybeShowAutomaticWelcome(messages.length);
                
                this.updateStatus('Ready');
            } else {
                console.error('Failed to load chat history:', data);
                await this.maybeShowAutomaticWelcome(0); // Fallback to greeting
            }
        } catch (error) {
            console.error('Error loading chat history:', error);
            await this.maybeShowAutomaticWelcome(0); // Fallback to greeting
        }
    }

    async maybeShowAutomaticWelcome(messageCount) {
        // Jarvis should greet once per local calendar day. First-time users get
        // the richer orientation once. On hosted/client installs this must not be
        // suppressed just because the DB already contains the starter "Hi, I'm VMO"
        // chat-history seed. QuickLaunch users need the spoken orientation the
        // first time they enter the plugin after handoff.
        const firstKey = this.getJarvisWelcomeStorageKey('first_time');
        const dailyKey = this.getJarvisWelcomeStorageKey('daily');
        let firstSeen = false;
        let dailySeen = false;

        try {
            firstSeen = localStorage.getItem(firstKey) === '1';
            dailySeen = localStorage.getItem(dailyKey) === '1';
        } catch (_) {}

        const clientMode = this.isClientMode();
        const quicklaunch = this.getQuickLaunchWelcomeData();
        const urlWelcome = /[?&]vmo_quicklaunch_welcome=1/i.test(String((window.location && window.location.href) || ''));
        const forceFirstWelcome = !!(clientMode || urlWelcome || quicklaunch.pending || quicklaunch.message || quicklaunch.spoken_message);
        const forceQuickLaunchOrientation = !!(urlWelcome || quicklaunch.pending);
        const shouldSpeak = clientMode || forceFirstWelcome; // hosted/client Jarvis greets audibly
        const useBackend = true;

        if ((forceQuickLaunchOrientation || !firstSeen) && (forceFirstWelcome || Number(messageCount || 0) === 0)) {
            if (this.quickLaunchWelcomeRequiresServiceReady() && !(await this.refreshQuickLaunchServiceReady())) {
                this.deferQuickLaunchWelcomeUntilServiceReady(messageCount);
                return;
            }
            this._quickLaunchWelcomeReadinessRetries = 0;
            await this.loadGreeting('first_time', { speak: shouldSpeak, useBackend });
            try {
                localStorage.setItem(firstKey, '1');
                localStorage.setItem(dailyKey, '1'); // avoid first-time + daily double greeting same day
            } catch (_) {}
            console.log('[VMOVoicePanel] First-entry Jarvis welcome shown for today', { clientMode, messageCount });
            return;
        }

        if (!dailySeen) {
            await this.loadGreeting('daily', { speak: shouldSpeak, useBackend });
            try { localStorage.setItem(dailyKey, '1'); } catch (_) {}
            console.log('[VMOVoicePanel] Daily Jarvis welcome shown for local date:', this.getLocalDateKey(), { clientMode });
        }
    }
    
    async loadGreeting(kind = 'first_time', options = {}) {
        const opts = Object.assign({ speak: false, useBackend: true }, options || {});
        let greeting = '';

        if (opts.useBackend) {
            try {
                const response = await fetch(vmo_ajax.ajaxurl, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: new URLSearchParams({
                        action: 'vmo_get_user_status_for_greeting',
                        requested_greeting: kind,
                        quicklaunch_first_entry: kind === 'first_time' && (this.isClientMode() || /[?&]vmo_quicklaunch_welcome=1/i.test(String((window.location && window.location.href) || ''))) ? '1' : '0',
                        local_date: this.getLocalDateKey(),
                        local_time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
                        security: vmo_ajax.nonce
                    })
                });
                const data = await response.json();
                if (data.success && data.data && data.data.greeting) {
                    greeting = data.data.greeting;
                }
            } catch (error) {
                console.warn('Jarvis greeting lookup failed:', error);
            }
        }

        if (!greeting) {
            greeting = kind === 'daily'
                ? 'Welcome back. Jarvis here, your VMO AI Coordinator. What would you like to do today? I can check your emails, review your website health, guide you through the dashboard, or help you take the next right action. For instant voice replies, choose Premium mode from the gear icon; Default mode is cheaper but usually a few seconds slower.'
                : 'Welcome. I’m Jarvis, the AI Coordinator for your VMO plugin. You can think of me as your operations manager. Your workspace is ready. VMO is your business builder and manager, not just a website builder. Press the red Business Wizard button to begin the Business Wizard consultation. For instant voice replies, choose Premium mode from the gear icon; Default mode is cheaper but usually a few seconds slower.';
        }

        this.addMessage(greeting, 'ai', false);
        this.updateStatus('Ready');

        if (opts.speak) {
            // Give the interface a beat to finish painting before voice starts.
            // On brand-new QuickLaunch/Vultr instances, Cloudflare/PHP can answer
            // normal pages before admin-ajax/REST proxy calls are fully stable.
            // Queue the spoken welcome with quiet retry instead of failing once and
            // making the brand-new customer think Jarvis is silent.
            this.scheduleAutomaticWelcomeSpeech(greeting);
        }
    }
    

    isTransientLaunchAudioError(error) {
        const msg = String((error && (error.message || error.statusText)) || error || '').toLowerCase();
        const status = parseInt((error && error.status) || 0, 10);
        return !!(
            [0, 429, 500, 502, 503, 504, 521, 522, 523, 524].indexOf(status) !== -1 ||
            msg.indexOf('failed to fetch') !== -1 ||
            msg.indexOf('gateway') !== -1 ||
            msg.indexOf('cloudflare') !== -1 ||
            msg.indexOf('timed out') !== -1 ||
            msg.indexOf('timeout') !== -1 ||
            msg.indexOf('origin') !== -1 ||
            msg.indexOf('server responded with a status of 52') !== -1
        );
    }

    scheduleAutomaticWelcomeSpeech(greeting) {
        const text = String(greeting || '').trim();
        if (!text) return;
        if (this._autoWelcomeVoiceTimer) {
            try { clearTimeout(this._autoWelcomeVoiceTimer); } catch (_) {}
        }
        const quicklaunch = this.getQuickLaunchWelcomeData();
        const freshQuickLaunch = this.isClientMode() || /[?&]vmo_quicklaunch_welcome=1/i.test(String((window.location && window.location.href) || '')) || !!(quicklaunch && (quicklaunch.pending || quicklaunch.message || quicklaunch.spoken_message));
        if (freshQuickLaunch && this.quickLaunchWelcomeRequiresServiceReady() && !this.isQuickLaunchServiceReady()) {
            this.deferQuickLaunchWelcomeUntilServiceReady(0);
            return;
        }
        if (freshQuickLaunch) {
            this.showListeningModal('speaking');
            this.updateLiveTranscript('Jarvis is preparing your welcome…', 'ai');
        }
        const delays = freshQuickLaunch ? [200, 2500, 8000, 18000, 35000, 60000] : [600, 5000, 15000];
        const speakAttempt = (index) => {
            this._autoWelcomeVoiceTimer = setTimeout(() => {
                if (this.isProcessing || this.isSpeaking) {
                    speakAttempt(Math.min(index + 1, delays.length - 1));
                    return;
                }
                console.log('[VMOVoicePanel] Speaking automatic Jarvis welcome via VMO/OpenAI voice', { attempt: index + 1, freshQuickLaunch });
                this.speakWithOpenAI(text, {
                    quicklaunchWelcome: freshQuickLaunch,
                    ttsMaxAttempts: freshQuickLaunch ? 2 : 1,
                    timeoutMs: freshQuickLaunch ? 30000 : 18000,
                    noAutoRestart: true,
                    voice: freshQuickLaunch ? 'ash' : undefined,
                    welcomeFullscreen: freshQuickLaunch,
                    syncElement: freshQuickLaunch ? this.listeningTranscript : null,
                    syncText: text
                }).catch(err => {
                    const transient = this.isTransientLaunchAudioError(err);
                    console.warn('Jarvis automatic welcome voice did not play:', err?.message || err, { transient, attempt: index + 1 });
                    if (transient && index + 1 < delays.length) {
                        this.updateStatus('Voice is warming up…');
                        speakAttempt(index + 1);
                    } else {
                        this.updateStatus('Ready');
                    }
                });
            }, delays[index] || 1000);
        };
        speakAttempt(0);
    }

    activateTextChat() {
        // Focus on the text input area if it exists, or create one
        let textInput = document.getElementById('vmo-voice-text-input');
        
        if (!textInput) {
            // Create text input area dynamically
            const inputContainer = document.createElement('div');
            inputContainer.className = 'vmo-voice-text-input-container';
            inputContainer.innerHTML = `
                <textarea 
                    id="vmo-voice-text-input" 
                    class="vmo-voice-text-input" 
                    placeholder="Type your message to VMO..."
                    rows="3"
                ></textarea>
                <button id="vmo-voice-send-text" class="vmo-voice-send-btn" title="Send Message">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
                        <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
                    </svg>
                </button>
            `;
            
            // Insert before the input controls
            const inputControls = document.querySelector('.vmo-voice-input-controls');
            if (inputControls) {
                inputControls.parentNode.insertBefore(inputContainer, inputControls);
            }
            
            textInput = document.getElementById('vmo-voice-text-input');
            const sendBtn = document.getElementById('vmo-voice-send-text');
            
            // Bind send button
            sendBtn?.addEventListener('click', () => {
                const message = textInput.value.trim();
                if (message || this.hasStagedAttachments()) {
                    this.sendMessage(message);
                    textInput.value = '';
                }
            });
            
            // Bind Enter key (Shift+Enter for new line)
            textInput?.addEventListener('keypress', (e) => {
                if (e.key === 'Enter' && !e.shiftKey) {
                    e.preventDefault();
                    const message = textInput.value.trim();
                    if (message || this.hasStagedAttachments()) {
                        this.sendMessage(message);
                        textInput.value = '';
                    }
                }
            });
        }
        
        // Focus the input
        textInput?.focus();
    }
    
    /**
     * ✅ Populate the Jarvis chat input box with transcribed text.
     * Creates the input if it doesn't exist, updates it live during dictation.
     */
    _populateInputBox(text) {
        let textInput = document.getElementById('vmo-voice-text-input');
        if (!textInput) {
            this.activateTextChat();
            textInput = document.getElementById('vmo-voice-text-input');
        }
        if (textInput) {
            textInput.value = text;
            textInput.focus();
            // Auto-resize: scroll to bottom of textarea so latest text is visible
            textInput.scrollTop = textInput.scrollHeight;
        }
    }
    
    hasStagedAttachments() {
        return Array.isArray(this.stagedAttachments) && this.stagedAttachments.length > 0;
    }

    getUploadSessionId() {
        if (window.vmoCurrentConsultationSessionId) {
            return String(window.vmoCurrentConsultationSessionId);
        }

        try {
            const stored = sessionStorage.getItem('vmo_jarvis_upload_session_id');
            if (stored) return stored;

            const generated = 'jarvis_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
            sessionStorage.setItem('vmo_jarvis_upload_session_id', generated);
            return generated;
        } catch (_) {
            return 'jarvis_' + Date.now();
        }
    }

    getAttachmentKind(fileOrPayload) {
        const mime = String(fileOrPayload?.type || fileOrPayload?.media_type || '').toLowerCase();
        const name = String(fileOrPayload?.name || fileOrPayload?.filename || fileOrPayload?.title || fileOrPayload?.url || '').toLowerCase();

        if (mime.startsWith('video/') || /\.(mp4|webm|ogg|mov|m4v)$/i.test(name)) return 'video';
        if (mime.startsWith('audio/') || /\.(mp3|wav|m4a|aac|oga)$/i.test(name)) return 'audio';
        if (mime.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(name)) return 'image';
        return 'file';
    }

    formatFileSize(bytes) {
        const size = Number(bytes || 0);
        if (!size) return '';
        if (size < 1024) return size + ' B';
        if (size < 1024 * 1024) return Math.round(size / 1024) + ' KB';
        return (size / (1024 * 1024)).toFixed(size >= 10 * 1024 * 1024 ? 0 : 1) + ' MB';
    }

    stageFilesForAttachment(files) {
        const incoming = Array.from(files || []);
        if (!incoming.length) return;

        if (!Array.isArray(this.stagedAttachments)) this.stagedAttachments = [];

        incoming.forEach(file => {
            const exists = this.stagedAttachments.some(item => {
                const stagedFile = item.file || item;
                return stagedFile.name === file.name && stagedFile.size === file.size && stagedFile.lastModified === file.lastModified;
            });

            if (exists) return;

            const kind = this.getAttachmentKind(file);
            this.stagedAttachments.push({
                file,
                kind,
                name: file.name,
                size: file.size,
                type: file.type,
                previewUrl: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : ''
            });
        });

        this.renderStagedAttachments();
        this.activateTextChat();
        this.updateStatus(this.stagedAttachments.length === 1 ? '1 file attached. Add an instruction.' : this.stagedAttachments.length + ' files attached. Add an instruction.');
    }

    removeStagedAttachment(index) {
        if (!Array.isArray(this.stagedAttachments)) return;
        const removed = this.stagedAttachments.splice(index, 1)[0];
        if (removed?.previewUrl) {
            try { URL.revokeObjectURL(removed.previewUrl); } catch (_) {}
        }
        this.renderStagedAttachments();
        this.updateStatus(this.hasStagedAttachments() ? 'Attachment ready. Add an instruction.' : 'Ready');
    }

    clearStagedAttachments() {
        if (Array.isArray(this.stagedAttachments)) {
            this.stagedAttachments.forEach(item => {
                if (item?.previewUrl) {
                    try { URL.revokeObjectURL(item.previewUrl); } catch (_) {}
                }
            });
        }
        this.stagedAttachments = [];
        this.renderStagedAttachments();
    }

    renderStagedAttachments() {
        let preview = document.getElementById('vmo-voice-attachment-preview');
        const inputControls = document.querySelector('.vmo-voice-input-controls');
        const attachments = Array.isArray(this.stagedAttachments) ? this.stagedAttachments : [];

        if (!attachments.length) {
            if (preview) preview.remove();
            return;
        }

        if (!preview) {
            preview = document.createElement('div');
            preview.id = 'vmo-voice-attachment-preview';
            preview.className = 'vmo-voice-attachment-preview';
            if (inputControls && inputControls.parentNode) {
                inputControls.parentNode.insertBefore(preview, inputControls);
            } else if (this.panel) {
                this.panel.appendChild(preview);
            }
        }

        preview.innerHTML = '';

        attachments.forEach((item, index) => {
            const chip = document.createElement('div');
            chip.className = 'vmo-voice-attachment-chip';

            const thumb = document.createElement('div');
            thumb.className = 'vmo-voice-attachment-thumb';

            if (item.kind === 'image' && item.previewUrl) {
                const img = document.createElement('img');
                img.src = item.previewUrl;
                img.alt = '';
                thumb.appendChild(img);
            } else if (item.kind === 'video' && item.previewUrl) {
                const video = document.createElement('video');
                video.src = item.previewUrl;
                video.muted = true;
                thumb.appendChild(video);
            } else {
                thumb.textContent = item.kind === 'audio' ? 'Audio' : 'File';
            }

            const label = document.createElement('div');
            label.className = 'vmo-voice-attachment-label';
            label.innerHTML = `<strong>${this.escapeHtml(item.name || 'Attachment')}</strong><span>${this.formatFileSize(item.size)}</span>`;

            const remove = document.createElement('button');
            remove.type = 'button';
            remove.className = 'vmo-voice-attachment-remove';
            remove.setAttribute('aria-label', 'Remove attachment');
            remove.textContent = '×';
            remove.addEventListener('click', () => this.removeStagedAttachment(index));

            chip.appendChild(thumb);
            chip.appendChild(label);
            chip.appendChild(remove);
            preview.appendChild(chip);
        });
    }

    escapeHtml(value) {
        return String(value || '')
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    getAttachmentDisplayText(text, attachments) {
        const message = String(text || '').trim();
        const names = (attachments || []).map(item => item.name || item.file?.name || 'attachment').join(', ');
        if (!names) return message;
        return message ? `${message}\n\nAttached: ${names}` : `Attached: ${names}`;
    }

    normalizeUploadedAttachment(payload, originalFile) {
        const data = payload || {};
        const url = data.url || data.media_url || data.source_url || data.guid || '';
        const filename = data.filename || data.title || data.name || originalFile?.name || (url ? url.split('/').pop() : 'Attachment');
        const mime = data.type || data.media_type || originalFile?.type || '';
        const kind = this.getAttachmentKind({ type: mime, filename, url });

        return {
            id: data.id || data.attachment_id || data.attachmentId || data.media_id || data.mediaId || '',
            url,
            filename,
            title: data.title || filename,
            type: kind,
            mime,
            size: originalFile?.size || 0
        };
    }

    getUploadedAttachmentContext(message, uploadedFiles) {
        const base = String(message || '').trim() || 'Please review the attached file(s).';
        if (!uploadedFiles || !uploadedFiles.length) return base;

        const attachmentLines = uploadedFiles.map((file, index) => {
            return `${index + 1}. ${file.title || file.filename || 'Attachment'} (${file.mime || file.type || 'file'}): ${file.url}`;
        }).join('\n');

        return `${base}\n\nAttached files for context:\n${attachmentLines}\n\nUse these attachments as evidence/reference unless I explicitly ask you to insert them into the website preview.`;
    }

    shouldUseAttachmentsForWebsite(prompt) {
        const text = String(prompt || '').toLowerCase();
        const action = /\b(insert|place|put|add|apply|use|set|make|turn|create|replace|show|display)\b/i.test(text);
        const websiteTarget = /\b(website|webpage|page|site|section|hero|banner|background|backdrop|parallax|gallery|carousel|slider|iframe|preview|live preview|web page)\b/i.test(text);
        const referenceOnly = /\b(error|issue|bug|problem|screenshot|reference|explain|why|what is|diagnose|debug|broken|failed)\b/i.test(text);

        return action && websiteTarget && !(referenceOnly && !/\b(insert|place|put|add|apply|set|replace|gallery|carousel|slider|background|hero|parallax)\b/i.test(text));
    }

    async uploadStagedAttachments(attachments) {
        const uploaded = [];
        for (const item of attachments) {
            const file = item.file || item;
            const result = await this.uploadFile(file);
            uploaded.push(result);
        }
        return uploaded;
    }

    // ✅ NEW: Open file upload dialog for Jarvis AI chat
    openFileUpload() {
        console.log('📎 Opening file upload for Jarvis AI chat...');
        
        // Create a hidden file input if it doesn't exist
        let fileInput = document.getElementById('vmo-voice-file-input');
        if (!fileInput) {
            fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.id = 'vmo-voice-file-input';
            fileInput.accept = 'image/*,video/*,audio/*,.pdf,.doc,.docx,.txt,.csv,.json,.zip';
            fileInput.multiple = true;
            fileInput.style.display = 'none';
            document.body.appendChild(fileInput);
            
            // Handle file selection
            fileInput.addEventListener('change', (e) => {
                const files = Array.from(e.target.files);
                if (files.length === 0) return;
                this.stageFilesForAttachment(files);
                
                // Clear the input for next use
                fileInput.value = '';
            });
        }
        
        // Trigger file picker
        fileInput.click();
    }
    
    // ✅ NEW: Upload a file to the server
    async uploadFile(file) {
        const formData = new FormData();
        formData.append('media', file);
        formData.append('action', 'vmo_upload_consultation_media');
        formData.append('session_id', this.getUploadSessionId());
        formData.append('context', 'jarvis_voice_attachment');
        
        // Get nonce from various sources
        const nonce = window.vmo_builder_nonce || 
                      window.vmoBuilderNonce || 
                      (window.vmo_ajax && window.vmo_ajax.nonce) ||
                      (window.vmoAjax && (window.vmoAjax.nonce || window.vmoAjax.security)) || '';
        formData.append('security', nonce);
                formData.append('nonce', nonce);
                formData.append('_ajax_nonce', nonce);
        
        // Get ajax URL
        const ajaxUrl = (window.vmo_ajax && window.vmo_ajax.ajaxurl) || 
                        (window.vmoAjax && (window.vmoAjax.ajaxurl || window.vmoAjax.ajax_url || window.vmoAjax.url)) ||
                        (window.ajaxurl) ||
                        '/wp-admin/admin-ajax.php';
        
        const response = await fetch(ajaxUrl, {
            method: 'POST',
            body: formData
        });
        
        const result = await response.json();
        
        if (result.success && result.data) {
            const uploaded = this.normalizeUploadedAttachment(result.data, file);
            if (!uploaded.url) {
                throw new Error('Upload succeeded but no media URL was returned');
            }

            console.log('✅ File uploaded:', uploaded.filename);
            
            // Store the uploaded file info
            if (!this.uploadedFiles) this.uploadedFiles = [];
            this.uploadedFiles.push(uploaded);
            
            return uploaded;
        } else {
            throw new Error(result.data?.message || 'Upload failed');
        }
    }

    isServiceLockError(message, payload = {}) {
        const combined = [
            message || '',
            payload && payload.detail ? payload.detail : '',
            payload && payload.message ? payload.message : '',
            payload && payload.status ? payload.status : '',
            payload && payload.code ? payload.code : '',
            payload && payload.service_lock_reason ? payload.service_lock_reason : ''
        ].join(' ').toLowerCase();
        return !!(payload && (payload.service_locked || payload.payment_required)) ||
            /services? (are )?paused/.test(combined) ||
            /subscription payment/.test(combined) ||
            /payment_failed|past_due|requires_payment_method|billing/.test(combined) ||
            /service_locked/.test(combined);
    }

    showServiceLockError(message, payload = {}) {
        const detail = (payload && payload.detail) ? payload.detail : '';
        const userMessage = detail || message || 'Your VMO services are paused until billing is resolved.';
        if (typeof window !== 'undefined' && typeof window.vmoShowServiceLockModal === 'function') {
            window.vmoShowServiceLockModal(Object.assign({
                message: userMessage,
                title: 'Your VMO services are paused',
                level: 'error',
                payment_required: true,
                service_locked: true
            }, payload || {}));
        }
        return 'Your VMO services are paused. Use the popup to update payment or restore access, then try Jarvis again.';
    }

    formatAssistantError(message) {
        const cleaned = String(message || '').replace(/^Error:\s*/i, '').trim();
        if (!cleaned) {
            return 'Sorry, I couldn\'t complete that just now. Please try again.';
        }
        if (
            /^sorry\b/i.test(cleaned) ||
            /^jarvis\b/i.test(cleaned) ||
            /^the consultation assistant\b/i.test(cleaned) ||
            /^insufficient tokens\b/i.test(cleaned) ||
            /^your vmo services\b/i.test(cleaned)
        ) {
            return cleaned;
        }
        return 'Sorry, I couldn\'t complete that just now. Please try again.';
    }

    isJarvisOnboardingReady() {
        return !!(window.vmo_ajax && window.vmo_ajax.jarvis_onboarding_ready);
    }

    getConsultationStartMessage() {
        return 'Before I build or change anything, please click the red Business Wizard button at the top to begin your Business Consultation. I can still answer questions about VMO and help you find your way around the plugin, but the consultation has to create the connected business foundation before I build, refine, or set up the website and business systems.';
    }

    shouldRoutePreConsultationToWizard(text, pendingAttachments) {
        const hasAttachments = Array.isArray(pendingAttachments) && pendingAttachments.length > 0;
        const normalized = String(text || '').toLowerCase().replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
        if (!normalized && hasAttachments) return true;

        const patterns = [
            /\b(build|create|generate|make|design|draft|produce|start|launch|publish)\b.{0,90}\b(my|our|me|us|website|web\s*site|site|landing\s*page|homepage|home\s*page|store|shop|ecommerce|e-commerce)\b/i,
            /\b(website|web\s*site|site|landing\s*page|homepage|home\s*page|page|section|hero|header|footer|testimonial|testimonials|portfolio|services|preview)\b.{0,90}\b(add|change|edit|update|replace|remove|refine|improve|redesign|rewrite|insert|move|resize)\b/i,
            /\b(add|change|edit|update|replace|remove|refine|improve|redesign|rewrite|insert|move|resize)\b.{0,90}\b(website|web\s*site|site|landing\s*page|homepage|home\s*page|page|section|hero|header|footer|testimonial|testimonials|portfolio|services|preview)\b/i,
            /\b(set\s*up|setup|connect|provision|configure|activate|turn\s*on|install)\b.{0,90}\b(business|website|web\s*site|email|mailbox|phone|sms|seo|payment|payments|stripe|shipping|domain|hosting|crm|kpi|dashboard)\b/i,
            /\b(read|compose|reply|send|save|draft)\b.{0,90}\b(email|emails|mail|message|messages)\b/i
        ];

        return patterns.some((pattern) => pattern.test(normalized));
    }

    focusConsultationButton() {
        const btn = document.getElementById('vmo-business-wizard-btn');
        if (!btn) return;
        try { btn.focus({ preventScroll: false }); } catch (_) { try { btn.focus(); } catch (_) {} }
        const oldShadow = btn.style.boxShadow;
        const oldTransform = btn.style.transform;
        btn.style.boxShadow = '0 0 0 4px rgba(239,68,68,.25), 0 0 22px rgba(239,68,68,.45)';
        btn.style.transform = 'translateY(-1px)';
        setTimeout(() => {
            btn.style.boxShadow = oldShadow;
            btn.style.transform = oldTransform;
        }, 1800);
    }
    
    async sendMessage(text) {
        const originalText = String(text || '').trim();
        const pendingAttachments = Array.isArray(this.stagedAttachments) ? this.stagedAttachments.slice() : [];
        const userDisplayText = this.getAttachmentDisplayText(originalText, pendingAttachments);

        if (!originalText && !pendingAttachments.length) {
            this.updateStatus('Type a message or attach a file first.');
            return;
        }

        // Add user message to chat
        this.addMessage(userDisplayText, 'user');

        const owner = (this.listeningModal && this.listeningModal.dataset && this.listeningModal.dataset.owner) || '';
        if (owner !== 'consultation' && !this.isJarvisOnboardingReady() && this.shouldRoutePreConsultationToWizard(originalText, pendingAttachments)) {
            const guidance = this.getConsultationStartMessage();
            this.addMessage(guidance, 'ai');
            this.focusConsultationButton();
            this.updateStatus('Business Consultation required');
            this.isProcessing = false;
            return;
        }
        
        // Update status
        this.updateStatus(pendingAttachments.length ? 'Preparing attached file(s)...' : 'VMO is thinking...');
        // If in voice session, reflect phase in modal
        if (this.isActive) this.setModalPhase('thinking');
        // Prevent premature auto-listen while we wait for AI + TTS
        this.isProcessing = true;

        // Safety watchdog: keep it long enough for research/consultation-grade answers.
        const processingWatchdog = setTimeout(() => {
            if (this.isProcessing) {
                console.warn('⚠️ Processing watchdog fired — resetting isProcessing after extended wait');
                this.isProcessing = false;
                this.updateStatus('Request timed out');
                this.addMessage('Sorry, the request took too long. Please try again.', 'ai');
                if (this.isActive && !this.isSpeaking) {
                    setTimeout(() => this.startVoiceInput(false), 500);
                }
            }
        }, 180000);
        
        try {
            let messageForAI = originalText;

            if (pendingAttachments.length) {
                let uploadedAttachments = [];

                this.updateStatus(pendingAttachments.length === 1 ? 'Uploading attached file...' : 'Uploading attached files...');

                try {
                    uploadedAttachments = await this.uploadStagedAttachments(pendingAttachments);
                } catch (uploadError) {
                    console.error('File upload error:', uploadError);
                    this.updateStatus('File upload failed');
                    this.addMessage('File upload failed. The attachment is still staged, so you can try sending again.', 'system', false);
                    this.isProcessing = false;
                    if (this.isActive) {
                        setTimeout(() => {
                            if (this.isActive && !this.isSpeaking && !this.isProcessing) this.startVoiceInput(false);
                        }, 1500);
                    }
                    return;
                }

                this.clearStagedAttachments();

                if (this.shouldUseAttachmentsForWebsite(originalText) && typeof window.vmoApplyMediaInstructionToPreview === 'function') {
                    const instruction = originalText || 'Insert the attached media into the website preview.';
                    const applied = window.vmoApplyMediaInstructionToPreview(instruction, uploadedAttachments, { clearPending: true });

                    this.addMessage(applied
                        ? 'Done. I applied the attached media to the website preview.'
                        : 'I uploaded the file, but I could not apply it to the current website preview. Please make sure a generated page is loaded first.', 'ai');
                    this.updateStatus(applied ? 'Ready' : 'Could not apply media');
                    this.isProcessing = false;
                    return;
                }

                messageForAI = this.getUploadedAttachmentContext(originalText, uploadedAttachments);

                for (let i = this.messages.length - 1; i >= 0; i--) {
                    if (this.messages[i] && this.messages[i].sender === 'user') {
                        this.messages[i].text = messageForAI;
                        break;
                    }
                }
            }

            if (owner !== 'consultation' && await this.tryHandleJarvisEmailOperatorCommand(messageForAI)) {
                clearTimeout(processingWatchdog);
                return;
            }

            this.updateStatus('VMO is thinking...');

            // Build conversation history for context (normalize shapes and filter out empties)
            const chatHistory = this.messages
                .map(msg => {
                    const content = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '');
                    const role = (msg.sender === 'user' || msg.role === 'user') ? 'user' : 'assistant';
                    if (!content || typeof content !== 'string' || content.trim() === '') return null;
                    return { role, content: content.trim() };
                })
                .filter(Boolean);
            
            // ✅ STREAMING + INTERRUPTION: Use AbortController for both timeout and barge-in
            if (this._streamController) { try { this._streamController.abort(); } catch(_){} }
            this._streamController = new AbortController();
            const fetchTimeout = setTimeout(() => {
                if (this._streamController) this._streamController.abort();
            }, 180000); // Long enough for detailed streamed answers

            const params = new URLSearchParams();
            params.set('action', 'vmo_conversation_chat');
            const chatType = owner === 'consultation' ? 'consultant' : 'jarvis';
            params.set('chat_type', chatType);
            const sid = (typeof window !== 'undefined' && window.vmoCurrentConsultationSessionId) ? String(window.vmoCurrentConsultationSessionId) : '';
            if (sid) params.set('session_id', sid);
            try {
                const lang = (this.recognition && this.recognition.lang) ? this.recognition.lang : (this.defaultLang || navigator.language || '');
                if (lang) params.set('lang', lang);
            } catch (_) {}
            params.set('chat', JSON.stringify(chatHistory));
            params.set('security', vmo_ajax.nonce);
            params.set('stream', '1'); // ✅ Request SSE streaming

            const response = await fetch(vmo_ajax.ajaxurl, {
                method: 'POST',
                signal: this._streamController.signal,
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: params
            });
            
            clearTimeout(fetchTimeout);

            const contentType = response.headers.get('Content-Type') || '';

            if (contentType.includes('text/event-stream')) {
                // ══ STREAMING PATH ══
                // Create placeholder AI message bubble. In active voice mode, keep text synced
                // to the spoken audio instead of dumping the full transcript before Jarvis talks.
                const msgEl = this._addStreamBubble();
                const syncVoiceTranscript = !!this.isActive;
                if (syncVoiceTranscript && msgEl) {
                    this._prepareVoiceSyncedBubble(msgEl, 'Jarvis is listening to the answer…');
                }
                let fullText = '';
                let streamError = '';
                let streamErrorDetail = '';
                const reader = response.body.getReader();
                const decoder = new TextDecoder();
                let sseBuffer = '';

                try {
                    while (true) {
                        const { done, value } = await reader.read();
                        if (done) break;
                        sseBuffer += decoder.decode(value, { stream: true });
                        const lines = sseBuffer.split('\n');
                        sseBuffer = lines.pop(); // keep incomplete line
                        for (const line of lines) {
                            const trimmed = line.trim();
                            if (!trimmed || !trimmed.startsWith('data: ')) continue;
                            try {
                                const evt = JSON.parse(trimmed.substring(6));
                                if (evt.t) {
                                    fullText += evt.t;
                                    if (!syncVoiceTranscript && msgEl) {
                                        msgEl.textContent = fullText;
                                        if (this.chatHistory) this.chatHistory.scrollTop = this.chatHistory.scrollHeight;
                                    }
                                } else if (evt.error) {
                                    streamError = evt.error;
                                    streamErrorDetail = evt.detail || '';
                                }
                                // evt.done handled after loop
                            } catch (pe) {
                                if (pe.message && !pe.message.includes('JSON')) throw pe;
                            }
                        }
                    }
                } catch (streamErr) {
                    if (streamErr.name === 'AbortError') {
                        // Interrupted by user barge-in — keep partial text
                        console.log('🔇 Stream interrupted by user (barge-in)');
                        this._partialAIResponse = fullText;
                    } else {
                        throw streamErr;
                    }
                }

                if (streamError && !fullText) {
                    if (streamErrorDetail) {
                        console.error('Jarvis stream detail:', streamErrorDetail);
                    }
                    const servicePayload = { detail: streamErrorDetail, message: streamError };
                    const displayError = this.isServiceLockError(streamError, servicePayload)
                        ? this.showServiceLockError(streamErrorDetail || streamError, servicePayload)
                        : this.formatAssistantError(streamError);
                    this.addMessage(displayError, 'ai');
                    this.updateStatus('Error');
                    this.isProcessing = false;
                    if (this.isActive) {
                        setTimeout(() => {
                            if (this.isActive && !this.isSpeaking && !this.isProcessing) this.startVoiceInput(false);
                        }, 1500);
                    }
                    return;
                }

                if (fullText) {
                    this.messages.push({ text: fullText, sender: 'ai', time: new Date() });
                }
                if (this.isActive && fullText) {
                    if (this.recognition && this.isListening) {
                        try { this.recognition.stop(); this.isListening = false; } catch(_) {}
                    }
                    await this.speakWithOpenAI(fullText, syncVoiceTranscript ? { syncElement: msgEl, syncText: fullText } : {});
                } else {
                    this.updateStatus('Ready');
                    this.isProcessing = false;
                }
            } else {
                // ══ FALLBACK: non-streaming JSON response ══
                const data = await response.json();
                if (data.success && data.data && data.data.response) {
                    let syncMsgEl = null;
                    if (this.isActive) {
                        syncMsgEl = this._addStreamBubble();
                        this._prepareVoiceSyncedBubble(syncMsgEl, 'Jarvis is preparing the voice…');
                        this.messages.push({ text: data.data.response, sender: 'ai', time: new Date() });
                    } else {
                        this.addMessage(data.data.response, 'ai');
                    }
                    if (data.data.consultation_required) {
                        this.focusConsultationButton();
                        this.updateStatus('Business Consultation required');
                        this.isProcessing = false;
                        return;
                    }
                    if (this.isActive) {
                        if (this.recognition && this.isListening) {
                            try { this.recognition.stop(); this.isListening = false; } catch(_) {}
                        }
                        await this.speakWithOpenAI(data.data.response, syncMsgEl ? { syncElement: syncMsgEl, syncText: data.data.response } : {});
                    } else {
                        this.updateStatus('Ready');
                        this.isProcessing = false;
                    }
                } else {
                    let errorMsg = '';
                    if (data.data && data.data.message) {
                        errorMsg = data.data.message;
                        if (data.data.detail) {
                            console.error('Jarvis request detail:', data.data.detail);
                        }
                    } else if (data.message) {
                        errorMsg = data.message;
                    } else {
                        errorMsg = 'Sorry, I couldn\'t complete that just now. Please try again.';
                    }
                    const errorPayload = (data && data.data) ? data.data : {};
                    const displayError = this.isServiceLockError(errorMsg, errorPayload)
                        ? this.showServiceLockError(errorMsg, errorPayload)
                        : this.formatAssistantError(errorMsg);
                    this.addMessage(displayError, 'ai');
                    this.updateStatus('Error');
                    this.isProcessing = false;
                    if (this.isActive) {
                        setTimeout(() => {
                            if (this.isActive && !this.isSpeaking && !this.isProcessing) this.startVoiceInput(false);
                        }, 1500);
                    }
                }
            }
        } catch (error) {
            console.error('Failed to send message:', error);
            const isTimeout = error.name === 'AbortError';
            if (isTimeout && this._partialAIResponse) {
                // Barge-in: partial response already rendered, don't show error
                this.isProcessing = false;
            } else {
                const fallbackMsg = isTimeout
                    ? 'Sorry, the request timed out. Please try again.'
                    : 'Sorry, I couldn\'t complete that just now. Please try again.';
                this.addMessage(fallbackMsg, 'ai');
                this.updateStatus(isTimeout ? 'Request timed out' : 'Request error');
                this.isProcessing = false;
            }
            if (this.isActive) {
                setTimeout(() => {
                    if (this.isActive && !this.isSpeaking && !this.isProcessing) this.startVoiceInput(false);
                }, 1500);
            }
        } finally {
            clearTimeout(processingWatchdog);
            this._streamController = null;
        }
    }

    async tryHandleJarvisEmailOperatorCommand(text) {
        const command = String(text || '').trim();
        if (!command || !/\b(email|emails|inbox|mail|message|messages|reply|draft)\b/i.test(command)) {
            return false;
        }
        if (/^\s*(write|compose|create|send)\s+(?:an?\s+)?email\s*$/i.test(command)) {
            return false;
        }

        try {
            const response = await fetch(vmo_ajax.ajaxurl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: new URLSearchParams({
                    action: 'vmo_jarvis_command',
                    security: vmo_ajax.nonce,
                    command: command
                })
            });
            const payload = await response.json();
            if (!payload || !payload.success || !payload.data || payload.data.type !== 'email_operator') {
                return false;
            }

            const data = payload.data;
            if (data.tab && typeof switchTab === 'function') {
                switchTab(data.tab, null);
            }
            if (data.operation === 'draft_reply' && typeof window.vmoSwitchMailFolder === 'function') {
                window.vmoSwitchMailFolder('drafts');
            } else if (data.operation === 'send_reply' && typeof window.vmoSwitchMailFolder === 'function') {
                window.vmoSwitchMailFolder('sent');
            } else if ((data.operation === 'sync_inbox' || data.operation === 'mark_done' || data.operation === 'list_emails' || data.operation === 'read_email') && typeof window.vmoSwitchMailFolder === 'function') {
                window.vmoSwitchMailFolder('inbox');
            }

            const spoken = this.formatJarvisEmailOperatorText(data);
            let syncMsgEl = null;
            if (this.isActive) {
                syncMsgEl = this._addStreamBubble();
                this._prepareVoiceSyncedBubble(syncMsgEl, 'Jarvis is preparing the voice…');
                this.messages.push({ text: spoken, sender: 'ai', time: new Date() });
                await this.speakWithOpenAI(spoken, { syncElement: syncMsgEl, syncText: spoken });
            } else {
                this.addMessage(spoken, 'ai');
                this.updateStatus('Ready');
                this.isProcessing = false;
            }
            return true;
        } catch (error) {
            console.warn('Jarvis email operator command failed:', error);
            return false;
        }
    }

    formatJarvisEmailOperatorText(data) {
        let text = String((data && data.message) || 'Email command completed.');
        if (Array.isArray(data.emails) && data.emails.length) {
            const lines = data.emails.slice(0, 5).map(email => {
                return 'Email ' + (email.index || '') + ' from ' + (email.from_email || 'unknown') + ': ' + (email.subject || '(No subject)') + '. ' + (email.snippet || '');
            });
            text += '\n' + lines.join('\n');
        }
        if (data.email) {
            text += '\nFrom ' + (data.email.from_email || 'unknown') + ': ' + (data.email.subject || '(No subject)') + '. ' + (data.email.body || data.email.snippet || '');
        }
        if (data.draft_preview) {
            text += '\nDraft preview: ' + data.draft_preview;
        }
        return text;
    }
    
    async startVoiceInput(isManualBargeIn = true) {
        // ✅ MUTE GUARD: If muted, never capture microphone audio.
        if (window.vmoMicMuted) {
            console.log('🔇 Voice input blocked - Microphone is muted');
            this.updateStatus('Microphone muted');
            this.applyMute();
            return;
        }

        if (!this.isSecureVoiceContext()) {
            this.handleVoiceUnavailable(
                'voice-insecure',
                'Secure HTTPS required',
                'Voice input is available only from your secure HTTPS domain. QuickLaunch should keep you in onboarding until that secure domain is ready, then open Jarvis there automatically.'
            );
            return;
        }

        if (!this.recognition) {
            this.showVoiceNoticeOnce(
                'speech-unsupported',
                'Voice input requires a browser with speech recognition support. You can still type to Jarvis in the message box.'
            );
            this.updateStatus('Voice unavailable');
            return;
        }

        this._micPermissionBlocked = false;
        
        // ✅ SELF-LOOP PREVENTION: Only block AUTOMATIC restarts, not manual barge-in
        // Manual barge-in (user clicking mic) should ALWAYS work
        if (this._realtimeSession) {
            console.log('🎙️ Realtime session owns microphone capture; skipping Web Speech fallback start.');
            return;
        }

        if (!isManualBargeIn && this.isListening) {
            console.log('🎤 Auto-restart skipped - recognition is already listening');
            return;
        }

        if (!isManualBargeIn) {
            // This is an automatic restart after TTS - apply cooldown checks
            if (this.isSpeaking) {
                console.log('🔇 Auto-restart blocked - AI is speaking');
                return;
            }
            
            if (this._ttsEndedAt && (Date.now() - this._ttsEndedAt < (this._ttsEndCooldownMs || 1500))) {
                console.log('🔇 Auto-restart blocked - TTS cooldown period');
                return;
            }
            
            if (window.VMOAudioCoordinator && window.VMOAudioCoordinator.isSpeaking) {
                console.log('🔇 Auto-restart blocked - VMOAudioCoordinator reports TTS active');
                return;
            }
        }
        
        if (this.isListening) {
            // Stop listening
            this.recognition.stop();
            this.isListening = false;
            this.hideListeningModal();
            this.updateStatus('Stopped listening');
        } else {
            // ═══════════════════════════════════════════════════════════════════════════════
            // ✅ GUARANTEED BARGE-IN: Force-stop ALL audio immediately
            // This ensures the user can ALWAYS interrupt the AI
            // ═══════════════════════════════════════════════════════════════════════════════
            console.log('🔊 BARGE-IN: Forcing stop of all audio');
            
            // 1. Stop our own audio element
            if (this.currentAudio) {
                try {
                    this.currentAudio._vmoIntentionalStop = true;
                    this.currentAudio.onended = null;
                    this.currentAudio.onerror = null;
                    this.currentAudio.pause();
                    this.currentAudio.currentTime = 0;
                    this.currentAudio.removeAttribute('src');
                    this.currentAudio.load && this.currentAudio.load();
                } catch(_) {}
                this.currentAudio = null;
            }
            
            // 2. Stop browser speech synthesis (fallback TTS)
            try {
                if (window.speechSynthesis) {
                    window.speechSynthesis.cancel();
                }
            } catch(_) {}
            
            // 3. Stop any global audio via coordinator
            try {
                if (window.VMOAudioCoordinator) {
                    if (window.VMOAudioCoordinator.stopAllTTS) {
                        window.VMOAudioCoordinator.stopAllTTS('barge-in', 'voice-panel');
                    }
                    // Also try to stop any audio elements it tracks
                    if (window.VMOAudioCoordinator.currentAudio) {
                        try { window.VMOAudioCoordinator.currentAudio.pause(); } catch(_) {}
                    }
                }
            } catch(_) {}
            
            // 4. Stop global vmoCurrentAudio if exists
            try {
                if (window.vmoCurrentAudio) {
                    window.vmoCurrentAudio.pause();
                    window.vmoCurrentAudio = null;
                }
            } catch(_) {}
            
            // 5. Force all audio elements on page to pause (nuclear option)
            try {
                document.querySelectorAll('audio').forEach(audio => {
                    if (this._realtimeSession && audio.id === 'vmo-realtime-audio') return;
                    try { audio.pause(); } catch(_) {}
                });
            } catch(_) {}
            
            // Clear state
            this.isSpeaking = false;
            this.isProcessing = false;
            this._ttsEndedAt = Date.now();
            this.clearLiveTranscript();
            
            // ═══════════════════════════════════════════════════════════════════════════════
            // Now request mic - but DON'T let coordinator block us on barge-in
            // ═══════════════════════════════════════════════════════════════════════════════
            if (window.VMOAudioCoordinator) {
                // Force-request mic with barge-in flag
                try {
                    // First release any existing mic claims
                    window.VMOAudioCoordinator.releaseMic('voice-panel');
                    window.VMOAudioCoordinator.releaseMic('consultation');
                } catch(_) {}
                
                // Mark coordinator as not speaking (we just stopped everything)
                try {
                    if (window.VMOAudioCoordinator.onTTSStop) {
                        window.VMOAudioCoordinator.onTTSStop();
                    }
                    window.VMOAudioCoordinator.isSpeaking = false;
                } catch(_) {}
                
                // Now request mic - should always succeed after clearing state
                const ok = window.VMOAudioCoordinator.requestMic('voice-panel', { bargeIn: true });
                if (!ok) {
                    // If still blocked, just proceed anyway for barge-in
                    console.warn('⚠️ Coordinator blocked mic request, but proceeding with barge-in anyway');
                }
            }
            
            // Start listening
            try {
                await this.micWarmup();
                this.recognition.start();
            } catch (error) {
                const errorName = error && error.name ? String(error.name) : '';
                if (errorName === 'InvalidStateError') {
                    console.warn('🎤 Speech recognition was already started; ignoring duplicate start.');
                    this.isListening = true;
                    return;
                }
                console.error('Failed to start recognition:', error);
                if (errorName === 'NotAllowedError' || errorName === 'SecurityError') {
                    this.handleVoiceUnavailable(
                        errorName === 'SecurityError' ? 'voice-insecure' : 'mic-permission',
                        errorName === 'SecurityError' ? 'Secure HTTPS required' : 'Mic permission blocked',
                        errorName === 'SecurityError'
                            ? 'Voice input is available only from your secure HTTPS domain. QuickLaunch should keep you in onboarding until that secure domain is ready, then open Jarvis there automatically.'
                            : 'Microphone permission was blocked. Click the microphone permission prompt and choose Allow. If the browser already blocked it, use the site permission icon in the address bar, allow microphone access, then reload.'
                    );
                } else {
                    this.updateStatus('Error starting voice input');
                }
                try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.releaseMic('voice-panel'); } catch(_) {}
            }
        }
    }
    
    processSpeechInput(transcript) {
        console.log('📝 Transcript:', transcript);
        
        // ✅ RACE CONDITION FIX: Immediately flag processing to prevent double-submissions
        if (this.isActive && this.isProcessing) return;

        // ✅ SELF-LOOP PREVENTION: Don't process speech if AI is speaking or processing
        if (this.isSpeaking || this.isProcessing) {
            console.log('🔇 Ignoring speech input in processSpeechInput - AI is speaking or processing');
            return;
        }
        
        // ✅ COOLDOWN CHECK: Don't process speech too soon after TTS ends
        if (this._ttsEndedAt && (Date.now() - this._ttsEndedAt < (this._ttsEndCooldownMs || 1500))) {
            console.log('🔇 Ignoring speech input in processSpeechInput - TTS cooldown period');
            return;
        }
        
        // Keep modal visible during active voice session to avoid flicker
        if (!this.isActive) this.hideListeningModal();
        
        if (transcript.trim()) {
            // ✅ ALWAYS populate the input box — user reviews, edits, and clicks Send
            this._populateInputBox(transcript.trim());
        }
    }
    
    async speakWithOpenAI(text, syncOptions = {}) {
        // Stop any current audio
        if (this.currentAudio) {
            try { this.currentAudio.pause(); } catch(_) {}
            this.currentAudio = null;
        }
        
        // ✅ CRITICAL SELF-LOOP PREVENTION: Force-stop recognition before TTS
        // Also abort any pending recognition to ensure clean state
        if (this.recognition) {
            try {
                this.recognition.abort(); // More aggressive than stop()
                this.isListening = false;
            } catch(_) {}
            try {
                this.recognition.stop();
                this.isListening = false;
            } catch(_) {}
        }
        
        // VMO_FAST_WELCOME_FULLSCREEN_ASH_v1
        // First-entry QuickLaunch welcome must not whisper from the corner after
        // the user is already distracted. Show the Jarvis full-screen voice modal
        // immediately, then reveal the spoken words in large text while Ash plays.
        const welcomeFullscreen = !!(syncOptions && syncOptions.welcomeFullscreen);
        if (!this.isActive && welcomeFullscreen) {
            this.showListeningModal('speaking');
        } else if (!this.isActive) {
            this.hideListeningModal();
        }
        
        // Clear any pending silence timers and transcript buffers
        if (this._silenceTimer) {
            clearTimeout(this._silenceTimer);
            this._silenceTimer = null;
        }
        this._transcriptBuffer = '';
        this._interimText = '';
        
        // ✅ SELF-LOOP PREVENTION: Mark TTS start time
        this._ttsStartedAt = Date.now();
        this._ttsEndedAt = 0; // Reset end time
        
    this.isSpeaking = true;
    // Transition guard complete: moving into Speaking
    this.isProcessing = false;
    this.updateStatus('VMO is speaking...');
    // Update modal to Speaking phase
    if (this.isActive || welcomeFullscreen) this.setModalPhase('speaking');

    // ✅ SAFETY WATCHDOG: Force-reset isSpeaking after 60s max
    // Prevents permanent freeze if audio never fires onended/onerror
    if (this._speakingWatchdog) clearTimeout(this._speakingWatchdog);
    this._speakingWatchdog = setTimeout(() => {
        if (this.isSpeaking) {
            console.warn('⚠️ Speaking watchdog fired — resetting isSpeaking after 60s');
            this.isSpeaking = false;
            this._ttsEndedAt = Date.now();
            this.clearLiveTranscript();
            try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.onTTSStop(); } catch(_) {}
            this.updateStatus('Ready');
            if (this.isActive) {
                this.setModalPhase('listening');
                setTimeout(() => {
                    if (this.isActive && !this.isSpeaking && !this.isProcessing) {
                        this.startVoiceInput(false);
                    }
                }, 500);
            }
        }
    }, 60000);
    
        // ✅ LIVE TRANSCRIPT: Show AI text while speaking (will be cleared when done)
        // Show first ~100 chars with ellipsis if longer
        const displayText = text.length > 100 ? text.substring(0, 100) + '...' : text;
        this.updateLiveTranscript(displayText, 'ai');
        this._currentSpeakingText = text; // Store full text for progressive reveal

        const syncElement = syncOptions && syncOptions.syncElement ? syncOptions.syncElement : null;
        const syncText = syncOptions && syncOptions.syncText ? String(syncOptions.syncText) : String(text || '');
        if (syncElement) {
            this._prepareVoiceSyncedBubble(syncElement, 'Jarvis is preparing the voice…');
        }
        
        // Notify coordinator that TTS is starting
        try { 
            window.VMOAudioCoordinator && window.VMOAudioCoordinator.onTTSStart('voice-panel'); 
        } catch(_) {}
        
        try {
            // ✅ DOUBLE-CHECK: Ensure we are ABSOLUTELY not listening while speaking
            if (this.recognition) {
                try { this.recognition.abort(); } catch(_) {}
                try { this.recognition.stop(); } catch(_) {}
                this.isListening = false;
            }
            if (this._silenceTimer) { try { clearTimeout(this._silenceTimer); } catch(_) {} this._silenceTimer = null; }

            // Ensure the TTS client is available; retry briefly if still loading
            const ensureTts = () => new Promise((resolve, reject) => {
                if (typeof window.vmoSpeak === 'function') return resolve();
                const start = Date.now();
                const timer = setInterval(() => {
                    if (typeof window.vmoSpeak === 'function') {
                        clearInterval(timer);
                        resolve();
                    } else if (Date.now() - start > 3000) { // wait up to 3s
                        clearInterval(timer);
                        reject(new Error('TTS client not available'));
                    }
                }, 150);
            });

            await ensureTts();

            if (typeof window.vmoSpeak !== 'function') {
                throw new Error('TTS system not loaded');
            }

            // Use the REST/API-proxy TTS. QuickLaunch first-entry welcomes
            // get a little more patience because the tenant origin and master proxy
            // may still be warming seconds after the first successful login.
            const voice = (syncOptions && syncOptions.voice) ? String(syncOptions.voice) : (window.vmoSelectedVoice || 'ash');
            const estimatedTtsTimeout = Math.max(45000, Math.min(180000, 25000 + String(text || '').length * 35));
            const ttsOptions = {
                maxAttempts: Math.max(1, parseInt((syncOptions && syncOptions.ttsMaxAttempts) || 1, 10)),
                timeoutMs: Math.max(1000, parseInt((syncOptions && syncOptions.timeoutMs) || estimatedTtsTimeout, 10)),
                playbackSignalTimeoutMs: Math.max(120000, Math.min(45 * 60 * 1000, estimatedTtsTimeout + 60000))
            };
            this.currentAudio = await window.vmoSpeak(text, voice, 'mp3', 1.0, ttsOptions);
            // Apply global mute preference
            if (this.currentAudio) {
                try {
                    this.currentAudio.muted = !!window.vmoMuteTTS;
                    this.currentAudio.volume = window.vmoMuteTTS ? 0 : 1;
                } catch(_) {}
            }

            if (!this.currentAudio) {
                throw new Error('TTS failed to generate audio');
            }

            if (syncElement) {
                this._startVoiceSyncedReveal(syncElement, syncText, this.currentAudio);
            }

            // Set up event handlers
            this.currentAudio.onended = () => {
                console.log('🔊 TTS playback ended');
                this.isSpeaking = false;
                if (this._speakingWatchdog) { clearTimeout(this._speakingWatchdog); this._speakingWatchdog = null; }
                
                // ✅ SELF-LOOP PREVENTION: Mark TTS end time for cooldown
                this._ttsEndedAt = Date.now();
                
                if (syncElement) {
                    this._finishVoiceSyncedReveal(syncElement, syncText);
                }

                // ✅ LIVE TRANSCRIPT: Clear when AI finishes speaking
                this.clearLiveTranscript();
                this._currentSpeakingText = null;
                
                // Notify coordinator that TTS has stopped
                try { 
                    window.VMOAudioCoordinator && window.VMOAudioCoordinator.onTTSStop(); 
                } catch(_) {}
                
                this.updateStatus('Ready');
                if (this.isActive) this.setModalPhase('listening');
                if (welcomeFullscreen && !this.isActive) {
                    setTimeout(() => this.hideListeningModal(), 1200);
                }

                // If voice session is active, automatically start listening again
                // ✅ INCREASED DELAY: Wait longer before restarting to ensure TTS audio has fully stopped
                if (this.isActive && !(syncOptions && syncOptions.noAutoRestart)) {
                    const cooldownDelay = this._ttsEndCooldownMs || 1500;
                    setTimeout(() => {
                        // Double-check we're still in active mode and not speaking
                        if (this.isActive && !this.isSpeaking && !this.isProcessing) {
                            console.log('🎤 Auto-restarting voice input after TTS (with cooldown)');
                            this.startVoiceInput(false); // false = automatic restart, not manual barge-in
                        }
                    }, cooldownDelay);
                }
            };

            this.currentAudio.onerror = (error) => {
                const audioEl = error && error.currentTarget ? error.currentTarget : this.currentAudio;
                const mediaError = audioEl && audioEl.error ? audioEl.error : null;
                const details = {
                    code: mediaError ? mediaError.code : null,
                    message: mediaError && mediaError.message ? mediaError.message : '',
                    networkState: audioEl ? audioEl.networkState : null,
                    readyState: audioEl ? audioEl.readyState : null,
                    src: audioEl && audioEl.currentSrc ? audioEl.currentSrc : '',
                    intentionalStop: !!(audioEl && audioEl._vmoIntentionalStop)
                };
                if (details.intentionalStop || !this.currentAudio || this.currentAudio !== audioEl) {
                    console.debug('[VMOVoicePanel] Ignored stale/intentional audio error', details);
                    return;
                }
                console.error('❌ Audio playback error:', details, error);
                this.isSpeaking = false;
                if (this._speakingWatchdog) { clearTimeout(this._speakingWatchdog); this._speakingWatchdog = null; }
                
                // ✅ SELF-LOOP PREVENTION: Mark TTS end time for cooldown
                this._ttsEndedAt = Date.now();
                
                if (syncElement) {
                    this._finishVoiceSyncedReveal(syncElement, syncText);
                }

                // ✅ LIVE TRANSCRIPT: Clear on error too
                this.clearLiveTranscript();
                this._currentSpeakingText = null;
                
                // Notify coordinator that TTS has stopped
                try { 
                    window.VMOAudioCoordinator && window.VMOAudioCoordinator.onTTSStop(); 
                } catch(_) {}
                
                this.updateStatus('Audio error');
                if (this.isActive) this.setModalPhase('listening');
                
                // Still resume listening on error if in active mode
                // ✅ INCREASED DELAY: Use cooldown period
                if (this.isActive && !(syncOptions && syncOptions.noAutoRestart)) {
                    const cooldownDelay = this._ttsEndCooldownMs || 1500;
                    setTimeout(() => {
                        if (this.isActive && !this.isSpeaking && !this.isProcessing) {
                            this.startVoiceInput(false); // false = automatic restart
                        }
                    }, cooldownDelay);
                }
            };
            
        } catch (error) {
            console.error('❌ TTS error:', error);
            if (this._speakingWatchdog) { clearTimeout(this._speakingWatchdog); this._speakingWatchdog = null; }
            // Notify coordinator that TTS has stopped (even on error)
            try { 
                window.VMOAudioCoordinator && window.VMOAudioCoordinator.onTTSStop(); 
            } catch(_) {}
            
            // Do NOT fall back to robotic browser speech here. If VMO/OpenAI TTS fails,
            // fail fast, reset the state, and let the user continue without a delayed fake voice.
            this.isSpeaking = false;
            this._ttsEndedAt = Date.now();
            if (syncElement) {
                this._finishVoiceSyncedReveal(syncElement, syncText);
            }
            this.clearLiveTranscript();
            this.updateStatus('Voice playback unavailable');
            if (this.isActive && !(syncOptions && syncOptions.noAutoRestart)) {
                const cooldownDelay = this._ttsEndCooldownMs || 800;
                setTimeout(() => {
                    if (this.isActive && !this.isSpeaking && !this.isProcessing) {
                        this.startVoiceInput(false);
                    }
                }, cooldownDelay);
            }
        }
        // Keep mic mute UI separate from TTS output mute.
        this.applyMute();
        // Apply to current audio if any
        if (this.currentAudio) {
            try {
                this.currentAudio.muted = !!window.vmoMuteTTS;
                this.currentAudio.volume = window.vmoMuteTTS ? 0 : 1;
            } catch (_) {}
        }
    }
    
    startVoiceSession() {
        if (!this.isSecureVoiceContext()) {
            this.handleVoiceUnavailable(
                'voice-insecure',
                'Secure HTTPS required',
                'Voice input is available only from your secure HTTPS domain. QuickLaunch should keep you in onboarding until that secure domain is ready, then open Jarvis there automatically.'
            );
            return;
        }

        if (this.isActive) {
            this.endVoiceSession('Voice chat stopped');
            return;
        }

        // A fresh click on Start Voice is the only thing that re-enables the mic
        // after End Chat. This avoids the perception that Jarvis is still listening.
        this._voiceHardStopped = false;
        window.vmoMicMuted = false;
        this.isMicMuted = false;
        this.applyMute();
        try { localStorage.setItem('vmoJarvisVoiceStartAt', String(Date.now())); } catch (_) {}
        try {
            if (window.VMORealtimeSession && typeof window.VMORealtimeSession.stopAll === 'function') {
                window.VMORealtimeSession.stopAll();
            }
        } catch (_) {}

        // ─── User-selected voice mode ───────────────────────────────────────────
        // Default Chat Voice uses Web Speech recognition + VMO/OpenAI TTS.
        // Premium Live Voice uses Realtime/WebRTC for instant, interruptible audio.
        // Do NOT auto-upgrade every user into Realtime: it is a paid premium path.
        // [VMO_REALTIME_VOICE_PANEL_HOOK]
        const premiumSelected = this.isPremiumVoiceSelected();
        if (!this.isActive && premiumSelected) {
            if (global_VMORealtime_isUsable() && !this._realtimeDisabled) {
                this._startRealtimeSession();
                return;
            }
            this.showVoiceNoticeOnce(
                'premium-voice-unavailable',
                'Premium Live Voice is selected, but the live Realtime/WebRTC connection is not available in this browser/session yet. I will use Default Chat Voice for now.'
            );
        }
        if (!this.recognition) {
            this.showVoiceNoticeOnce(
                'speech-unsupported',
                'Voice chat requires a browser with speech recognition support. You can still type to Jarvis in the message box.'
            );
            this.updateStatus('Voice unavailable');
            return;
        }
        
        if (!this.isActive) {
            this.isActive = true;
            this._autoRestartEnabled = true;
            this.updateStatus('Voice chat active');
            if (this.startBtn) {
                this.startBtn.innerHTML = '<span class="vmo-voice-start-icon">⏸️</span><span class="vmo-voice-start-text">Stop Voice</span>';
            }
            // Attempt to unlock audio playback gate on some browsers (Safari/iOS)
            try {
                const AC = window.AudioContext || window.webkitAudioContext;
                if (AC) {
                    if (!window._vmoAudioCtx) window._vmoAudioCtx = new AC();
                    window._vmoAudioCtx.resume().catch(() => {});
                }
            } catch(_) {}
            
            // Add system message
            this.addMessage(`${this.getVoiceModeLabel(this.getVoiceMode())} started. Speak naturally!`, 'system');
            
            // Keep the listening modal visible throughout the session
            this.showListeningModal('listening');
            // Start listening
            this.startVoiceInput();
        } else {
            this.endVoiceSession();
        }
    }
    
    // (removed duplicate show/hide modal methods; see unified versions below)
    
    addMessage(text, sender, saveToDb = true) {
        const messageDiv = document.createElement('div');
        messageDiv.className = `vmo-voice-message ${sender}`;
        
        const contentDiv = document.createElement('div');
        contentDiv.className = 'vmo-voice-message-content';
        contentDiv.textContent = text;
        
        const timeDiv = document.createElement('div');
        timeDiv.className = 'vmo-voice-message-time';
        timeDiv.textContent = new Date().toLocaleTimeString();
        
        messageDiv.appendChild(contentDiv);
        messageDiv.appendChild(timeDiv);
        
        this.chatHistory?.appendChild(messageDiv);
        
        // Scroll to bottom
        if (this.chatHistory) {
            this.chatHistory.scrollTop = this.chatHistory.scrollHeight;
        }
        
        // Store message (don't store system messages, and only save if requested)
        if (sender !== 'system' && saveToDb) {
            this.messages.push({ text, sender, time: new Date() });
        }
    }
    
    /**
     * Create an empty AI message bubble for streaming and return its content element.
     * Tokens are appended to the returned element as they arrive.
     */
    _addStreamBubble() {
        const messageDiv = document.createElement('div');
        messageDiv.className = 'vmo-voice-message ai';

        const contentDiv = document.createElement('div');
        contentDiv.className = 'vmo-voice-message-content';
        contentDiv.textContent = ''; // starts empty

        const timeDiv = document.createElement('div');
        timeDiv.className = 'vmo-voice-message-time';
        timeDiv.textContent = new Date().toLocaleTimeString();

        messageDiv.appendChild(contentDiv);
        messageDiv.appendChild(timeDiv);

        this.chatHistory?.appendChild(messageDiv);
        if (this.chatHistory) this.chatHistory.scrollTop = this.chatHistory.scrollHeight;

        return contentDiv;
    }

    _prepareVoiceSyncedBubble(el, waitingText = 'Jarvis is preparing the voice…') {
        if (!el) return;
        try {
            if (el._vmoVoiceSyncTimer) clearTimeout(el._vmoVoiceSyncTimer);
            el._vmoVoiceSyncTimer = null;
            el.classList.add('vmo-voice-sync-pending');
            el.textContent = waitingText;
            if (this.chatHistory) this.chatHistory.scrollTop = this.chatHistory.scrollHeight;
        } catch (_) {}
    }

    _startVoiceSyncedReveal(el, text, audio) {
        if (!el) return;
        const fullText = String(text || '');
        const pieces = fullText.match(/\S+\s*/g) || (fullText ? [fullText] : []);
        if (!pieces.length) return;

        try { if (el._vmoVoiceSyncTimer) clearTimeout(el._vmoVoiceSyncTimer); } catch (_) {}
        el.classList.remove('vmo-voice-sync-pending');
        el.classList.add('vmo-voice-sync-active');
        el.textContent = '';

        let index = 0;
        const startedAt = (window.performance && performance.now) ? performance.now() : Date.now();
        const estimateDurationMs = () => {
            const duration = audio && Number.isFinite(audio.duration) && audio.duration > 0.5 ? audio.duration * 1000 : 0;
            if (duration) return Math.max(1200, Math.min(90000, duration * 0.96));
            // Natural voice average: roughly 2.8–3.5 words/sec depending on punctuation.
            return Math.max(1600, Math.min(90000, pieces.length * 315));
        };

        const tick = () => {
            try {
                if (!el.isConnected) return;
                const now = (window.performance && performance.now) ? performance.now() : Date.now();
                const elapsed = Math.max(1, now - startedAt);
                const durationMs = estimateDurationMs();
                let target = Math.ceil((elapsed / durationMs) * pieces.length);
                if (audio && Number.isFinite(audio.duration) && audio.duration > 0.5 && Number.isFinite(audio.currentTime)) {
                    target = Math.ceil((audio.currentTime / audio.duration) * pieces.length);
                }
                target = Math.max(index + 1, Math.min(pieces.length, target));
                while (index < target) {
                    el.textContent += pieces[index++];
                }
                if (this.chatHistory) this.chatHistory.scrollTop = this.chatHistory.scrollHeight;

                if (index < pieces.length && audio && !audio.ended) {
                    el._vmoVoiceSyncTimer = setTimeout(tick, 45);
                } else {
                    this._finishVoiceSyncedReveal(el, fullText);
                }
            } catch (_) {
                this._finishVoiceSyncedReveal(el, fullText);
            }
        };

        try {
            audio.addEventListener('ended', () => this._finishVoiceSyncedReveal(el, fullText), { once: true });
            audio.addEventListener('error', () => this._finishVoiceSyncedReveal(el, fullText), { once: true });
        } catch (_) {}
        tick();
    }

    _finishVoiceSyncedReveal(el, text) {
        if (!el) return;
        try { if (el._vmoVoiceSyncTimer) clearTimeout(el._vmoVoiceSyncTimer); } catch (_) {}
        el._vmoVoiceSyncTimer = null;
        el.classList.remove('vmo-voice-sync-pending');
        el.classList.remove('vmo-voice-sync-active');
        el.textContent = String(text || '');
        if (this.chatHistory) this.chatHistory.scrollTop = this.chatHistory.scrollHeight;
    }

    updateStatus(text) {
        if (this.statusText) {
            this.statusText.textContent = text;
        }
    }
    
    // Phase helper: listening | thinking | speaking
    setModalPhase(phase = 'listening') {
        if (!this.listeningModal) return;
        this.listeningModal.classList.remove('listening', 'thinking', 'speaking');
        this.listeningModal.classList.add(phase);
        if (this.listeningText) {
            if (phase === 'speaking') this.listeningText.textContent = 'Speaking...';
            else if (phase === 'thinking') this.listeningText.textContent = 'Processing...';
            else this.listeningText.textContent = 'Listening...';
        }
    }

    showListeningModal(phaseOrTranscript = '') {
        if (this.listeningModal) {
            // Ensure modal is at top-level to avoid stacking-context traps
            try {
                if (document.body && this.listeningModal.parentElement !== document.body) {
                    document.body.appendChild(this.listeningModal);
                }
            } catch(_) {}
            this.listeningModal.style.display = 'flex';
            // Mark ownership to Jarvis by default
            this.listeningModal.dataset.owner = 'voice-panel';
            // Hide consultation-only upload affordance when Jarvis owns modal
            const uploadBtn = document.getElementById('vmo-listening-upload');
            if (uploadBtn) uploadBtn.style.display = 'none';
            // Accept either a phase string or a transcript string
            const validPhase = ['listening','thinking','speaking'];
            if (typeof phaseOrTranscript === 'string' && validPhase.includes(phaseOrTranscript)) {
                this.setModalPhase(phaseOrTranscript);
            } else {
                // Treat as transcript text and default to listening phase
                this.setModalPhase('listening');
                if (this.listeningTranscript) {
                    this.listeningTranscript.textContent = phaseOrTranscript || '';
                }
            }
        }
    }
    
    hideListeningModal() {
        if (this.listeningModal) {
            this.listeningModal.style.display = 'none';
            if (this.listeningTranscript) {
                this.listeningTranscript.textContent = '';
            }
            // Clear ownership when hidden
            try { delete this.listeningModal.dataset.owner; } catch(_) {}
        }
    }

    /**
     * ✅ LIVE TRANSCRIPT: Update the transcript display in real-time
     * Shows what the user or AI is currently saying in the listening modal
     * @param {string} text - The text to display
     * @param {string} speaker - 'user' or 'ai' to style accordingly
     */
    updateLiveTranscript(text, speaker = 'user') {
        if (!this.listeningTranscript) {
            this.listeningTranscript = document.getElementById('vmo-listening-transcript');
        }
        
        if (this.listeningTranscript && text) {
            // Style based on speaker
            const prefix = speaker === 'ai' ? '🤖 ' : '🎤 ';
            this.listeningTranscript.textContent = prefix + text;
            this.listeningTranscript.style.opacity = '1';
            
            // Add visual distinction for AI vs user
            if (speaker === 'ai') {
                this.listeningTranscript.style.color = '#4CAF50'; // Green for AI
                this.listeningTranscript.style.fontStyle = 'normal';
            } else {
                this.listeningTranscript.style.color = '#fff'; // White for user
                this.listeningTranscript.style.fontStyle = 'italic';
            }
        }
    }

    /**
     * ✅ LIVE TRANSCRIPT: Clear the transcript display
     * Called when speech/message is complete
     */
    clearLiveTranscript() {
        if (!this.listeningTranscript) {
            this.listeningTranscript = document.getElementById('vmo-listening-transcript');
        }
        
        if (this.listeningTranscript) {
            this.listeningTranscript.textContent = '';
            this.listeningTranscript.style.opacity = '0';
        }
        
        // Reset buffers
        this._transcriptBuffer = '';
        this._interimText = '';
    }

    endVoiceSession(status = 'Voice chat stopped', broadcastStop = true) {
        // HARD PRIVACY SHUTDOWN. End Chat must immediately stop speaking,
        // stop listening, mute the mic, clear timers, and invalidate every stale
        // Realtime/default callback so Jarvis cannot keep talking after close.
        this._voiceHardStopped = true;
        this._voiceSessionSerial++;
        this._autoRestartEnabled = false;
        this.isActive = false;
        this.isListening = false;
        this.isSpeaking = false;
        this.isProcessing = false;

        // Global hard-stop for any detached/stale WebRTC instance, including one
        // created by an older panel object in the same tab. End Chat must be final.
        try { window.dispatchEvent(new CustomEvent('vmo:voice-force-stop', { detail: { source: 'voice-panel-end' } })); } catch (_) {}
        try {
            if (window.VMORealtimeSession && typeof window.VMORealtimeSession.stopAll === 'function') {
                window.VMORealtimeSession.stopAll();
            }
        } catch (_) {}

        if (broadcastStop) {
            try { localStorage.setItem('vmoJarvisVoiceHardStopAt', String(Date.now())); } catch(_) {}
        }

        if (this._streamController) {
            try { this._streamController.abort(); } catch(_) {}
            this._streamController = null;
        }
        if (this._silenceTimer) { try { clearTimeout(this._silenceTimer); } catch(_) {} this._silenceTimer = null; }
        if (this._speakingWatchdog) { try { clearTimeout(this._speakingWatchdog); } catch(_) {} this._speakingWatchdog = null; }
        if (this._voiceRestartTimer) { try { clearTimeout(this._voiceRestartTimer); } catch(_) {} this._voiceRestartTimer = null; }

        // Stop Realtime first, including the shared session if this panel somehow
        // lost its local pointer. This kills mic tracks, remote tracks, data channel,
        // peer connection, audio sink, and billing heartbeat.
        this._stopRealtimeSession(true);
        try {
            if (window.VMORealtimeSession && window.VMORealtimeSession.shared) {
                window.VMORealtimeSession.shared.stop();
                window.VMORealtimeSession.shared = null;
            }
        } catch(_) {}

        // Stop fallback recognition and all pending Web Speech callbacks.
        if (this.recognition) {
            try { this.recognition.onresult = this.recognition.onresult; } catch(_) {}
            try { this.recognition.abort(); } catch(_) {}
            try { this.recognition.stop(); } catch(_) {}
        }
        this.isListening = false;

        // Stop every non-Realtime audio path immediately.
        if (this.currentAudio) {
            try { this.currentAudio._vmoIntentionalStop = true; } catch(_) {}
            try { this.currentAudio.onended = null; this.currentAudio.onerror = null; } catch(_) {}
            try { this.currentAudio.pause(); } catch(_) {}
            try { this.currentAudio.currentTime = 0; } catch(_) {}
            try { this.currentAudio.removeAttribute('src'); } catch(_) {}
            try { this.currentAudio.load && this.currentAudio.load(); } catch(_) {}
            this.currentAudio = null;
        }
        try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch(_) {}
        try {
            if (window.vmoCurrentAudio) {
                try { window.vmoCurrentAudio.pause(); } catch(_) {}
                try { window.vmoCurrentAudio.removeAttribute && window.vmoCurrentAudio.removeAttribute('src'); } catch(_) {}
                window.vmoCurrentAudio = null;
            }
        } catch(_) {}
        try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.stopAllTTS && window.VMOAudioCoordinator.stopAllTTS('end-voice-session', 'voice-panel'); } catch(_) {}
        try {
            document.querySelectorAll('audio').forEach(audio => {
                try { audio.pause(); } catch(_) {}
                try { audio.muted = true; } catch(_) {}
                try {
                    const src = audio.srcObject;
                    if (src && src.getTracks) src.getTracks().forEach(t => { try { t.stop(); } catch(_) {} });
                    audio.srcObject = null;
                } catch(_) {}
                try { audio.removeAttribute('src'); } catch(_) {}
                try { audio.load && audio.load(); } catch(_) {}
            });
        } catch(_) {}

        this._transcriptBuffer = '';
        this._interimText = '';
        this._partialAIResponse = '';
        this.clearLiveTranscript();

        // End Chat means muted until the next explicit Start Voice click.
        window.vmoMicMuted = true;
        this.isMicMuted = true;
        this.applyMute();

        this.updateStatus(status);
        if (this.startBtn) {
            this.startBtn.innerHTML = '<span class="vmo-voice-start-icon">🎤</span><span class="vmo-voice-start-text">Start Voice</span>';
        }
        this.hideListeningModal();
        try { if (window.__vmoVoicePanelInstance === this) window.__vmoVoicePanelInstance = this; } catch (_) {}
        try { window.VMOAudioCoordinator && window.VMOAudioCoordinator.releaseMic('voice-panel'); } catch(_) {}
        try { window.dispatchEvent(new CustomEvent('vmo:voice-ended', { detail: { source: 'voice-panel' } })); } catch(_) {}
    }

    openSettingsPopover() {
        let pop = document.getElementById('vmo-voice-settings-popover');
        if (pop) { pop.remove(); return; }

        pop = document.createElement('div');
        pop.id = 'vmo-voice-settings-popover';
        pop.className = 'vmo-voice-settings-popover';

        const btnRect = this.settingsBtn?.getBoundingClientRect();
        const x = btnRect ? btnRect.right : (window.innerWidth - 340);
        const y = btnRect ? btnRect.bottom + 8 : 80;
        pop.style.left = Math.max(12, Math.min(x - 340, window.innerWidth - 360)) + 'px';
        pop.style.top = Math.min(window.innerHeight - 420, Math.max(12, y)) + 'px';

        const currentVoice = window.vmoSelectedVoice || (function(){ try { return localStorage.getItem('vmoJarvisVoice'); } catch(_) { return null; } })() || 'ash';
        const currentMode = this.getVoiceMode();
        const premiumAvailable = global_VMORealtime_isUsable() && !this._realtimeDisabled;

        pop.innerHTML = `
          <div class="vmo-voice-settings-head">
            <div>
              <strong>Jarvis Voice Settings</strong>
              <div class="vmo-voice-settings-subtitle">Choose voice quality and response speed.</div>
            </div>
            <button id="vmo-voice-settings-close" class="vmo-voice-settings-close" type="button" aria-label="Close">✕</button>
          </div>

          <div class="vmo-voice-settings-section">
            <div class="vmo-voice-settings-label">Chat voice mode</div>
            <label class="vmo-voice-mode-option ${currentMode === 'default' ? 'selected' : ''}">
              <input type="radio" name="vmo-voice-mode" value="default" ${currentMode === 'default' ? 'checked' : ''}>
              <span>
                <strong>Default Chat Voice</strong>
                <small>Reliable voice replies using the current VMO/OpenAI TTS path. Best for everyday use and lower cost.</small>
              </span>
            </label>
            <label class="vmo-voice-mode-option ${currentMode === 'premium' ? 'selected' : ''}">
              <input type="radio" name="vmo-voice-mode" value="premium" ${currentMode === 'premium' ? 'checked' : ''}>
              <span>
                <strong>Premium Live Voice</strong>
                <small>Instant Realtime/WebRTC voice with natural interruption. Uses more VMO Credits. ${premiumAvailable ? 'Available on this browser.' : 'Will fall back if Realtime is not available yet.'}</small>
              </span>
            </label>
          </div>

          <div class="vmo-voice-settings-section">
            <div class="vmo-voice-settings-label">Preferred Jarvis voice</div>
            <div id="vmo-voice-settings-list" class="vmo-voice-settings-list">
              <div style="color:#aaa;">Loading voices…</div>
            </div>
          </div>

          <div class="vmo-voice-settings-actions">
            <button id="vmo-voice-save" class="vmo-voice-save" type="button">Save</button>
          </div>
        `;
        document.body.appendChild(pop);

        const close = () => { try { pop.remove(); } catch(_) {} };
        pop.querySelector('#vmo-voice-settings-close')?.addEventListener('click', close);

        pop.querySelectorAll('input[name="vmo-voice-mode"]').forEach(input => {
            input.addEventListener('change', () => {
                pop.querySelectorAll('.vmo-voice-mode-option').forEach(row => row.classList.remove('selected'));
                input.closest('.vmo-voice-mode-option')?.classList.add('selected');
            });
        });

        const listEl = pop.querySelector('#vmo-voice-settings-list');
        const renderList = (voices) => {
            if (!voices || !voices.length) {
                listEl.innerHTML = '<div style="color:#aaa;">No voices available.</div>';
                return;
            }
            listEl.innerHTML = '';
            voices.forEach(v => {
                const row = document.createElement('label');
                row.className = 'vmo-voice-choice-row';
                const input = document.createElement('input');
                input.type = 'radio';
                input.name = 'vmo-voice-choice';
                input.value = v.id;
                if (v.id === currentVoice) input.checked = true;
                const span = document.createElement('span');
                span.textContent = `${v.name} (${v.id})`;
                row.appendChild(input);
                row.appendChild(span);
                listEl.appendChild(row);
            });
        };

        (async () => {
            try {
                const voices = (typeof window.vmoGetVoices === 'function') ? await window.vmoGetVoices() : [];
                renderList(voices);
            } catch (e) {
                listEl.innerHTML = '<div style="color:#f99;">Failed to load voices.</div>';
            }
        })();

        pop.querySelector('#vmo-voice-save')?.addEventListener('click', () => {
            const sel = pop.querySelector('input[name="vmo-voice-choice"]:checked');
            const val = sel?.value || 'ash';
            window.vmoSelectedVoice = val;
            try { localStorage.setItem('vmoJarvisVoice', val); } catch(_) {}

            const selectedMode = pop.querySelector('input[name="vmo-voice-mode"]:checked')?.value || 'default';
            const previousMode = this.getVoiceMode();
            const savedMode = this.setVoiceMode(selectedMode);
            this._realtimeDisabled = false; // allow another attempt after user explicitly saves Premium.

            this.addMessage(`Jarvis voice set to ${val}. Chat voice mode: ${this.getVoiceModeLabel(savedMode)}.`, 'system');
            close();
            this.switchActiveVoiceSessionToSelectedMode(previousMode);
        });
    }

    enableDrag() {
        const header = this.panel?.querySelector('.vmo-voice-header');
        const panel = this.panel;
        if (!header || !panel) return;
        let dragging = false;
        let startX = 0, startY = 0;
        let origLeft = 0, origTop = 0;
        const onMouseMove = (e) => {
            if (!dragging) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            let left = origLeft + dx;
            let top = origTop + dy;
            // Clamp within viewport
            left = Math.min(window.innerWidth - panel.offsetWidth, Math.max(0, left));
            top = Math.min(window.innerHeight - panel.offsetHeight, Math.max(0, top));
            panel.style.left = left + 'px';
            panel.style.top = top + 'px';
            panel.style.bottom = 'auto';
        };
        const onMouseUp = () => {
            dragging = false;
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
        };
        header.addEventListener('mousedown', (e) => {
            // Ignore clicks on buttons in the header (collapse/settings)
            if (e.target.closest('button')) return;
            dragging = true;
            const rect = panel.getBoundingClientRect();
            startX = e.clientX;
            startY = e.clientY;
            // Compute current left/top from rect
            origLeft = rect.left;
            origTop = rect.top;
            // Ensure fixed positioning
            panel.style.position = 'fixed';
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });
    }


    // VMO_PREMIUM_VOICE_PARITY_CONTEXT_v1
    buildPremiumVoiceRecentMessages(limit = 34) {
        const src = Array.isArray(this.messages) ? this.messages : [];
        const out = [];
        for (const msg of src) {
            if (!msg) continue;
            const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '');
            if (!text || !text.trim()) continue;
            const sender = (msg.sender === 'user' || msg.role === 'user') ? 'user' : 'assistant';
            out.push({ sender, text: text.trim() });
        }
        return out.slice(Math.max(0, out.length - limit));
    }

    buildPremiumVoiceMemoryContext() {
        const recent = this.buildPremiumVoiceRecentMessages(34);
        const username = (window.vmoCurrentUserName || window.vmoDisplayName || window.vmoQuickLaunchUsername || '').toString().trim();
        const lines = [];
        lines.push('Jarvis user display name: ' + (username || 'the logged-in VMO user'));
        lines.push('Current screen: VMO dashboard Jarvis chat.');
        lines.push('Important product rule: Premium Live Voice is the same Jarvis assistant as Default Chat Voice; it must retain the same context/capabilities and must not deny memory because the user switched voice mode.');
        lines.push('Recent conversation, oldest to newest:');
        if (!recent.length) {
            lines.push('(No recent chat rows were loaded into the browser yet. Use VMO tools/history when needed instead of claiming there is no memory.)');
        } else {
            recent.forEach((m, idx) => {
                const label = m.sender === 'user' ? 'User' : 'Jarvis';
                let text = String(m.text || '').replace(/\s+/g, ' ').trim();
                if (text.length > 900) text = text.slice(0, 900) + '…';
                lines.push((idx + 1) + '. ' + label + ': ' + text);
            });
        }
        return lines.join('\n');
    }

    persistRealtimeTurn(text, sender) {
        const content = String(text || '').trim();
        if (!content || sender === 'system') return;
        try {
            const ax = window.vmo_ajax || window.vmoAjax || {};
            const ajaxurl = ax.ajaxurl || '/wp-admin/admin-ajax.php';
            const nonce = ax.nonce || window.vmo_builder_nonce || window.vmoBuilderNonce || '';
            const body = new URLSearchParams();
            body.set('action', 'vmo_realtime_save_turn');
            body.set('chat_type', 'jarvis');
            body.set('role', sender === 'user' ? 'user' : 'assistant');
            body.set('content', content);
            body.set('security', nonce);
            body.set('nonce', nonce);
            fetch(ajaxurl, {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
                body: body.toString()
            }).catch(() => {});
        } catch (_) {}
    }

    showRealtimeUserCaption(text, final = false) {
        const value = String(text || '').trim();
        if (!value) return;
        if (this._realtimeUserCaptionTimer) {
            try { clearTimeout(this._realtimeUserCaptionTimer); } catch (_) {}
            this._realtimeUserCaptionTimer = null;
        }
        this.updateLiveTranscript(value, 'user');
        // Do not clear final text immediately. Premium Realtime often emits only
        // final transcription events, so immediate clearing makes it look like the
        // user's words never appeared in the full-screen voice interface.
        if (final) {
            this._realtimeUserCaptionTimer = setTimeout(() => {
                if (!this.isSpeaking && !this.isProcessing) this.clearLiveTranscript();
            }, 2200);
        }
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // REALTIME (WebRTC) VOICE PATH — preferred over Web Speech + TTS
    // Native server-side VAD + barge-in. See assets/vmo-realtime.js.
    // ═══════════════════════════════════════════════════════════════════════════

    startPremiumRealtimeTranscriptFallback(sess, sessionSerial) {
        // VMO_PREMIUM_WEBRTC_TEXT_TURN_FALLBACK_v1
        // Some browsers/origins establish WebRTC and the Realtime data channel,
        // but server VAD can miss the final speech_stopped event. Use Web Speech
        // ONLY as a transcript trigger, then send the text into the same Realtime
        // session so the assistant response still comes back through Premium
        // WebRTC audio — not the slower Default/TTS path.
        try {
            const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
            if (!SR || !sess) return;
            if (this._premiumRealtimeRecognition) {
                try { this._premiumRealtimeRecognition.abort(); } catch (_) {}
                try { this._premiumRealtimeRecognition.stop(); } catch (_) {}
                this._premiumRealtimeRecognition = null;
            }
            const rec = new SR();
            this._premiumRealtimeRecognition = rec;
            rec.lang = this.defaultLang || navigator.language || 'en-US';
            rec.continuous = true;
            rec.interimResults = true;
            let interim = '';
            let lastFinalAt = 0;
            let interimSendTimer = null;
            let lastSentText = '';
            const sendPremiumTextTurn = (spoken, reason) => {
                spoken = String(spoken || '').replace(/\s+/g, ' ').trim();
                if (!spoken || !sess || !stillCurrent()) return;
                if (spoken === lastSentText && Date.now() - lastFinalAt < 1600) return;
                if (sess.status === 'speaking' || sess.status === 'thinking' || sess.activeResponseId || sess._responsePending) return;
                lastFinalAt = Date.now();
                lastSentText = spoken;
                this.showRealtimeUserCaption(spoken, true);
                if (typeof sess.sendTextTurn === 'function') {
                    this.addMessage(spoken, 'user');
                    this.persistRealtimeTurn(spoken, 'user');
                    const ok = sess.sendTextTurn(spoken, reason || 'webspeech-premium-bridge');
                    if (!ok) this.updateStatus('Premium voice is reconnecting…');
                }
            };
            const scheduleInterimSend = () => {
                if (interimSendTimer) { try { clearTimeout(interimSendTimer); } catch (_) {} }
                interimSendTimer = setTimeout(() => {
                    if (!stillCurrent()) return;
                    const candidate = String(interim || '').trim();
                    if (candidate) sendPremiumTextTurn(candidate, 'webspeech-interim-silence');
                }, 950);
            };
            const stillCurrent = () => this._realtimeSession === sess && this.isActive && !this._voiceHardStopped && this._voiceSessionSerial === sessionSerial;
            rec.onresult = (event) => {
                if (!stillCurrent()) return;
                let finalText = '';
                for (let i = event.resultIndex; i < event.results.length; i++) {
                    const res = event.results[i];
                    const text = (res && res[0] && res[0].transcript ? res[0].transcript : '').trim();
                    if (!text) continue;
                    if (res.isFinal) finalText += (finalText ? ' ' : '') + text;
                    else interim = text;
                }
                if (interim) { this.showRealtimeUserCaption(interim, false); scheduleInterimSend(); }
                if (finalText) {
                    const spoken = finalText.trim();
                    this.showRealtimeUserCaption(spoken, true);
                    const now = Date.now();
                    if (now - lastFinalAt < 700) return;
                    lastFinalAt = now;
                    setTimeout(() => {
                        if (!stillCurrent()) return;
                        sendPremiumTextTurn(spoken, 'webspeech-final-vad-missed');
                    }, 250);
                }
            };
            rec.onerror = (e) => {
                if (!stillCurrent()) return;
                const err = e && e.error ? String(e.error) : '';
                if (err && err !== 'no-speech' && err !== 'aborted') {
                    console.warn('[VMOVoicePanel] Premium transcript fallback error', e);
                }
            };
            rec.onend = () => {
                if (!stillCurrent()) return;
                setTimeout(() => {
                    if (!stillCurrent()) return;
                    try { rec.start(); } catch (_) {}
                }, 350);
            };
            try { rec.start(); } catch (_) {}
        } catch (e) {
            console.warn('[VMOVoicePanel] Premium transcript fallback could not start', e);
        }
    }

    stopPremiumRealtimeTranscriptFallback() {
        try { if (this._premiumInterimSendTimer) { clearTimeout(this._premiumInterimSendTimer); this._premiumInterimSendTimer = null; } } catch (_) {}
        if (this._premiumRealtimeRecognition) {
            try { this._premiumRealtimeRecognition.abort(); } catch (_) {}
            try { this._premiumRealtimeRecognition.stop(); } catch (_) {}
            this._premiumRealtimeRecognition = null;
        }
    }

    _startRealtimeSession() {
        if (!this.isSecureVoiceContext()) {
            this.handleVoiceUnavailable(
                'voice-insecure',
                'Secure HTTPS required',
                'Voice input is available only from your secure HTTPS domain. QuickLaunch should keep you in onboarding until that secure domain is ready, then open Jarvis there automatically.'
            );
            return;
        }
        if (this._realtimeSession) return;
        try {
            if (window.VMORealtimeSession && typeof window.VMORealtimeSession.stopAll === 'function') {
                window.VMORealtimeSession.stopAll();
            }
        } catch (_) {}
        try {
            const sessionSerial = ++this._voiceSessionSerial;
            this._voiceHardStopped = false;
            this.isActive = true;
            this._autoRestartEnabled = false;            // Realtime owns the loop
            this.updateStatus('Connecting to Jarvis…');
            if (this.startBtn) {
                this.startBtn.innerHTML = '<span class="vmo-voice-start-icon">⏸️</span><span class="vmo-voice-start-text">Stop Voice</span>';
            }
            try { if (this.recognition) { this.recognition.abort && this.recognition.abort(); this.recognition.stop && this.recognition.stop(); } } catch (_) {}
            this.isListening = false;
            this.stopPremiumRealtimeTranscriptFallback();
            this.addMessage('Premium Live Voice started. Speak naturally — you can interrupt anytime.', 'system');
            this.showListeningModal('listening');

            const voice = window.vmoSelectedVoice || (function(){ try { return localStorage.getItem('vmoJarvisVoice'); } catch(_) { return null; } })() || 'ash';
            const premiumMemoryContext = this.buildPremiumVoiceMemoryContext();
            const premiumRecentMessages = this.buildPremiumVoiceRecentMessages(34);
            const sess = new window.VMORealtimeSession({
                context: 'jarvis',
                voice: voice,
                muted: !!window.vmoMicMuted,
                memoryContext: premiumMemoryContext,
                recentMessages: premiumRecentMessages
            });
            this._realtimeSession = sess;
            window.VMORealtimeSession.shared = sess;
            const isCurrentRealtime = () => this._realtimeSession === sess && this.isActive && !this._voiceHardStopped && this._voiceSessionSerial === sessionSerial;

            let aiBubble = null;
            sess.on('status', (s) => {
                if (!isCurrentRealtime()) return;
                if (s === 'live')      { this.updateStatus(window.vmoMicMuted ? 'Microphone muted' : 'Listening…'); this.isSpeaking = false; }
                else if (s === 'thinking')  { this.updateStatus('Jarvis is thinking…'); this.isSpeaking = false; }
                else if (s === 'speaking')  { this.updateStatus('Jarvis is speaking…'); this.isSpeaking = true; }
                else if (s === 'listening') { this.updateStatus(window.vmoMicMuted ? 'Microphone muted' : 'Listening…'); this.isSpeaking = false; }
                else if (s === 'ended')     { this.updateStatus('Voice chat stopped'); }
                else if (s === 'error')     { this.updateStatus('Voice error'); }
            });
            sess.on('ready', () => {
                if (!isCurrentRealtime()) return;
                if (window.vmoMicMuted) this.setMicMuted(true, { restartFallback: false });
                else this.applyMute();
                this.startPremiumRealtimeTranscriptFallback(sess, sessionSerial);
            });
            sess.on('barge_in', () => {
                if (!isCurrentRealtime()) return;
                // User cut Jarvis off — close any in-flight assistant bubble
                aiBubble = null;
                this.isSpeaking = false;
            });
            sess.on('transcript.user', ({ text, final }) => {
                if (!isCurrentRealtime()) return;
                this.showRealtimeUserCaption(text, !!final);
                if (final && text && text.trim()) {
                    const spoken = text.trim();
                    this.addMessage(spoken, 'user');
                    this.persistRealtimeTurn(spoken, 'user');
                }
            });
            sess.on('transcript.assistant', ({ text, final }) => {
                if (!isCurrentRealtime()) return;
                if (!aiBubble) aiBubble = this._addStreamBubble();
                if (aiBubble) aiBubble.textContent = text;
                if (text && text.trim()) {
                    this.updateLiveTranscript(text, 'ai');
                }
                if (final) {
                    if (text && text.trim()) {
                        const answer = text.trim();
                        this.messages.push({ text: answer, sender: 'ai', time: new Date() });
                        this.persistRealtimeTurn(answer, 'ai');
                    }
                    aiBubble = null;
                }
            });
            sess.on('error', (err) => {
                if (!isCurrentRealtime()) return;
                const errorMessage = err && err.message ? String(err.message) : String(err || '');
                if (/Cancellation failed/i.test(errorMessage) && /no active response/i.test(errorMessage)) {
                    console.warn('[VMOVoicePanel] Ignoring benign Realtime cancel warning; staying in Premium WebRTC mode', err);
                    this.updateStatus(window.vmoMicMuted ? 'Microphone muted' : 'Listening…');
                    return;
                }
                console.warn('[VMOVoicePanel] Realtime error', err);
                const errorName = err && err.name ? String(err.name) : '';
                this._realtimeDisabled = (errorName === 'NotAllowedError' || errorName === 'SecurityError');
                this._realtimeSession = null;
                window.VMORealtimeSession.shared = null;
                this.isActive = false;
                if (errorName === 'NotAllowedError' || errorName === 'SecurityError') {
                    this.handleVoiceUnavailable(
                        errorName === 'SecurityError' ? 'voice-insecure' : 'mic-permission',
                        errorName === 'SecurityError' ? 'Secure HTTPS required' : 'Mic permission blocked',
                        errorName === 'SecurityError'
                            ? 'Voice input is available only from your secure HTTPS domain. QuickLaunch should keep you in onboarding until that secure domain is ready, then open Jarvis there automatically.'
                            : 'Microphone permission was blocked. Click the microphone permission prompt and choose Allow. If the browser already blocked it, use the site permission icon in the address bar, allow microphone access, then reload.'
                    );
                    return;
                }
                // Premium must not silently degrade into the slower Default Chat Voice.
                // If Realtime cannot connect, fail fast and let the user retry Premium
                // or explicitly switch to Default in the gear menu.
                this.addMessage('Premium Live Voice could not connect yet. Please try Start Voice again, or switch to Default Chat Voice from the gear menu.', 'system');
                this.updateStatus('Premium voice unavailable');
                if (this.startBtn) {
                    this.startBtn.innerHTML = '<span class="vmo-voice-start-icon">🎤</span><span class="vmo-voice-start-text">Start Voice</span>';
                }
            });

            sess.start().catch(() => { /* error handler above already fired */ });
        } catch (e) {
            console.warn('[VMOVoicePanel] Realtime start threw', e);
            this._realtimeDisabled = (e && (e.name === 'NotAllowedError' || e.name === 'SecurityError'));
            this._realtimeSession = null;
            this.isActive = false;
            this.updateStatus('Premium voice unavailable');
            this.addMessage('Premium Live Voice could not start. Please try again, or switch to Default Chat Voice from the gear menu.', 'system');
            if (this.startBtn) {
                this.startBtn.innerHTML = '<span class="vmo-voice-start-icon">🎤</span><span class="vmo-voice-start-text">Start Voice</span>';
            }
        }
    }

    _stopRealtimeSession(hard = false) {
        this.stopPremiumRealtimeTranscriptFallback();
        const sess = this._realtimeSession;
        if (!sess) return;
        try {
            if (hard && typeof sess.mute === 'function') sess.mute();
            if (typeof sess.stop === 'function') sess.stop();
        } catch (_) {}
        if (window.VMORealtimeSession && window.VMORealtimeSession.shared === sess) {
            window.VMORealtimeSession.shared = null;
        }
        this._realtimeSession = null;
    }
}

// Helper used by VMOVoicePanel to decide whether the Realtime path is available.
function global_VMORealtime_isUsable() {
    try {
        const host = (window.location && window.location.hostname ? window.location.hostname : '').toLowerCase();
        if (!window.isSecureContext && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
            return false;
        }
    } catch (_) {
        return false;
    }
    return !!(window.VMORealtimeSession &&
              typeof window.VMORealtimeSession.isSupported === 'function' &&
              window.VMORealtimeSession.isSupported());
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    if (document.getElementById('vmo-ai-voice')) {
        if (window.vmoVoicePanel && window.vmoVoicePanel instanceof VMOVoicePanel) {
            console.warn('⚠️ VMO Voice Panel already initialized; skipping duplicate init');
            return;
        }
        window.vmoVoicePanel = new VMOVoicePanel();
        console.log('✅ VMO Voice Panel initialized');
    }
});

// Close listening modal on click
document.addEventListener('click', (e) => {
    const modal = document.getElementById('vmo-listening-modal');
    if (modal && e.target === modal) {
        if (window.vmoVoicePanel) {
            // During an active session, ignore outside clicks to prevent accidental close
            if (!window.vmoVoicePanel.isActive) window.vmoVoicePanel.hideListeningModal();
        }
    }
});
