/**
 * SiteStaffr Core - Realtime Voice Integration (WebRTC)
 *
 * This core class handles realtime voice sessions via WebRTC:
 * - WebRTC peer connection to voice service (automatic audio handling)
 * - Session establishment via middleware (unified interface)
 * - Native audio via MediaStream (no manual encoding/decoding)
 * - DataChannel for events (transcripts, conversation flow)
 * - Automatic interruption handling (server-side)
 * - Transcript tracking from delta events
 * - Session management
 * - Conversation end detection
 *
 * Widget-specific UI is handled by wrapper classes:
 * - SiteStaffrWidget (fixed floating widget)
 * - SiteStaffrButtonWidget (inline button widget)
 *
 * @package    SiteStaffr
 * @since      0.13.0 (Realtime API Migration)
 * @since      0.13.18 (WebRTC Migration - automatic interruptions)
 * @since      0.13.20 (Unified interface - SDP through middleware)
 * @since      0.13.49 (Transcript saving to WordPress database)
 */

(function(window) {
	'use strict';

	// GTM dataLayer helpers — shared across widget, button, core, and text-chat JS.
	window._sitestaffrGTM = window._sitestaffrGTM || {
		pageContext: function () {
			return { sitestaffr_page_url: window.location.href, sitestaffr_page_title: document.title || '' };
		}
	};

	// Test-mode support ("Test your widget" admin action). When the admin-minted
	// token rides the URL, sessions get flagged is_test_call server-side so they
	// never appear in stats, leads, or notifications. Shared by voice + text chat.
	window._sitestaffrTestMode = window._sitestaffrTestMode || (function () {
		var token = null;
		try {
			token = new URLSearchParams(window.location.search).get('sitestaffr_test');
		} catch (e) {
			token = null;
		}
		var flagged = {};

		if (token) {
			var addChip = function () {
				var chip = document.createElement('div');
				chip.className = 'sitestaffr-test-chip';
				chip.textContent = 'Test mode — this conversation won\'t appear in your stats';
				document.body.appendChild(chip);
			};
			if (document.readyState === 'loading') {
				document.addEventListener('DOMContentLoaded', addChip);
			} else {
				addChip();
			}
		}

		return {
			active: function () { return !!token; },
			flag: function (sessionId, ajaxUrl) {
				if (!token || !sessionId || !ajaxUrl || flagged[sessionId]) {
					return;
				}
				flagged[sessionId] = true;
				var body = new FormData();
				body.append('action', 'sitestaffr_flag_test_session');
				body.append('session_id', sessionId);
				body.append('token', token);
				fetch(ajaxUrl, { method: 'POST', body: body }).catch(function () { /* non-fatal: conversation works, just unflagged */ });
			}
		};
	})();

	// Skip retrieval for questions answerable from the system prompt.
	var PAGE_SCOPED_SKIP_PATTERNS = [
		/\b(office hours|business hours|when .*(open|close))\b/i,
		/\b(your (phone|email|address|location)|how (can|do) (i|we) (contact|reach) you|where are you (located|based))\b/i,
		/\b(what (procedures|services)|what do you (do|offer))\b/i,
		/^[\s]*(hello|hi|hey|good (morning|afternoon|evening))[.!?,\s]*$/i,
		/^[\s]*(thank( you)?|thanks|bye|goodbye)[.!?,\s]*$/i
	];

	/**
 * SiteStaffr Core Class - Realtime Voice API
	 *
	 * @param {Object} config - Configuration object
	 * @param {string} config.siteId - WordPress site ID
	 * @param {string} config.ajaxUrl - WordPress AJAX URL for token generation
	 * @param {string} config.middlewareUrl - Middleware base URL (HTTP/HTTPS)
	 * @param {string} config.pluginUrl - WordPress plugin URL for assets
	 * @param {Object} callbacks - UI callback functions
	 * @param {Function} callbacks.onStateChange - Called when state changes (state, oldState)
	 * @param {Function} callbacks.onError - Called on error (message)
	 * @param {Function} callbacks.onConversationStart - Called when conversation starts
	 * @param {Function} callbacks.onConversationEnd - Called when conversation ends (reason)
	 */
	class SiteStaffrCore {
		constructor(config, callbacks) {
			// Configuration
			this.config = {
				siteId: config.siteId || '',
				ajaxUrl: config.ajaxUrl || '',
				middlewareUrl: config.middlewareUrl || '',
				pluginUrl: config.pluginUrl || '',
				channelSource: config.channelSource || 'widget',
				persona: config.persona || '',
			};

			// Auth token (fetched dynamically when needed)
			this.authToken = null;

			// UI Callbacks
			this.callbacks = {
				onStateChange: callbacks.onStateChange || (() => {}),
				onError: callbacks.onError || (() => {}),
				onConversationStart: callbacks.onConversationStart || (() => {}),
				onConversationEnd: callbacks.onConversationEnd || (() => {}),
				onFieldUpdate: callbacks.onFieldUpdate || (() => {}),
				onMicDenied: callbacks.onMicDenied || (() => {}),
				onMinutesExhausted: callbacks.onMinutesExhausted || (() => {}),
			};

			// State management
			this.state = 'idle'; // idle, connecting, listening, thinking, speaking, error
			this.inConversation = false;
			this.greetingComplete = false; // Track when first AI response (greeting) is done
			this.initialGreetingInProgress = false; // Prevent UI flicker before first greeting settles
			this.sessionId = null;
			this.conversationStartTime = null; // Track when conversation started (for duration calculation)

			// WebRTC connection
			this.peerConnection = null;
			this.dataChannel = null;
			this.audioElement = null;
			this.mediaStream = null;
			this.sessionInstructions = null; // System prompt from middleware
			this.sessionModes = null;        // { greeting, answering, collecting, farewell } lifecycle mode prompts
			this.toolsByMode = null;         // { greeting: [], answering: [...], ... }
			this.lifecycleResponses = null;  // { begin_contact_collection, begin_answering, begin_farewell }
			this.conversationMode = 'greeting'; // Current lifecycle mode
			this.sessionModel = '';
			this.transcriptionModel = '';
			this.greetingTone = 'warm_neutral';
			this.customGreetingText = '';
			this.sessionTools = []; // Function calling tools from middleware
			this.contactMemory = this.createEmptyContactMemory();
			this.searchItemIds = []; // Track search_knowledge item IDs for chunk cleanup
			this.conversationItemIds = []; // Track message item IDs for context trimming
			this.searchingCount = 0;
			this.searchPending = false;
			this._pageScopedAutoRetrieval = false; // True when page_scoped mode uses always-on retrieval
			this._retrievalItemIds = []; // Track injected retrieval context items for cleanup
			this._lastRetrievalTime = 0; // Timestamp of last successful retrieval
			this._lastRetrievalHadResults = false; // Whether last retrieval found chunks

			// Transcript tracking
			this.conversationTranscript = [];
			this.currentUserText = '';
			this.currentAiText = '';
			this.realtimeUsageTotals = this.createEmptyRealtimeUsageTotals();

			// Re-entrancy guard for endConversation
			this.isEndingConversation = false;
			this._qualifiedLeadFired = false;

			// Inactivity handling (silence watchdog)
			this.silenceTimer = null;
			this.silenceTimeoutMs = 5000; // 5 seconds between silence checks
			this.silenceChecksSent = 0;
			this.maxSilenceChecks = 1; // First check-in only; second timeout triggers goodbye + hangup
			this.isUserSpeaking = false;
			this.maxSessionDurationMs = 10 * 60 * 1000; // 10-minute hard cap per conversation
			this.sessionDurationTimer = null;
			this.initialUtteranceDurationMs = 90 * 1000; // First long-turn limit
			this.postViolationUtteranceDurationMs = 30 * 1000; // After first breach, stricter per-turn cap
			this.utteranceTimer = null;
			this.currentUtteranceStartMs = 0;
			this.utteranceViolationCount = 0;
			this.maxUtteranceViolations = 2;

			// Screen Wake Lock (prevents mobile screen sleep during calls)
			this.wakeLock = null;
			this.handleVisibilityChange = this.handleVisibilityChange.bind(this);

			// Conversation end detection (end_session function call + farewell phrase fallback)
			this.endPhrases = [
				'have a great day',
				'have a good day',
				'have a nice day',
				'have a wonderful day',
				'have an excellent day',
				'have a lovely day',
				'goodbye',
				'bye bye',
				'bye for now',
				'take care',
				'talk to you later',
				'thanks for calling',
				'thank you for calling',
				'thanks for reaching out',
				'thank you for reaching out',
				'be in touch',
				'feel free to reach out',
				'feel free to contact',
				"don't hesitate to reach out",
				"don't hesitate to contact"
			];
			this.shouldAutoHangup = false;
			this.endSessionCalled = false; // True only when AI invokes end_session tool
			this._endedByHint = null; // 'timed_out' when a silence-timeout goodbye reuses the auto-hangup path
			this.autoHangupFallbackTimer = null;
			// Sound effects
			this.sounds = {
				open: this.createSoundPlayer('open.mp3'),
				close: this.createSoundPlayer('close.mp3'),
			};
			this.cachedSoundBlobUrls = {};

		}

		/**
		 * Check if browser supports required features
		 */
		checkBrowserSupport() {
			if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
				console.error('SiteStaffr: getUserMedia not supported');
				return false;
			}

			if (!window.RTCPeerConnection) {
				console.error('SiteStaffr: WebRTC not supported');
				return false;
			}

			return true;
		}

		/**
		 * Create audio player for sounds
		 */
		createSoundPlayer(filename) {
			const audio = new Audio();
			audio.src = this.config.pluginUrl + 'assets/audio/' + filename;
			audio.preload = 'auto';
			return audio;
		}

		/**
		 * Play a sound effect
		 */
		playSound(soundName) {
			if (soundName === 'close') {
				this.playCloseSoundWithFallback();
				return;
			}

			if (this.sounds[soundName]) {
				const primary = this.sounds[soundName];
				primary.currentTime = 0;
				primary.play().catch(error => {
					console.warn('SiteStaffr: Could not play sound:', soundName, error);
					// Fallback to a fresh audio element in case the primary player is in a bad state.
					try {
						const fallback = new Audio(primary.src);
						fallback.currentTime = 0;
						fallback.play().catch(() => {
							this.playFallbackTone();
						});
					} catch (fallbackError) {
						console.warn('SiteStaffr: Fallback sound player failed:', fallbackError);
						this.playFallbackTone();
					}
				});
			}
		}

		/**
		 * Play close.mp3 first, then try blob playback, then local tone.
		 */
		playCloseSoundWithFallback() {
			const primary = this.sounds.close;
			if (!primary) {
				this.playFallbackTone();
				return;
			}

			primary.currentTime = 0;
			primary.play().catch(async (error) => {
				console.warn('SiteStaffr: Could not play sound: close', error);
				const playedFromBlob = await this.tryPlaySoundFromBlob('close.mp3');
				if (!playedFromBlob) {
					this.playFallbackTone();
				}
			});
		}

		/**
		 * Fetch and play an audio asset via blob URL to bypass range-request issues.
		 */
		async tryPlaySoundFromBlob(filename) {
			try {
				const cacheKey = filename;
				let blobUrl = this.cachedSoundBlobUrls[cacheKey];
				if (!blobUrl) {
					const soundUrl = this.config.pluginUrl + 'assets/audio/' + filename + '?v=' + encodeURIComponent(Date.now().toString());
					const response = await fetch(soundUrl, {
						method: 'GET',
						cache: 'no-store',
						credentials: 'same-origin'
					});
					if (!response.ok) {
						return false;
					}
					const blob = await response.blob();
					blobUrl = URL.createObjectURL(blob);
					this.cachedSoundBlobUrls[cacheKey] = blobUrl;
				}

				const fallback = new Audio(blobUrl);
				fallback.currentTime = 0;
				await fallback.play();
				return true;
			} catch (e) {
				console.warn('SiteStaffr: Blob sound fallback failed:', e);
				return false;
			}
		}

		/**
		 * Last-resort local tone so close cue still plays when media asset fails.
		 */
		playFallbackTone() {
			try {
				const AudioCtx = window.AudioContext || window.webkitAudioContext;
				if (!AudioCtx) {
					return;
				}
				const ctx = new AudioCtx();
				const osc = ctx.createOscillator();
				const gain = ctx.createGain();
				osc.type = 'sine';
				osc.frequency.setValueAtTime(523.25, ctx.currentTime);
				gain.gain.setValueAtTime(0.0001, ctx.currentTime);
				gain.gain.exponentialRampToValueAtTime(0.07, ctx.currentTime + 0.01);
				gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.18);
				osc.connect(gain);
				gain.connect(ctx.destination);
				osc.start();
				osc.stop(ctx.currentTime + 0.2);
				setTimeout(() => ctx.close().catch(() => {}), 250);
			} catch (e) {
				console.warn('SiteStaffr: Could not play fallback tone', e);
			}
		}

		/**
		 * Get current state
		 */
		getState() {
			return this.state;
		}

		/**
		 * Check if in conversation
		 */
		isInConversation() {
			return this.inConversation;
		}

		/**
		 * Set state and notify UI
		 */
		setState(newState) {
			const oldState = this.state;
			this.state = newState;

			// Maintain inactivity timer only while actively listening for user speech.
			if (newState === 'listening') {
				this.resetSilenceTimer();
			} else {
				this.clearSilenceTimer();
			}

			this.callbacks.onStateChange(newState, oldState);
		}

		/**
		 * Reset inactivity timer while waiting for caller input.
		 */
		resetSilenceTimer() {
			if (!this.inConversation) {
				return;
			}

			this.clearSilenceTimer();
			this.silenceTimer = setTimeout(() => {
				this.handleSilenceTimeout();
			}, this.silenceTimeoutMs);
		}

		/**
		 * Clear inactivity timer.
		 */
		clearSilenceTimer() {
			if (this.silenceTimer) {
				clearTimeout(this.silenceTimer);
				this.silenceTimer = null;
			}
		}

		/**
		 * Schedule a long safety fallback for auto-hangup.
		 * Primary hangup should come from audio-ended events.
		 */
		scheduleAutoHangupFallback(timeoutMs = 15000) {
			this.clearAutoHangupFallback();
			this.autoHangupFallbackTimer = setTimeout(() => {
				if (this.shouldAutoHangup && this.inConversation) {
					this.shouldAutoHangup = false;
					this.endConversation('conversation_complete');
				}
			}, timeoutMs);
		}

		/**
		 * Clear auto-hangup safety fallback timer.
		 */
		clearAutoHangupFallback() {
			if (this.autoHangupFallbackTimer) {
				clearTimeout(this.autoHangupFallbackTimer);
				this.autoHangupFallbackTimer = null;
			}
		}

		/**
		 * Start/clear hard session duration timer.
		 */
		scheduleSessionDurationCap() {
			this.clearSessionDurationCap();
			this.sessionDurationTimer = setTimeout(() => {
				if (!this.inConversation) {
					return;
				}
				this.triggerManagedHangup('Conversation limit reached. Please reconnect if you need more help.');
			}, this.maxSessionDurationMs);
		}

		clearSessionDurationCap() {
			if (this.sessionDurationTimer) {
				clearTimeout(this.sessionDurationTimer);
				this.sessionDurationTimer = null;
			}
		}

		/**
		 * Track continuous caller speech duration.
		 */
		startUtteranceTimer() {
			this.clearUtteranceTimers();
			this.currentUtteranceStartMs = Date.now();
			const capMs = this.getCurrentUtteranceCapMs();
			this.utteranceTimer = setTimeout(() => {
				this.handleUtteranceCapExceeded();
			}, capMs);
		}

		clearUtteranceTimers() {
			if (this.utteranceTimer) {
				clearTimeout(this.utteranceTimer);
				this.utteranceTimer = null;
			}
			this.currentUtteranceStartMs = 0;
		}

		getCurrentUtteranceCapMs() {
			return this.utteranceViolationCount > 0
				? this.postViolationUtteranceDurationMs
				: this.initialUtteranceDurationMs;
		}

		handleUtteranceCapExceeded() {
			if (!this.inConversation || !this.isUserSpeaking) {
				return;
			}

			this.utteranceViolationCount += 1;

			if (this.utteranceViolationCount >= this.maxUtteranceViolations) {
				this.triggerManagedHangup('I need to end this call now. Please reconnect and share your request in shorter steps.');
				return;
			}

			// First violation: warning, then apply stricter 30s per-turn cap for the rest of the session.
			this.sendMessage({
				type: 'response.create',
				response: {
					output_modalities: ['audio'],
					instructions: 'Politely interrupt and ask the visitor to continue in shorter chunks so you can help accurately.'
				}
			});
			// If caller continues speaking without pause, enforce the stricter second-stage cap immediately.
			this.utteranceTimer = setTimeout(() => {
				this.handleUtteranceCapExceeded();
			}, this.postViolationUtteranceDurationMs);
		}

		/**
		 * Send one final message, then auto-hangup cleanly.
		 */
		triggerManagedHangup(finalLine) {
			if (!this.inConversation) {
				return;
			}

			this.shouldAutoHangup = true;
			if (this.dataChannel && this.dataChannel.readyState === 'open') {
				this.sendMessage({
					type: 'response.create',
					response: {
						output_modalities: ['audio'],
						instructions: `${finalLine} End with: "Have a great day."`
					}
				});
				this.scheduleAutoHangupFallback(12000);
			} else {
				this.endConversation('conversation_complete');
			}
		}

		/**
		 * Handle prolonged caller silence.
		 */
		handleSilenceTimeout() {
			if (!this.inConversation || this.state !== 'listening' || this.isUserSpeaking) {
				return;
			}

			// First timeout: prompt caller to confirm they're still there.
			if (this.silenceChecksSent < this.maxSilenceChecks) {
				if (this.dataChannel && this.dataChannel.readyState === 'open') {
					this.silenceChecksSent += 1;
					this.sendMessage({
						type: 'response.create',
						response: {
							output_modalities: ['audio'],
							instructions: 'Visitor has been silent. Briefly ask if they are still there and whether they need anything else.'
						}
					});
					this.setState('thinking');
				}
				return;
			}

			// Second timeout after first check-in: graceful goodbye and auto-hangup.
			if (this.dataChannel && this.dataChannel.readyState === 'open') {
				this.shouldAutoHangup = true;
				// Silence hangups reuse the auto-hangup → 'conversation_complete' path,
				// so hint the resolver that this end is really an inactivity timeout.
				this._endedByHint = 'timed_out';
				this.sendMessage({
					type: 'response.create',
					response: {
						output_modalities: ['audio'],
						instructions: 'Visitor is still silent. Say a brief, polite goodbye. Do NOT mention ending the session, hanging up, or any internal actions — just say goodbye.'
					}
				});
				this.setState('thinking');
				// Safety fallback only; prefer audio-ended events for natural hangup timing.
				this.scheduleAutoHangupFallback(15000);
				return;
			}

			// If channel is unavailable, end immediately.
			this.endConversation('inactivity_timeout');
		}

		/**
		 * Mute or unmute microphone track
		 * Used to prevent false speech detection during AI audio playback
		 */
		setMicrophoneMuted(muted) {
			if (!this.mediaStream) {
				console.warn('SiteStaffr: Cannot mute/unmute, no media stream');
				return;
			}

			this.mediaStream.getAudioTracks().forEach(track => {
				track.enabled = !muted;
			});

		}

		/**
		 * Request screen wake lock to prevent mobile screen sleep.
		 */
		async requestWakeLock() {
			try {
				if ('wakeLock' in navigator) {
					this.wakeLock = await navigator.wakeLock.request('screen');
					document.addEventListener('visibilitychange', this.handleVisibilityChange);
				}
			} catch (err) {
			}
		}

		/**
		 * Release screen wake lock.
		 */
		async releaseWakeLock() {
			document.removeEventListener('visibilitychange', this.handleVisibilityChange);
			if (this.wakeLock) {
				await this.wakeLock.release();
				this.wakeLock = null;
			}
		}

		/**
		 * Re-acquire wake lock when page becomes visible again (browser releases on tab switch).
		 */
		async handleVisibilityChange() {
			if (document.visibilityState === 'visible' && this.inConversation) {
				await this.requestWakeLock();
			}
		}

		/**
		 * Start conversation flow
		 */
		async startConversation() {
			try {

				// Play open sound and show spinner immediately for instant feedback
				this.playSound('open');
				this.setState('connecting');
				this.callbacks.onConversationStart();

				this.inConversation = true;
				this.greetingComplete = false; // Reset greeting flag for new conversation
				this.conversationStartTime = Date.now(); // Track conversation start time

				// Register unload safety nets so transcript/billing is saved even if
				// the visitor reloads, closes tab, or navigates away mid-call.
				// pagehide is added for bfcache/mobile parity with the text-chat save triggers.
				this._boundBeforeUnload = this._onBeforeUnload.bind(this);
				this._boundPageHide = this._onBeforeUnload.bind(this);
				window.addEventListener('beforeunload', this._boundBeforeUnload);
				window.addEventListener('pagehide', this._boundPageHide);

				this.silenceChecksSent = 0;
				this.utteranceViolationCount = 0;
				this.clearUtteranceTimers();
				this.scheduleSessionDurationCap();

				// Prevent mobile screen sleep during conversation
				this.requestWakeLock();

				// Reset transcript
				this.conversationTranscript = [];
				this.currentUserText = '';
				this.currentAiText = '';
				this.realtimeUsageTotals = this.createEmptyRealtimeUsageTotals();
				this.contactMemory = this.createEmptyContactMemory();
				this._qualifiedLeadFired = false;
				this._reconnectAttempts = 0;
				this._rateLimitRetries = 0;

				// Step 1: Fetch fresh authentication token via AJAX
				const tokenData = await this.fetchAuthToken();
				if (!tokenData || !tokenData.token) {
					throw new Error('Could not get authentication token');
				}

				this.authToken = tokenData.token;
				this.config.siteId = tokenData.site_id;

				// Step 2: Request microphone permission
				await this.requestMicrophonePermission();

				// Step 3: Connect to realtime voice API via WebRTC (through middleware)
				await this.connectRealtimeAPI();

				// GTM dataLayer: voice conversation started
				(window.dataLayer = window.dataLayer || []).push(Object.assign({
					event: 'sitestaffr_conversation_start',
					sitestaffr_mode: 'voice',
					sitestaffr_session_id: this.sessionId,
				}, window._sitestaffrGTM.pageContext()));

			} catch (error) {
				// User manually ended while connection bootstrap was still in-flight.
				// Ignore late connect errors in this state to avoid false UI failures.
				if (!this.inConversation && this.state === 'idle') {
					return;
				}

				// Ensure microphone and peer resources are fully released on failed startup.
				// Without this, browser "using your microphone" indicators can persist.
				try {
					await this.releaseWakeLock();
				} catch (e) {}
				this.cleanup();

				console.error('SiteStaffr: Failed to start conversation:', error);

				if (error.message === 'MIC_DENIED') {
					this.callbacks.onMicDenied();
					this.inConversation = false;
					this.setState('idle');
					return; // Don't show generic error — widget handles recovery
				} else if (error.message && error.message.startsWith('ENTITLEMENT_BLOCKED:')) {
					const reason = error.message.split(':')[1];
					let friendlyMessage;
					switch (reason) {
						case 'minutes_exhausted':
							this.callbacks.onMinutesExhausted();
							this.inConversation = false;
							this.setState('idle');
							return; // Don't show generic error — widget handles silent fallback
						case 'trial_expired':
							this.callbacks.onMinutesExhausted();
							this.inConversation = false;
							this.setState('idle');
							return;
						case 'payment_failed':
							friendlyMessage = 'Our AI assistant is temporarily unavailable. Please contact us directly.';
							break;
						default:
							friendlyMessage = 'Our AI assistant is currently unavailable. Please contact us directly.';
					}
					this.callbacks.onError(friendlyMessage);
					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 10000);
				} else if (error.message && error.message.startsWith('RATE_LIMITED:')) {
					const minutes = error.message.split(':')[1];
					const friendlyMessage = `Too many attempts. Please wait ${minutes} minute${minutes > 1 ? 's' : ''} to speak with our representative again.`;
					this.callbacks.onError(friendlyMessage);

					// Auto-clear after 10 seconds and return to idle
					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 10000);
				} else if (error.message && error.message === 'IDENTIFIER_BANNED') {
					this.callbacks.onError('Access blocked for this network. Please contact the site owner if you believe this is a mistake.');

					// Auto-clear after 10 seconds and return to idle
					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 10000);
				} else if (error.message && error.message.startsWith('FORBIDDEN:')) {
					const forbiddenMessage = error.message.slice('FORBIDDEN:'.length) || 'Voice session is unavailable right now. Please try again.';
					this.callbacks.onError(forbiddenMessage);

					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 8000);
				} else if (error.message && error.message === 'MAINTENANCE') {
					this.callbacks.onError('Our assistant is temporarily undergoing maintenance. Please try again shortly.');

					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 8000);
				} else if (error.message && error.message === 'TOKEN_AUTH_FAILED') {
					this.callbacks.onError('Connection blocked. Please disable your ad blocker for this site and try again.');

					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 8000);
				} else if (error.message && error.message.startsWith('TOKEN_FETCH_FAILED:')) {
					this.callbacks.onError('Unable to start right now. Please wait a moment and try again.');

					setTimeout(() => {
						if (this.state === 'error' && !this.inConversation) {
							this.setState('idle');
						}
					}, 8000);
				} else {
					this.callbacks.onError(error.message || 'Connection failed');
				}

				this.inConversation = false;
				this.setState('error');
			}
		}

		/**
		 * Refresh nonces once per page load for page-cached environments.
		 *
		 * Page caching (LiteSpeed, Cloudflare, etc.) serves stale nonces.
		 * This fetches fresh ones via admin-ajax.php (never cached) before
		 * the first interaction. Runs once — subsequent calls are no-ops.
		 *
		 * @return {Promise<void>}
		 */
		async _refreshNoncesOnce() {
			if (SiteStaffrCore._noncesRefreshed) {
				return;
			}
			SiteStaffrCore._noncesRefreshed = true;

			try {
				const formData = new FormData();
				formData.append('action', 'sitestaffr_refresh_nonces');

				const response = await fetch(this.config.ajaxUrl, {
					method: 'POST',
					body: formData,
					credentials: 'same-origin',
				});

				if (!response.ok) {
					return; // Fall through to cached nonces — best effort
				}

				const result = await response.json();
				if (result.success && result.data) {
					const nonces = result.data;
					// Update widget data in-place so all subsequent reads get fresh nonces
					if (window.sitestaffr_widget_data) {
						Object.assign(window.sitestaffr_widget_data, nonces);
					}
					if (window.sitestaffr_button_data) {
						Object.assign(window.sitestaffr_button_data, nonces);
					}
				}
			} catch (e) {
				// Silent fail — cached nonces may still be valid
			}
		}

		/**
		 * Fetch fresh authentication token via REST API
		 *
		 * Protected with nonce and honeypot for security.
		 *
		 * @return {Promise<Object>} Token data
		 */
		async fetchAuthToken() {
			try {
				// Refresh nonces once per page load to handle page-cached environments
				// (LiteSpeed, Cloudflare, etc.) where wp_localize_script nonces go stale.
				await this._refreshNoncesOnce();

				// Get nonce from localized script data (check both widget types)
				const nonce = window.sitestaffr_widget_data?.token_nonce || window.sitestaffr_button_data?.token_nonce || '';
				if (!nonce) {
					console.error('SiteStaffr: Missing token nonce');
					throw new Error('TOKEN_AUTH_FAILED');
				}

				const formData = new FormData();
				formData.append('action', 'sitestaffr_get_token');
				formData.append('nonce', nonce);
				formData.append('email', ''); // Honeypot field (should be empty)

				const response = await fetch(this.config.ajaxUrl, {
					method: 'POST',
					body: formData,
					credentials: 'same-origin'
				});

				if (!response.ok) {
					let errorData = null;
					try {
						errorData = await response.json();
					} catch (e) {
						// Response body may be empty or non-JSON.
					}

					if (response.status === 429) {
						let retryMinutes = 5;
						if (errorData?.data?.retry_after) {
							retryMinutes = Math.ceil(Number(errorData.data.retry_after) / 60);
						}
						throw new Error(`RATE_LIMITED:${retryMinutes}`);
					}

					// Stale/missing nonce or WAF-style token auth failure.
					if (response.status === 403) {
						throw new Error('TOKEN_AUTH_FAILED');
					}

					const serverMessage = errorData?.data?.message || errorData?.message || '';
					if (serverMessage) {
						throw new Error(`TOKEN_FETCH_FAILED:${serverMessage}`);
					}

					throw new Error(`TOKEN_FETCH_FAILED:HTTP_${response.status}`);
				}

				const data = await response.json();

				if (data.success && data.data) {
					return {
						token: data.data.token,
						site_id: data.data.site_id,
						expires_in: data.data.expires_in,
					};
				} else {
					console.error('SiteStaffr: Token fetch failed:', data);
					const message = data?.data?.message || data?.message || 'Token endpoint returned an invalid response';
					throw new Error(`TOKEN_FETCH_FAILED:${message}`);
				}
			} catch (error) {
				console.error('SiteStaffr: Token fetch error:', error);
				throw error;
			}
		}

		/**
		 * Request microphone permission
		 */
		async requestMicrophonePermission() {
			// Only show the overlay if mic permission hasn't been granted yet.
			var needsOverlay = true;
			try {
				if (navigator.permissions && navigator.permissions.query) {
					var permStatus = await navigator.permissions.query({ name: 'microphone' });
					if (permStatus.state === 'granted') needsOverlay = false;
				}
			} catch (e) { /* permissions API not available — show overlay to be safe */ }
			if (needsOverlay) this._showMicOverlay();
			try {
				this.mediaStream = await navigator.mediaDevices.getUserMedia({
					audio: {
						echoCancellation: true,
						noiseSuppression: true,
						autoGainControl: true,
						sampleRate: 24000, // 24kHz for realtime voice API
					}
				});
				this._hideMicOverlay();
			} catch (error) {
				this._hideMicOverlay();
				console.error('SiteStaffr: Microphone permission denied:', error);
				throw new Error('MIC_DENIED');
			}
		}

		/**
		 * Show full-page overlay during mic permission prompt.
		 */
		_showMicOverlay() {
			if (this._micOverlay) return;
			const overlay = document.createElement('div');
			overlay.className = 'sitestaffr-mic-overlay';
			overlay.innerHTML = '<div class="sitestaffr-mic-overlay-text">Please allow microphone access to start a voice conversation</div>';
			document.body.appendChild(overlay);
			this._micOverlay = overlay;
			// Trigger reflow then fade in
			void overlay.offsetWidth;
			overlay.classList.add('is-visible');
		}

		/**
		 * Hide and remove the mic permission overlay.
		 */
		_hideMicOverlay() {
			if (!this._micOverlay) return;
			const overlay = this._micOverlay;
			this._micOverlay = null;
			overlay.classList.remove('is-visible');
			overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
			// Safety: remove after 500ms if transitionend doesn't fire
			setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 500);
		}

		/**
		 * Connect to realtime voice API via WebRTC (through middleware)
		 *
		 * Uses unified interface: Browser sends SDP to middleware, which proxies to voice service.
		 * This avoids CORS issues with direct browser-to-provider connection.
		 *
		 * WebRTC provides:
		 * - Automatic audio handling (no manual encoding/decoding)
		 * - Automatic interruption handling (server-side)
		 * - Lower latency than WebSocket
		 */
		async connectRealtimeAPI() {

			try {
				// Create peer connection
				this.peerConnection = new RTCPeerConnection();

				// Set up audio element to play remote audio from the model
				this.audioElement = document.createElement('audio');
				this.audioElement.autoplay = true;

				// Track audio playback state for accurate icon updates
				this.audioElement.addEventListener('playing', () => {
					// Mute microphone when AI starts speaking to prevent false speech detection
					this.setMicrophoneMuted(true);
				});

				this.audioElement.addEventListener('ended', () => {
					// Don't transition while a search is running
					if (this.searchingCount > 0 || this.searchPending) return;
					this.setMicrophoneMuted(false);
					if (this.shouldAutoHangup && this.inConversation) {
						this.clearAutoHangupFallback();
						this.shouldAutoHangup = false;
						this.endConversation('conversation_complete');
					} else if (this.inConversation && !this.initialGreetingInProgress) {
						this.setState('listening');
					}
				});

				this.peerConnection.ontrack = (e) => {
					this.audioElement.srcObject = e.streams[0];
					// Explicit play() — autoplay attribute alone is unreliable
					// when there's a delay between user gesture and audio start.
					this.audioElement.play().catch(() => {});
				};

				// Add local audio track for microphone input
				this.mediaStream.getTracks().forEach(track => {
					this.peerConnection.addTrack(track, this.mediaStream);
				});

				// Set up data channel for sending and receiving events
				this.dataChannel = this.peerConnection.createDataChannel('voice-events');

				this.dataChannel.addEventListener('open', () => {

					// For reconnects, compose resume instructions from the fresh
					// middleware instructions + preserved conversation context.
					var instructions = this.sessionInstructions;
					if (this._resumeContext) {
						// Restore lifecycle mode before building instructions
						if (this._resumeContext.mode && this.sessionModes) {
							this.conversationMode = this._resumeContext.mode;
							this.contactMemory = this._resumeContext.contact;
							var visitorCtx = this.formatVisitorContext();
							instructions = this.sessionModes[this.conversationMode].replace('{{VISITOR_CONTEXT}}', visitorCtx);
							if (this.toolsByMode && this.toolsByMode[this.conversationMode]) {
								this.sessionTools = this.toolsByMode[this.conversationMode];
							}
						}
						instructions = this.buildResumeInstructions(
							instructions,
							this._resumeContext.contact,
							this._resumeContext.transcript
						);
						this._resumeContext = null;
					}

					// Send session.update with instructions FIRST
					if (instructions) {
						this.sendMessage({
							type: 'session.update',
							session: this.buildSessionUpdatePayload(instructions)
						});
					}

					// Avoid brief listening flicker before the first greeting starts.
					if (!this.greetingComplete) {
						this.setState('thinking');
					} else {
						this.setState('listening');
					}
				});

				this.dataChannel.addEventListener('message', (e) => {
					this.handleRealtimeMessage(e.data);
				});

				this.dataChannel.addEventListener('error', (e) => {
					console.error('SiteStaffr: Data channel error:', e);
				});

				this.dataChannel.addEventListener('close', () => {
					if (this.inConversation && !this._isReconnecting) {
						this.endConversation('connection_lost');
					}
				});

				// Handle connection state changes
				this.peerConnection.onconnectionstatechange = () => {
					if (this._isReconnecting) return;
					if (this.peerConnection && (this.peerConnection.connectionState === 'failed' ||
						this.peerConnection.connectionState === 'disconnected')) {
						if (this.inConversation) {
							this.endConversation('connection_lost');
						}
					}
				};

				// Create SDP offer
				const offer = await this.peerConnection.createOffer();
				await this.peerConnection.setLocalDescription(offer);

				// Get WebRTC nonce from localized script data (check both widget types)
				const webrtcNonce = window.sitestaffr_widget_data?.webrtc_nonce || window.sitestaffr_button_data?.webrtc_nonce || '';
				if (!webrtcNonce) {
					console.error('SiteStaffr: Missing WebRTC nonce');
					throw new Error('Security verification failed: Missing nonce');
				}

				// Send offer to WordPress (which proxies to middleware with HMAC auth)
				const formData = new FormData();
				formData.append('action', 'sitestaffr_webrtc_session');
				formData.append('nonce', webrtcNonce);
				formData.append('sdp', offer.sdp);
				formData.append('site_token', this.authToken);
				if (this.config.persona) {
					formData.append('persona', this.config.persona);
				}
				if (window.sitestaffr_widget_data && window.sitestaffr_widget_data.current_post_id) {
					formData.append('current_post_id', window.sitestaffr_widget_data.current_post_id);
				}

				const sdpResponse = await fetch(this.config.ajaxUrl, {
					method: 'POST',
					body: formData,
					credentials: 'same-origin'
				});

				if (!sdpResponse.ok) {
					let errorData;
					try {
						errorData = await sdpResponse.json();
					} catch (e) {
						if (sdpResponse.status === 403) {
							throw new Error('FORBIDDEN:Voice session request was denied by the server');
						}
						throw new Error('Failed to establish voice session: ' + sdpResponse.status);
					}

					if (errorData?.data?.error_code === 'ENTITLEMENT_BLOCKED') {
						throw new Error('ENTITLEMENT_BLOCKED:' + (errorData.data.reason || 'unavailable'));
					}
					// Also check top-level error_code (non-wrapped response)
					if (errorData?.error_code === 'ENTITLEMENT_BLOCKED') {
						throw new Error('ENTITLEMENT_BLOCKED:' + (errorData.reason || 'unavailable'));
					}
					if (errorData?.data?.error_code === 'IDENTIFIER_BANNED') {
						throw new Error('IDENTIFIER_BANNED');
					}
					if (errorData?.data?.error_code === 'FORBIDDEN' || errorData?.error_code === 'FORBIDDEN') {
						const forbiddenMessage = errorData?.data?.message || errorData?.message || 'Voice session request was denied by the server';
						throw new Error('FORBIDDEN:' + forbiddenMessage);
					}

					// Maintenance mode — middleware is temporarily shut down
					if (sdpResponse.status === 503 || errorData?.data?.error === 'MAINTENANCE' || errorData?.error === 'MAINTENANCE') {
						throw new Error('MAINTENANCE');
					}

					const structuredMessage = errorData?.data?.message || errorData?.message;
					if (sdpResponse.status === 403) {
						throw new Error('FORBIDDEN:' + (structuredMessage || 'Voice session request was denied by the server'));
					}

					throw new Error(structuredMessage || ('Failed to establish voice session: ' + sdpResponse.status));
				}

				// Parse JSON response from WordPress (contains sdp, instructions, and optional model settings)
				const sessionData = await sdpResponse.json();

				if (!sessionData.success || !sessionData.data?.sdp) {
					console.error('SiteStaffr: Invalid session response:', sessionData);
					throw new Error('Invalid session response from server');
				}

				// Save instructions for session.update after DataChannel opens
				this.sessionInstructions = sessionData.data.instructions || '';
				this.sessionModel = sessionData.data.model || '';
				this.sessionVoice = sessionData.data.voice || '';
				this.transcriptionModel = sessionData.data.transcription_model || '';
				this.greetingTone = sessionData.data.greeting_tone || 'warm_neutral';
				this.customGreetingText = sessionData.data.custom_greeting || '';
				this.sessionTools = sessionData.data.tools || [];
				// Lifecycle modes (null on onboarding sessions — falls back to legacy monolithic flow)
				this.sessionModes = sessionData.data.modes || null;
				this.toolsByMode = sessionData.data.tools_by_mode || null;
				this.lifecycleResponses = sessionData.data.lifecycle_responses || null;
				this.conversationMode = 'greeting';
				// Strip VISITOR_CONTEXT placeholder from the initial instructions (empty at session start)
				if (this.sessionInstructions) {
					this.sessionInstructions = this.sessionInstructions.replace('{{VISITOR_CONTEXT}}', '');
				}
				if (this.sessionModel) {
					this.realtimeUsageTotals.model = this.sessionModel;
				}

				// Set remote description from provider answer
				const answer = {
					type: 'answer',
					sdp: sessionData.data.sdp,
				};
				await this.peerConnection.setRemoteDescription(answer);

				// Connection is established, state will be updated when data channel opens

			} catch (error) {
				console.error('SiteStaffr: WebRTC connection failed:', error);
				if (error && error.message === 'IDENTIFIER_BANNED') {
					throw error;
				}
				if (error && error.message && error.message.startsWith('ENTITLEMENT_BLOCKED:')) {
					throw error;
				}
				if (error && error.message === 'MAINTENANCE') {
					throw error;
				}
				throw new Error('Realtime voice connection failed: ' + error.message);
			}
		}

		/**
		 * Trigger AI greeting to speak first
		 * Called after session is configured to initiate conversation
		 */
		triggerAIGreeting() {
			this.greetingComplete = true; // Prevent duplicate triggers from multiple session.updated events
			this.initialGreetingInProgress = true;
			const firstGreetingInstruction = this.getFirstGreetingInstruction();
			const responsePayload = {
				output_modalities: ['audio']
			};
			if (firstGreetingInstruction) {
				responsePayload.instructions = firstGreetingInstruction;
			}
			this.sendMessage({
				type: 'response.create',
				response: responsePayload
			});

			if (this.sessionModes) {
				// Lifecycle-modes path: transition to Answering Mode so next AI response
				// uses full answering rules.
				this.transitionToMode('answering');
			} else {
				// Fallback: strip the greeting from session instructions so it can
				// NEVER trigger again — context recovery, nuclear cleanup, and reconnect
				// all reuse session instructions, and the "FIRST RESPONSE RULE" would
				// cause the model to re-greet on an empty conversation.
				var cleanedInstructions = this.stripGreetingFromInstructions(this.sessionInstructions || '');
				this.sessionInstructions = cleanedInstructions;
				this.sendMessage({
					type: 'session.update',
					session: this.buildSessionUpdatePayload(cleanedInstructions)
				});
			}
		}

		getGreetingToneInstruction() {
			const tone = (this.greetingTone || 'warm_neutral').toLowerCase();
			const toneMap = {
				warm_neutral: 'Use a warm, neutral, welcoming delivery with natural energy.',
				upbeat_friendly: 'Use an upbeat, friendly delivery with bright energy and lively pacing.',
				calm_professional: 'Use a serious, professional delivery with formal tone, lower energy, measured pacing, and composed authority.',
				serious_respectful: 'Use a serious, respectful delivery with measured pacing and empathetic restraint.'
			};
			return toneMap[tone] || toneMap.warm_neutral;
		}

		getDefaultChatGreeting() {
			const localizedGreeting =
				window.sitestaffr_widget_data?.chat_greeting ||
				window.sitestaffr_button_data?.chat_greeting ||
				'';

			if (typeof localizedGreeting === 'string' && localizedGreeting.trim()) {
				return localizedGreeting.trim();
			}

			return 'Hi! How can I help you today?';
		}

		getFirstGreetingInstruction() {
			const toneInstruction = this.getGreetingToneInstruction();
			const customGreeting = (this.customGreetingText || '').trim();
			const greeting = customGreeting || this.getDefaultChatGreeting();

			return `FIRST GREETING RULE (MANDATORY): Say exactly this sentence as your first response and nothing else: ${JSON.stringify(greeting)}. Do not paraphrase. Do not add words before or after it. ${toneInstruction}`;
		}

		/**
		 * Format contactMemory into a VISITOR CONTEXT prompt header.
		 * Injected at the top of each mode's instructions on transition so
		 * the AI carries collected contact fields across state changes.
		 *
		 * The empty state is STATED, never implied by an absent section: prompt
		 * rules reason about this block ("Trust VISITOR CONTEXT"), and a small
		 * realtime model reads a missing section far more loosely than an
		 * explicit "nothing captured" (Loria voice incident 2026-08-06). Keep
		 * the wording in sync with the text-channel builder, buildVisitorContext
		 * in middleware routes/chat.js.
		 */
		formatVisitorContext() {
			var fields = this.contactMemory || {};
			var value = function (v) {
				return (typeof v === 'string' && v.trim()) ? v.trim() : '';
			};
			var lines = [];
			if (value(fields.name))     lines.push('Name: ' + value(fields.name));
			if (value(fields.phone))    lines.push('Phone: ' + value(fields.phone));
			if (value(fields.email))    lines.push('Email: ' + value(fields.email));
			if (value(fields.reason))   lines.push('Reason: ' + value(fields.reason));
			if (value(fields.location)) lines.push('Location: ' + value(fields.location));
			if (lines.length === 0) {
				return '## VISITOR CONTEXT\nNothing captured yet — no name, phone, email, reason, or location on file.\n\n';
			}
			return '## VISITOR CONTEXT\n' + lines.join('\n') + '\n\n';
		}

		/**
		 * Format a snapshot of all contactMemory fields for tool output.
		 * Shows captured and missing fields so the AI always knows
		 * exactly what info it has and what it still needs.
		 */
		formatContactSnapshot() {
			var fields = this.contactMemory || {};
			var hasCaptured = false;
			var parts = [];
			for (var key in fields) {
				if (fields.hasOwnProperty(key)) {
					if (fields[key]) hasCaptured = true;
					parts.push(key + ': ' + (fields[key] || '(not captured)'));
				}
			}
			if (!hasCaptured) return '';
			return 'Visitor info: ' + parts.join(' | ') + '. Do not re-ask for captured fields.';
		}

		/**
		 * Transition to a new conversation mode.
		 * Sends session.update with the new mode's instructions and tools,
		 * patching VISITOR CONTEXT with the current contactMemory.
		 */
		transitionToMode(newMode) {
			if (!this.sessionModes || !this.sessionModes[newMode]) {
				console.warn('SiteStaffr: No instructions for mode:', newMode);
				return;
			}
			if (this.conversationMode === newMode) {
				return;
			}

			this.conversationMode = newMode;

			var visitorCtx = this.formatVisitorContext();
			var instructions = this.sessionModes[newMode].replace('{{VISITOR_CONTEXT}}', visitorCtx);
			if (this.toolsByMode && this.toolsByMode[newMode]) {
				this.sessionTools = this.toolsByMode[newMode];
			}
			this.sessionInstructions = instructions;

			this.sendMessage({
				type: 'session.update',
				session: this.buildSessionUpdatePayload(instructions)
			});
		}

		/**
		 * Re-apply current mode's instructions with fresh VISITOR CONTEXT.
		 * Call after contactMemory updates so the AI sees collected fields
		 * without switching modes.
		 */
		refreshVisitorContext() {
			if (!this.sessionModes || !this.sessionModes[this.conversationMode]) {
				return;
			}
			var visitorCtx = this.formatVisitorContext();
			var instructions = this.sessionModes[this.conversationMode].replace('{{VISITOR_CONTEXT}}', visitorCtx);
			this.sessionInstructions = instructions;
			this.sendMessage({
				type: 'session.update',
				session: this.buildSessionUpdatePayload(instructions)
			});
		}

		/**
		 * Trim older conversation message items to prevent context overflow.
		 * Audio tokens are large — each exchange can be thousands of tokens.
		 * Keeps the most recent keepItems message items, deletes the rest.
		 */
		trimConversationContext(keepItems) {
			if (this.conversationItemIds.length <= keepItems) return;
			var deleteCount = this.conversationItemIds.length - keepItems;
			var toDelete = this.conversationItemIds.splice(0, deleteCount);
			for (var i = 0; i < toDelete.length; i++) {
				this.sendMessage({ type: 'conversation.item.delete', item_id: toDelete[i] });
			}
		}

		/**
		 * Send a function_call_output response via DataChannel.
		 */
		sendFunctionCallOutput(callId, output) {
			this.sendMessage({
				type: 'conversation.item.create',
				item: {
					type: 'function_call_output',
					call_id: callId,
					output: JSON.stringify(output)
				}
			});
		}

		/**
		 * Always-on retrieval for page_scoped voice: search, inject context, respond.
		 */
		_doPageScopedRetrieval(transcript) {
			if (!this.dataChannel || this.dataChannel.readyState !== 'open') return;

			for (var i = 0; i < PAGE_SCOPED_SKIP_PATTERNS.length; i++) {
				if (PAGE_SCOPED_SKIP_PATTERNS[i].test(transcript)) {
					this.sendMessage({ type: 'response.create' });
					return;
				}
			}

			// Follow-up cooldown: if last search was recent and had results,
			// skip re-searching — the model already has the context injected.
			var now = Date.now();
			if (this._lastRetrievalHadResults && (now - this._lastRetrievalTime) < 12000) {
				this.sendMessage({ type: 'response.create' });
				return;
			}

			var postId = window.sitestaffr_widget_data.current_post_id;
			var self = this;

			this.searchingCount++;
			this.callbacks.onSearchStart && this.callbacks.onSearchStart(transcript);
			this.setState('searching');

			// Clean up previous retrieval context items (keep last 2)
			if (this._retrievalItemIds.length > 2) {
				var oldItems = this._retrievalItemIds.splice(0, this._retrievalItemIds.length - 2);
				for (var i = 0; i < oldItems.length; i++) {
					this.sendMessage({ type: 'conversation.item.delete', item_id: oldItems[i] });
				}
			}

			this.trimConversationContext(4);

			SiteStaffrKnowledgeSearch.search(transcript, 3, postId)
				.then(function(result) {
					self.searchingCount = Math.max(0, self.searchingCount - 1);
					if (self.searchingCount === 0) {
						self.callbacks.onSearchEnd && self.callbacks.onSearchEnd();
					}
					if (!self.dataChannel || self.dataChannel.readyState !== 'open') return;

					var hasChunks = result.success && result.chunks && result.chunks.length > 0;
					self._lastRetrievalTime = Date.now();
					self._lastRetrievalHadResults = hasChunks;

					// Inject retrieved content (or no-results signal) as a system message
					var contextItemId = 'retrieval_ctx_' + Date.now();
					var content;
					if (hasChunks) {
						var parts = ['RETRIEVED KNOWLEDGE:'];
						if (result.chunks.length > 1) {
							parts.push('IMPORTANT: The content below may cover different recommendations for different situations. Ask the visitor about THEIR situation first (e.g. severity, type, or specifics), then give the matching recommendation. Do not list all the solutions — ask what applies to them so you can recommend the right one.');
						}
						for (var c = 0; c < result.chunks.length; c++) {
							var chunk = result.chunks[c];
							parts.push('### ' + (chunk.post_title || chunk.title || '') + '\n' + (chunk.chunk_text || chunk.content || ''));
						}
						content = parts.join('\n\n');
					} else {
						content = 'No relevant content found on this page for the visitor\'s question. Answer from PAGE OVERVIEW or BUSINESS IDENTITY if possible. If none of those cover it, say you don\'t have that detail.';
					}

					self.sendMessage({
						type: 'conversation.item.create',
						item: {
							id: contextItemId,
							type: 'message',
							role: 'system',
							content: [{ type: 'input_text', text: content }]
						}
					});
					self._retrievalItemIds.push(contextItemId);
					self.sendMessage({ type: 'response.create' });
				})
				.catch(function() {
					self.searchingCount = Math.max(0, self.searchingCount - 1);
					if (self.searchingCount === 0) {
						self.callbacks.onSearchEnd && self.callbacks.onSearchEnd();
					}
					if (!self.dataChannel || self.dataChannel.readyState !== 'open') return;
					self.sendMessage({ type: 'response.create' });
				});
		}

		/**
		 * Send message via DataChannel
		 */
		sendMessage(data) {
			if (this.dataChannel && this.dataChannel.readyState === 'open') {
				this.dataChannel.send(JSON.stringify(data));
			} else {
				console.error('SiteStaffr: Cannot send message, DataChannel not open (state:', this.dataChannel?.readyState, ')');
			}
		}


		/**
		 * Handle DataChannel messages from realtime voice API
		 *
		 * With WebRTC, audio is handled automatically by the peer connection.
		 * We only receive events for session management, transcripts, and conversation flow.
		 */
		handleRealtimeMessage(data) {
			try {
				const event = JSON.parse(data);

				switch (event.type) {
					case 'session.created':
						this.sessionId = event.session.id;
						window._sitestaffrTestMode.flag(this.sessionId, this.config.ajaxUrl);
						break;

					case 'session.updated':
						// Trigger AI greeting AFTER session config is confirmed
						if (!this.greetingComplete) {
							this.triggerAIGreeting();
						} else if (this._reconnectAttempts > 0) {
							// Reconnected session — trigger resume response (no greeting)
							this.sendMessage({
								type: 'response.create',
								response: {
									output_modalities: ['audio'],
									instructions: 'SESSION RESUMED: Do NOT greet or introduce yourself. Say: "I\'m sorry, I had a brief technical issue. Could you repeat your last question?"'
								}
							});
							this.setState('speaking');
						}
						break;

					case 'input_audio_buffer.speech_started':
						this.isUserSpeaking = true;
						this.silenceChecksSent = 0;
						this.clearSilenceTimer();
						this.startUtteranceTimer();
						this.setMicrophoneMuted(false);
						this.setState('listening');
						break;

					case 'input_audio_buffer.speech_stopped':
						this.isUserSpeaking = false;
						this.clearUtteranceTimers();
						this.setState('thinking');
						break;

					case 'conversation.item.input_audio_transcription.completed':
						// User's speech transcribed
						this.currentUserText = event.transcript;
						this.conversationTranscript.push({
							role: 'user',
							text: this.currentUserText,
							timestamp: new Date().toISOString()
						});
						// Track user item ID for context trimming
						if (event.item_id) {
							this.conversationItemIds.push(event.item_id);
						}
						// Reset recovery counter — new user speech means fresh attempt allowed.
						this._contextRecoveryAttempts = 0;
						// Page_scoped always-on retrieval: search, inject context, then respond.
						if (this._pageScopedAutoRetrieval && !this.initialGreetingInProgress && event.transcript) {
							this._doPageScopedRetrieval(event.transcript);
						}
						break;

					case 'response.created':
						// Set speaking state immediately for instant UI feedback
						this.isUserSpeaking = false;
						this.setState('speaking');
						// Mute microphone to prevent false speech detection
						this.setMicrophoneMuted(true);
						this.currentAiText = '';
						// Safety net: ensure audio element is playing (autoplay can silently fail)
						if (this.audioElement && this.audioElement.paused) {
							this.audioElement.play().catch(() => {});
						}
						break;

					case 'response.output_item.added':
						if (event.item && event.item.type === 'function_call') {
							if (event.item.name === 'search_knowledge') {
								this.searchPending = true;
							}
						} else if (event.item && event.item.id && event.item.type === 'message') {
							// Track AI message item ID for context trimming
							this.conversationItemIds.push(event.item.id);
						}
						break;

					case 'response.content_part.added':
						// Content part added
						break;

					case 'response.audio_transcript.delta':
						// AI's speech text (streaming)
						if (event.delta) {
							this.currentAiText += event.delta;
							// If we're receiving deltas, AI is speaking
							if (this.state !== 'speaking') {
								this.setState('speaking');
							}
						}
						break;

					case 'response.audio_transcript.done': {
						const cleanText = this.stripToolTokens(this.currentAiText);
						if (cleanText && cleanText.length > 0) {
							this.conversationTranscript.push({
								role: 'assistant',
								text: cleanText,
								timestamp: new Date().toISOString()
							});
						}
						if (this.initialGreetingInProgress && cleanText && cleanText.trim().length > 0) {
							this.initialGreetingInProgress = false;
						}
						// Fallback farewell detection in case end_session tool wasn't invoked
						this.checkForConversationEnd(this.currentAiText);
						break;
					}

					case 'response.output_audio_transcript.done':
						if (event.transcript) {
							const cleanText = this.stripToolTokens(event.transcript);
							if (cleanText && cleanText.length > 0) {
								this.conversationTranscript.push({
									role: 'assistant',
									text: cleanText,
									timestamp: new Date().toISOString()
								});
							}
							if (this.initialGreetingInProgress && cleanText && cleanText.trim().length > 0) {
								this.initialGreetingInProgress = false;
							}
							// Fallback farewell detection in case end_session tool wasn't invoked
							this.checkForConversationEnd(event.transcript);
						}
						break;

					case 'response.audio.done':
						break;

					case 'response.function_call_arguments.done':
						// Function calling: model invoked a tool
						if (event.name && event.call_id) {
							let parsedArgs = {};
							try {
								parsedArgs = JSON.parse(event.arguments || '{}');
							} catch (parseErr) {
								console.error('SiteStaffr: Failed to parse function call arguments:', parseErr);
							}
							this.handleFunctionCall(event.name, event.call_id, parsedArgs, event.item_id);
						}
						break;

					case 'response.done':
						// Usage may appear in different shapes depending on Realtime event payload.
						const usagePayload =
							(event.response && event.response.usage) ||
							event.usage ||
							(event.response && event.response.output && event.response.output[0] && event.response.output[0].usage) ||
							null;
						if (usagePayload) {
							this.accumulateRealtimeUsage(usagePayload);
						}
						// Handle failed responses (context overflow, rate limit, or transient API error)
						if (event.response && event.response.status === 'failed') {
							var errorDetail = event.response.status_details || {};
							var errorCode = (errorDetail.error && errorDetail.error.code) || '';
							var errorMessage = (errorDetail.error && errorDetail.error.message) || '';
							var self = this;
							// Recovery retries must include continuation instruction so the model
							// doesn't fall back to greeting behavior after context has been trimmed.
							var lastQuestion = this.currentUserText || '';
							var recoveryInstructions = 'You are in the MIDDLE of a conversation. Do NOT greet or re-introduce yourself.';
							if (lastQuestion) {
								recoveryInstructions += ' The visitor just asked: "' + lastQuestion.replace(/"/g, "'") + '". Answer this question.';
							} else {
								recoveryInstructions += ' Answer the visitor\'s most recent question.';
							}
							var recoveryResponse = {
								type: 'response.create',
								response: {
									output_modalities: ['audio'],
									instructions: recoveryInstructions
								}
							};

							// Rate limit: wait the suggested time and retry (no trim, no recovery escalation)
							if (errorCode === 'rate_limit_exceeded') {
								this._rateLimitRetries = (this._rateLimitRetries || 0) + 1;
								// Parse "Please try again in 3.652s" from the error message
								var waitMatch = errorMessage.match(/try again in ([\d.]+)s/);
								var waitMs = waitMatch ? Math.ceil(parseFloat(waitMatch[1]) * 1000) + 500 : 4000;
								console.warn('[Recovery] Rate limited (retry ' + this._rateLimitRetries + '), waiting ' + waitMs + 'ms');
								if (this._rateLimitRetries <= 3) {
									setTimeout(function() {
										if (self.inConversation && self.dataChannel && self.dataChannel.readyState === 'open') {
											self.sendMessage(recoveryResponse);
										}
									}, waitMs);
								} else {
									// Persistent rate limit — trim aggressively to reduce token footprint
									console.warn('[Recovery] Persistent rate limit, trimming context');
									this.trimConversationContext(4);
									this._rateLimitRetries = 0;
									setTimeout(function() {
										if (self.inConversation && self.dataChannel && self.dataChannel.readyState === 'open') {
											self.sendMessage(recoveryResponse);
										}
									}, waitMs);
								}
							} else {
								// Context overflow or other API error — escalating recovery
								this._contextRecoveryAttempts = (this._contextRecoveryAttempts || 0) + 1;
								console.error('[Recovery] Context overflow (attempt ' + this._contextRecoveryAttempts + ')',
									JSON.stringify(errorDetail));
								if (this._contextRecoveryAttempts === 1) {
									// First failure: moderate trim + delayed retry (let deletes propagate)
									this.trimConversationContext(4);
									setTimeout(function() {
										if (self.inConversation && self.dataChannel && self.dataChannel.readyState === 'open') {
											self.sendMessage(recoveryResponse);
										}
									}, 500);
								} else if (this._contextRecoveryAttempts === 2) {
									// Second failure: nuclear cleanup — delete all message + search items
									var allItems = this.conversationItemIds.concat(this.searchItemIds);
									for (var i = 0; i < allItems.length; i++) {
										this.sendMessage({ type: 'conversation.item.delete', item_id: allItems[i] });
									}
									this.conversationItemIds = [];
									this.searchItemIds = [];
									// Greeting already stripped from sessionInstructions in
									// triggerAIGreeting() — no session.update needed here.
									setTimeout(function() {
										if (self.inConversation && self.dataChannel && self.dataChannel.readyState === 'open') {
											self.sendMessage(recoveryResponse);
										}
									}, 1000);
								} else if (this._contextRecoveryAttempts >= 3) {
									// Unrecoverable via trimming — attempt session reconnect
									this.reconnectSession();
								}
							}
						} else {
							this._contextRecoveryAttempts = 0;
							this._rateLimitRetries = 0;
						}
						// If the response contained function calls but no search is pending,
						// the model needs response.create to continue.
						// Skip when end_session already fired (shouldAutoHangup) to prevent duplicate goodbye.
						// Skip during initial greeting — search_knowledge handler already sent response.create.
						if (event.response && event.response.status === 'completed' &&
							this.searchingCount === 0 && !this.searchPending && !this.shouldAutoHangup &&
							!this.initialGreetingInProgress &&
							event.response.output && event.response.output.some(function(o) { return o.type === 'function_call'; })) {
							this.sendMessage({ type: 'response.create' });
						}
						// Proactive context trimming on successful responses
						if (this.conversationItemIds.length > 10) {
							this.trimConversationContext(6);
						}
						if (this.initialGreetingInProgress && (!this.currentAiText || this.currentAiText.trim().length === 0)) {
							// Safety guard for responses that finish without transcript text.
							this.initialGreetingInProgress = false;
						}
						if (this.shouldAutoHangup) {
							this.scheduleAutoHangupFallback(15000);
						}
						break;

					case 'output_audio_buffer.stopped':
						// Don't transition while a search is running
						if (this.searchingCount > 0 || this.searchPending) break;
						this.setMicrophoneMuted(false);
						if (this.inConversation && !this.shouldAutoHangup && !this.initialGreetingInProgress) {
							this.setState('listening');
						}
						if (this.shouldAutoHangup && this.inConversation) {
							// Respect minimum delay so farewell audio can play.
							var remaining = (this._endSessionMinDelay || 0) - Date.now();
							if (remaining > 0) {
								setTimeout(() => {
									if (this.shouldAutoHangup && this.inConversation) {
										this.clearAutoHangupFallback();
										this.shouldAutoHangup = false;
										this.endConversation('conversation_complete');
									}
								}, remaining);
							} else {
								this.clearAutoHangupFallback();
								this.shouldAutoHangup = false;
								this.endConversation('conversation_complete');
							}
						}
						break;

					case 'error':
						// Silently ignore item-delete failures (stale IDs after context trimming)
						if (event.error && (
							event.error.code === 'item_delete_invalid_item_id' ||
							event.error.code === 'item_truncate_invalid_item_id'
						)) {
							break;
						}
						console.error('SiteStaffr: API error:', JSON.stringify(event.error));
						this.callbacks.onError(event.error.message || 'API error');
						break;

					case 'rate_limits.updated':
						// Token usage tracking (for cost analysis)
						break;

					default:
						// These events are informational - no action needed
						// Only log truly unexpected events
						const knownInfoEvents = [
							'conversation.item.added',
							'conversation.item.done',
							'response.content_part.done',
							'response.output_item.done',
							'input_audio_buffer.committed',
							'output_audio_buffer.started',
							'response.output_audio.done'
						];
						if (!event.type.includes('delta') && !knownInfoEvents.includes(event.type)) {
						}
				}
			} catch (error) {
				console.error('SiteStaffr: Failed to parse Realtime message:', error);
			}
		}

		/**
		 * Fallback: check if AI response contains farewell phrases.
		 * Covers cases where the model fails to invoke end_session tool.
		 */
		checkForConversationEnd(text) {
			const lowerText = text.toLowerCase();
			const endPortion = lowerText.slice(-60);

			for (const phrase of this.endPhrases) {
				if (endPortion.includes(phrase)) {
					this.shouldAutoHangup = true;
					if (this.config.persona === 'onboarding') {
						this.endSessionCalled = true;
					}
					return;
				}
			}
		}

		/**
		 * Strip internal tool-call tokens that the realtime model occasionally
		 * emits as literal spoken/transcript text instead of invoking silently
		 * (e.g. a farewell that ends "...have a great day! end_session").
		 *
		 * Deterministic plugin-side guard so saved transcripts — magic-link
		 * viewer, admin call detail, report-issue email — never display the raw
		 * token even when prompt hardening fails to suppress it. The token names
		 * are not natural language, so removing them is always safe.
		 */
		stripToolTokens(text) {
			if (typeof text !== 'string' || !text) {
				return text;
			}
			// Optional trailing call parens cover "end_session()" style emissions.
			const toolTokens = /\b(?:end_session|update_contact_field|search_knowledge|begin_contact_collection|begin_answering|begin_farewell)\b\s*(?:\([^)]*\))?/gi;
			return text
				.replace(toolTokens, '')
				.replace(/\s+([.,!?;:])/g, '$1')
				.replace(/\s{2,}/g, ' ')
				.trim();
		}

		sendFunctionCallOutput(callId, payload) {
			this.sendMessage({
				type: 'conversation.item.create',
				item: {
					type: 'function_call_output',
					call_id: callId,
					output: JSON.stringify(payload)
				}
			});
		}

		/**
		 * Handle a function call from the Realtime API.
		 *
		 * Two tools are supported:
		 * - update_contact_field: Updates contactMemory with captured visitor info
		 * - end_session: Signals the AI wants to end the conversation
		 */
		handleFunctionCall(name, callId, args, itemId) {
			switch (name) {
				case 'update_contact_field': {
					const field = args.field;
					const value = args.value;
					if (field && value && this.contactMemory.hasOwnProperty(field)) {
						var stringValue = String(value);

						// Phone validation: US numbers must be exactly 10 digits.
						// International numbers with + prefix accepted as-is.
						if (field === 'phone') {
							var hasCountryCode = stringValue.trim().charAt(0) === '+';
							var phoneDigits = stringValue.replace(/\D/g, '');
							var isValidUS = phoneDigits.length === 10 || (phoneDigits.length === 11 && phoneDigits.charAt(0) === '1');
							if (!hasCountryCode && !isValidUS) {
								this.sendFunctionCallOutput(callId, {
									success: false,
									message: 'Only ' + phoneDigits.length + ' digits received. US phone numbers need exactly 10 digits — tell the visitor how many digits you got and ask for the complete number.'
								});
								break;
							}
							if (!hasCountryCode && isValidUS) {
								var d = phoneDigits.length === 11 ? phoneDigits.substring(1) : phoneDigits;
								stringValue = d.substring(0, 3) + '-' + d.substring(3, 6) + '-' + d.substring(6);
							}
						}

						var changed = this.contactMemory[field] !== stringValue;
						this.contactMemory[field] = stringValue;
						if (changed) {
							this.callbacks.onFieldUpdate(field, stringValue);
							// Refresh VISITOR CONTEXT in current mode's instructions
							// so the AI sees the updated field immediately.
							if (this.sessionModes) {
								this.refreshVisitorContext();
							}
						}
						var finalValue = stringValue;
						if (field === 'email' && finalValue.indexOf('-') !== -1) {
							var visitorSaidDash = /\b(dash|hyphen)\b/i.test(this.currentUserText || '');
							if (!visitorSaidDash) {
								finalValue = finalValue.replace(/-/g, '');
								this.contactMemory[field] = finalValue;
								this.callbacks.onFieldUpdate(field, finalValue);
								if (this.sessionModes) {
									this.refreshVisitorContext();
								}
							}
						}
						// GTM dataLayer: lead field captured
						(window.dataLayer = window.dataLayer || []).push({
							event: 'sitestaffr_lead_captured',
							sitestaffr_field: field,
							sitestaffr_mode: 'voice',
							sitestaffr_session_id: this.sessionId,
						});

						// GTM dataLayer: qualified lead (name + phone or name + email)
						if (!this._qualifiedLeadFired && this.contactMemory.name) {
							if (this.contactMemory.phone || this.contactMemory.email) {
								this._qualifiedLeadFired = true;
								var qualifiedFields = Object.keys(this.contactMemory).filter(function(k) { return !!this.contactMemory[k]; }.bind(this));
								(window.dataLayer = window.dataLayer || []).push({
									event: 'sitestaffr_qualified_lead',
									sitestaffr_mode: 'voice',
									sitestaffr_session_id: this.sessionId,
									sitestaffr_fields_captured: qualifiedFields,
									sitestaffr_service_interest: this.contactMemory.reason || '',
								});
							}
						}

						var output = { success: true, field: field, value: finalValue };
						var snapshot = this.formatContactSnapshot();
						output.message = 'Saved. ' + snapshot;
						this.sendFunctionCallOutput(callId, output);
						break;
					}
					this.sendFunctionCallOutput(callId, { success: true });
					break;
				}

				case 'search_knowledge': {
					// Suppress search during the initial greeting — return empty results
					// so the model just delivers its greeting without RAG data.
					if (this.initialGreetingInProgress) {
						this.searchPending = false;
						this.sendMessage({
							type: 'conversation.item.create',
							item: {
								type: 'function_call_output',
								call_id: callId,
								output: JSON.stringify({ results: [], message: 'Search unavailable during greeting' }),
							},
						});
						this.sendMessage({ type: 'response.create', response: { output_modalities: ['audio'] } });
						break;
					}
					// Trim older conversation items to prevent audio token overflow.
					this.trimConversationContext(4);
					// Keep last search result set so the AI can answer follow-ups
					// without re-searching. Delete older sets only.
					if (this.searchItemIds.length > 2) {
						var oldItems = this.searchItemIds.splice(0, this.searchItemIds.length - 2);
						for (var si = 0; si < oldItems.length; si++) {
							this.sendMessage({ type: "conversation.item.delete", item_id: oldItems[si] });
						}
					}
					// Track the function_call item for future cleanup.
					if (itemId) { this.searchItemIds.push(itemId); }

					const query = args.query;
					if (!query) {
						this.sendMessage({
							type: 'conversation.item.create',
							item: {
								type: 'function_call_output',
								call_id: callId,
								output: JSON.stringify({ error: 'No query provided' }),
							},
						});
						this.sendMessage({ type: 'response.create' });
						break;
					}

					// Show searching indicator so widget displays contextual search text.
					this.searchPending = false;
					this.searchingCount++;
					this.callbacks.onSearchStart && this.callbacks.onSearchStart(query);
					this.setState('searching');

					// Relay search via WordPress AJAX.
					var self = this;
					var searchPostId = (window.sitestaffr_widget_data && window.sitestaffr_widget_data.knowledge_mode === 'page_scoped')
						? (window.sitestaffr_widget_data.current_post_id || null)
						: null;
					SiteStaffrKnowledgeSearch.search(query, 3, searchPostId)
						.then(function(result) {
							self.searchingCount = Math.max(0, self.searchingCount - 1);
							if (self.searchingCount === 0) {
								self.callbacks.onSearchEnd && self.callbacks.onSearchEnd();
							}

							var outputItemId = 'search_output_' + Date.now();
							var output = SiteStaffrKnowledgeSearch.formatForFunctionOutput(result);

							if (!self.dataChannel || self.dataChannel.readyState !== 'open') {
								return;
							}

							self.sendMessage({
								type: 'conversation.item.create',
								item: {
									id: outputItemId,
									type: 'function_call_output',
									call_id: callId,
									output: output,
								},
							});
							self.sendMessage({ type: 'response.create' });
							self.searchItemIds.push(outputItemId);
						})
						.catch(function(err) {
							self.searchingCount = Math.max(0, self.searchingCount - 1);
							if (self.searchingCount === 0) {
								self.callbacks.onSearchEnd && self.callbacks.onSearchEnd();
							}
							if (!self.dataChannel || self.dataChannel.readyState !== 'open') {
								return;
							}
							self.sendMessage({
								type: 'conversation.item.create',
								item: {
									type: 'function_call_output',
									call_id: callId,
									output: JSON.stringify({ error: 'Search failed' }),
								},
							});
							self.sendMessage({ type: 'response.create' });
						});
					break;
				}


				case 'begin_contact_collection': {
					var bccResponse = (this.lifecycleResponses && this.lifecycleResponses.begin_contact_collection)
						|| 'Collection mode active.';
					var mem = this.contactMemory || {};
					var hasName = !!mem.name;
					// "Nothing left to collect" means every contact field is already on
					// file — NOT merely a name plus one method. Treating name+phone as
					// complete locked the widget out of collecting mode for the rest of
					// the call, so an email given later was captured under ANSWERING-mode
					// instructions, which carry update_contact_field but none of the
					// spelling / spell-back / single-confirmation rules. That is how
					// "S-I-N-G-H-S" was stored as "sings" (Loria Medical 2026-08-08).
					var nothingLeftToCollect = hasName && !!mem.phone && !!mem.email;
					if (nothingLeftToCollect) {
						var allFields = [];
						if (mem.name) allFields.push('name: ' + mem.name);
						if (mem.phone) allFields.push('phone: ' + mem.phone);
						if (mem.email) allFields.push('email: ' + mem.email);
						if (mem.reason) allFields.push('reason: ' + mem.reason);
						this.sendFunctionCallOutput(callId, {
							success: true,
							message: 'All contact fields already captured (' + allFields.join(', ') + '). Do NOT transition to collection. Stay in current mode. Confirm the follow-up inline and ask "Anything else?"'
						});
					} else if (this.conversationMode === 'collecting') {
						this.sendFunctionCallOutput(callId, { success: true, message: 'Already in collection mode.' });
					} else {
						this.transitionToMode('collecting');
						var bccOutput = { success: true, message: bccResponse };
						var captured = [];
						if (mem.name) captured.push('name: ' + mem.name);
						if (mem.phone) captured.push('phone: ' + mem.phone);
						if (mem.email) captured.push('email: ' + mem.email);
						if (mem.reason) captured.push('reason: ' + mem.reason);
						if (captured.length > 0) {
							bccOutput.already_captured = captured.join(', ');
							bccOutput.message += ' Already captured: ' + captured.join(', ') + '. Do NOT re-ask for these fields.';
						}
						this.sendFunctionCallOutput(callId, bccOutput);
					}
					// response.create deferred to response.done handler
					break;
				}

				case 'begin_answering': {
					var baResponse = (this.lifecycleResponses && this.lifecycleResponses.begin_answering)
						|| 'Answering mode active.';
					if (this.conversationMode === 'answering') {
						this.sendFunctionCallOutput(callId, { success: true, message: 'Already in answering mode.' });
					} else {
						this.transitionToMode('answering');
						this.sendFunctionCallOutput(callId, { success: true, message: baResponse });
					}
					// response.create deferred to response.done handler
					break;
				}

				case 'begin_farewell': {
					var bfResponse = (this.lifecycleResponses && this.lifecycleResponses.begin_farewell)
						|| 'Farewell mode active.';
					this.transitionToMode('farewell');
					this.sendFunctionCallOutput(callId, { success: true, message: bfResponse });
					// response.create deferred to response.done handler
					break;
				}

				case 'end_session':
					// Reject end_session during greeting — model sometimes calls it
					// instead of speaking. Return error and re-trigger greeting.
					if (this.initialGreetingInProgress) {
						this.sendMessage({
							type: 'conversation.item.create',
							item: {
								type: 'function_call_output',
								call_id: callId,
								output: JSON.stringify({ error: 'Do not end the session. Deliver your greeting first.' })
							}
						});
						this.sendMessage({ type: 'response.create', response: { output_modalities: ['audio'] } });
						break;
					}
					// Acknowledge the tool call (no response.create — model should stop)
					this.sendMessage({
						type: 'conversation.item.create',
						item: {
							type: 'function_call_output',
							call_id: callId,
							output: JSON.stringify({ success: true })
						}
					});
					this.endSessionCalled = true;
					this.shouldAutoHangup = true;
					this._endSessionMinDelay = Date.now() + 3000;
					setTimeout(() => {
						if (this.shouldAutoHangup && this.inConversation) {
							this.endConversation('conversation_complete');
						}
					}, 3000);
					this.scheduleAutoHangupFallback(8000);
					break;

				default:
					// Unknown tool — acknowledge to avoid model stall
					this.sendMessage({
						type: 'conversation.item.create',
						item: {
							type: 'function_call_output',
							call_id: callId,
							output: JSON.stringify({ error: 'unknown_tool' })
						}
					});
					this.sendMessage({ type: 'response.create' });
					break;
			}
		}

		/**
		 * Build an empty usage accumulator for realtime token metrics.
		 */
		createEmptyRealtimeUsageTotals() {
			return {
				input_tokens: 0,
				output_tokens: 0,
				cached_input_tokens: 0,
				input_audio_tokens: 0,
				input_text_tokens: 0,
				cached_input_audio_tokens: 0,
				cached_input_text_tokens: 0,
				output_audio_tokens: 0,
				output_text_tokens: 0,
				response_count: 0,
				model: 'realtime-session'
			};
		}

		/**
		 * Build empty contact memory tracked during the active session.
		 */
		createEmptyContactMemory() {
			if (this.config.persona === 'onboarding') {
				return {
					name: '',
					business_name: '',
					website_url: '',
					email: '',
					phone: ''
				};
			}
			return {
				name: '',
				phone: '',
				email: '',
				reason: ''
			};
		}

		/**
		 * Reconnect the voice session when context recovery fails.
		 *
		 * Tears down the current WebRTC connection and establishes a fresh one,
		 * preserving contactMemory and recent transcript so the AI
		 * can resume naturally without re-greeting or losing collected information.
		 */
		async reconnectSession() {
			// Prevent infinite reconnect loops
			this._reconnectAttempts = (this._reconnectAttempts || 0) + 1;
			if (this._reconnectAttempts > 2) {
				this.callbacks.onError && this.callbacks.onError(
					'Session interrupted. Please start a new conversation.'
				);
				this.endConversation('session_error');
				return;
			}

			// Snapshot state we need to preserve BEFORE teardown
			var preservedContact = Object.assign({}, this.contactMemory);
			var preservedMode = this.conversationMode;
			var preservedTranscript = this.conversationTranscript.slice();

			// Set reconnecting flag to prevent close/disconnect handlers from ending conversation
			this._isReconnecting = true;

			// Tear down old connection (but keep mediaStream alive for reuse)
			if (this.dataChannel) {
				try { this.dataChannel.close(); } catch (e) {}
				this.dataChannel = null;
			}
			if (this.peerConnection) {
				try { this.peerConnection.close(); } catch (e) {}
				this.peerConnection = null;
			}
			if (this.audioElement) {
				this.audioElement.srcObject = null;
				this.audioElement = null;
			}

			this._isReconnecting = false;

			// Reset item tracking (new session = clean context)
			this.conversationItemIds = [];
			this.searchItemIds = [];
			this._contextRecoveryAttempts = 0;

			try {
				// Reuse existing mediaStream (mic already active), but request new if lost
				if (!this.mediaStream || this.mediaStream.getAudioTracks().length === 0) {
					await this.requestMicrophonePermission();
				}

				// Restore preserved state
				this.contactMemory = preservedContact;
				this.conversationMode = preservedMode;

				// Mark greeting as complete so session.updated doesn't trigger a new greeting
				this.greetingComplete = true;
				this.initialGreetingInProgress = false;

				// Store resume context — connectRealtimeAPI will overwrite sessionInstructions
				// with fresh middleware instructions, so we compose afterward.
				this._resumeContext = {
					contact: preservedContact,
					transcript: preservedTranscript,
					mode: preservedMode
				};

				// Establish new WebRTC connection (fetches new SDP from middleware)
				await this.connectRealtimeAPI();

			} catch (error) {
				console.error('Session reconnect failed:', error);
				this.callbacks.onError && this.callbacks.onError(
					'Session interrupted. Please start a new conversation.'
				);
				this.endConversation('session_error');
			}
		}

		/**
		 * Build modified instructions for a reconnected session.
		 *
		 * Prepends a resume context block to the original system instructions so
		 * the AI continues naturally with preserved contact info and conversation history.
		 */
		buildResumeInstructions(originalInstructions, contact, transcript) {
			var resumeParts = [];
			resumeParts.push('SESSION RESUMED: This is a continuation of an ongoing conversation. Do NOT greet the visitor again. Do NOT introduce yourself. Continue naturally from where you left off.');

			// Inject collected contact information
			var contactEntries = [];
			for (var key in contact) {
				if (contact.hasOwnProperty(key) && contact[key]) {
					contactEntries.push(key + ': ' + contact[key]);
				}
			}
			if (contactEntries.length > 0) {
				resumeParts.push('COLLECTED CONTACT INFO (already gathered, do not ask again): ' + contactEntries.join(', '));
			}

			// Inject recent transcript (last 6 turns max to stay within token budget)
			if (transcript && transcript.length > 0) {
				var recentTurns = transcript.slice(-6);
				var turnLines = recentTurns.map(function(t) {
					return (t.role === 'user' ? 'Visitor' : 'AI') + ': ' + (t.text || '').substring(0, 200);
				});
				resumeParts.push('RECENT CONVERSATION:\n' + turnLines.join('\n'));
			}

			resumeParts.push('Resume the conversation now. Say: "I\'m sorry, I had a brief technical issue. Could you repeat your last question?" Then wait for the visitor to respond.');

			// Strip the greeting section — it conflicts with "do not greet" and causes
			// the model to repeat the greeting after context recovery or reconnect.
			var cleanedInstructions = this.stripGreetingFromInstructions(originalInstructions || '');
			return resumeParts.join('\n\n') + '\n\n---\n\n' + cleanedInstructions;
		}

		/**
		 * Strip the greeting section from session instructions.
		 *
		 * After the initial greeting, the "FIRST RESPONSE RULE" section must be
		 * removed before any context recovery or session reconnect. Otherwise the
		 * model sees "HIGHEST PRIORITY: say this greeting" alongside "do not greet"
		 * and the greeting wins.
		 */
		stripGreetingFromInstructions(instructions) {
			// Match "## YOUR GREETING\n...everything until next ##" or end of string
			return instructions.replace(/## YOUR GREETING[\s\S]*?(?=\n## |$)/, '## GREETING\nThe visitor has already been greeted. Do NOT greet or introduce yourself. Continue the conversation naturally.');
		}

		/**
		 * Build a valid session.update payload for the Realtime API.
		 */
		buildSessionUpdatePayload(instructionsText) {
			this._pageScopedAutoRetrieval = (
				window.sitestaffr_widget_data &&
				window.sitestaffr_widget_data.knowledge_mode === 'page_scoped' &&
				window.sitestaffr_widget_data.current_post_id > 0
			);

			const payload = {
				type: 'realtime',
				output_modalities: ['audio'],
				audio: {
					input: {
						turn_detection: {
							type: 'semantic_vad',
							// Collecting mode: visitors pause mid-recall while dictating
							// digits or spelling emails — low eagerness keeps the AI from
							// talking over them ("It's uh..." → instant re-prompt).
							eagerness: this.conversationMode === 'collecting' ? 'low' : 'high',
							interrupt_response: false,
							create_response: !this._pageScopedAutoRetrieval
						}
					},
					output: {}
				},
				instructions: instructionsText || ''
			};

			// Preserve voice from SDP exchange — without this, session.update
			// can overwrite the audio config and lose the voice setting.
			if (this.sessionVoice) {
				payload.audio.output.voice = this.sessionVoice;
			}

			if (this.sessionModel) {
				payload.model = this.sessionModel;
			}

			if (this.transcriptionModel) {
				payload.audio.input.transcription = {
					model: this.transcriptionModel
				};
			}

			if (this.sessionTools && this.sessionTools.length > 0) {
				// Fix PHP json_encode converting empty {} to [] during passthrough
				payload.tools = this.sessionTools.map(tool => {
					if (!tool.parameters || Array.isArray(tool.parameters)) {
						tool.parameters = { type: 'object', properties: {} };
					} else if (Array.isArray(tool.parameters.properties)) {
						tool.parameters.properties = {};
					}
					return tool;
				});
			}

			return payload;
		}

		/**
		 * Add one response.usage payload into the aggregate usage totals.
		 */
		accumulateRealtimeUsage(usage) {
			if (!usage || typeof usage !== 'object') {
				return;
			}

			const inputDetails = usage.input_token_details || usage.input_tokens_details || {};
			const outputDetails = usage.output_token_details || usage.output_tokens_details || {};
			const cachedInputTokens = Number(
				usage.cached_input_tokens ||
				usage.cache_read_input_tokens ||
				inputDetails.cached_tokens ||
				0
			);

			this.realtimeUsageTotals.input_tokens += Number(usage.input_tokens || 0);
			this.realtimeUsageTotals.output_tokens += Number(usage.output_tokens || 0);
			this.realtimeUsageTotals.cached_input_tokens += cachedInputTokens;
			this.realtimeUsageTotals.input_audio_tokens += Number(inputDetails.audio_tokens || 0);
			this.realtimeUsageTotals.input_text_tokens += Number(inputDetails.text_tokens || 0);
			this.realtimeUsageTotals.cached_input_audio_tokens += Number(inputDetails.cached_audio_tokens || 0);
			this.realtimeUsageTotals.cached_input_text_tokens += Number(inputDetails.cached_text_tokens || 0);
			this.realtimeUsageTotals.output_audio_tokens += Number(outputDetails.audio_tokens || 0);
			this.realtimeUsageTotals.output_text_tokens += Number(outputDetails.text_tokens || 0);
			this.realtimeUsageTotals.response_count += 1;
		}


		/**
		 * beforeunload handler — fires sendBeacon to save transcript + finalize
		 * billing when the page unloads mid-call (reload, close tab, navigate away).
		 */
		_onBeforeUnload() {
			this._beaconSave();
		}

		/**
		 * Fire-and-forget save via navigator.sendBeacon.
		 *
		 * sendBeacon is guaranteed to be queued by the browser even during page
		 * teardown.  We send two beacons: one for transcript save (WordPress),
		 * one for usage finalization (WordPress AJAX bridge → middleware).
		 */
		_beaconSave() {
			if (!this.inConversation && this.conversationTranscript.length === 0) {
				return;
			}

			const nonce = window.sitestaffr_widget_data?.save_nonce || window.sitestaffr_button_data?.save_nonce || '';
			const finalizeNonce = window.sitestaffr_widget_data?.finalize_nonce || window.sitestaffr_button_data?.finalize_nonce || '';
			if (!nonce) {
				return;
			}

			// OVER-BILLING GUARD (2026-08-10): this path measures wall-clock from conversation
			// start to TAB CLOSE, not to call end. On a clean hangup that is harmless — cleanup
			// nulls conversationStartTime first, so this is 0 — but browsers suspend the
			// scheduleSessionDurationCap() setTimeout in background tabs, so a visitor who starts
			// a voice chat and switches away leaves the conversation open and closes the tab hours
			// later. That elapsed time was being reported as billable duration.
			//
			// Measured on production: 18 of 688 usage events carried 55.7% of all voice minutes
			// ever recorded, every one above the 10-minute cap. No paying site was affected
			// (exempt sandbox + legacy dev installs), but the mechanism was live.
			//
			// The server clamps this too (services/voice-duration-clamp.js) and IS the control —
			// a client-reported billing quantity can never be trusted. This is defence in depth.
			const elapsedSeconds = this.conversationStartTime
				? Math.floor((Date.now() - this.conversationStartTime) / 1000)
				: 0;
			// Math.max guards a backwards clock jump: a negative duration is rejected by the
			// middleware (400) and the session would go silently unbilled.
			const duration = Math.max(0, Math.min(elapsedSeconds, Math.floor(this.maxSessionDurationMs / 1000)));
			const sessionId = this.sessionId || 'unknown';
			const channelSource = this.config.channelSource || 'widget';
			const capturedEmail = (this.contactMemory && typeof this.contactMemory.email === 'string')
				? this.contactMemory.email.trim()
				: '';
			const capturedPhone = (this.contactMemory && typeof this.contactMemory.phone === 'string')
				? this.contactMemory.phone.trim()
				: '';
			const saveAction = (channelSource === 'button')
				? 'sitestaffr_button_save_conversation'
				: 'sitestaffr_save_conversation';
			const finalizeAction = (channelSource === 'button')
				? 'sitestaffr_button_finalize_usage'
				: 'sitestaffr_finalize_usage';

			// Beacon #1: save transcript
			if (this.conversationTranscript.length > 0) {
				const saveParams = new URLSearchParams();
				saveParams.append('action', saveAction);
				saveParams.append('nonce', nonce);
				saveParams.append('session_id', sessionId);
				saveParams.append('duration', String(duration));
				saveParams.append('transcript', JSON.stringify(this.conversationTranscript));
				saveParams.append('realtime_usage', JSON.stringify(this.realtimeUsageTotals));
				saveParams.append('channel_source', channelSource);
				saveParams.append('persona', this.config.persona || 'default');
				saveParams.append('contact_fields', JSON.stringify(this.contactMemory));
				// Tab-close during the farewell delay still counts as the AI wrapping up.
				saveParams.append('ended_by', this.endSessionCalled ? 'ai_ended' : 'visitor_left');
				navigator.sendBeacon(this.config.ajaxUrl, saveParams);
			}

			// Beacon #2: finalize usage
			if (finalizeNonce && sessionId !== 'unknown') {
				const finalizeParams = new URLSearchParams();
				finalizeParams.append('action', finalizeAction);
				finalizeParams.append('nonce', finalizeNonce);
				finalizeParams.append('session_id', sessionId);
				finalizeParams.append('duration', String(duration));
				finalizeParams.append('channel_source', channelSource);
				if (capturedEmail) {
					finalizeParams.append('contact_email', capturedEmail);
				}
				if (capturedPhone) {
					finalizeParams.append('contact_phone', capturedPhone);
				}
				navigator.sendBeacon(this.config.ajaxUrl, finalizeParams);
			}

			// Prevent double-fire if endConversation also runs
			this.inConversation = false;
			this.conversationTranscript = [];
		}

		/**
		 * Map an endConversation() reason onto the ended_by contract:
		 * 'ai_ended' | 'visitor_left' | 'timed_out' | null.
		 *
		 * Ambiguous ends (connection_lost, session_error, unknown) stay null
		 * deliberately — never guess.
		 */
		_resolveEndedBy(reason) {
			if (reason === 'conversation_complete') {
				return this._endedByHint === 'timed_out' ? 'timed_out' : 'ai_ended';
			}
			if (reason === 'inactivity_timeout') {
				return 'timed_out';
			}
			if (reason === 'visitor_close' || reason === 'user_hangup') {
				return 'visitor_left';
			}
			return null;
		}

		/**
		 * End conversation
		 *
		 * Reordered to disconnect first, play sound, THEN save transcript asynchronously.
		 * This ensures the call feels "over" before the database save happens.
		 */
		async endConversation(reason = 'unknown') {
			// Re-entrancy guard to prevent multiple calls
			if (this.isEndingConversation) {
				return;
			}
			this.isEndingConversation = true;
			const endTime = Date.now();
			const sessionIdSnapshot = this.sessionId;
			const conversationStartTimeSnapshot = this.conversationStartTime;
			const realtimeUsageSnapshot = Object.assign({}, this.realtimeUsageTotals);
			const endedBySnapshot = this._resolveEndedBy(reason);

			this.inConversation = false;

			// Remove unload safety nets BEFORE the async save to prevent beacon
			// from racing with the normal save path.
			if (this._boundBeforeUnload) {
				window.removeEventListener('beforeunload', this._boundBeforeUnload);
				this._boundBeforeUnload = null;
			}
			if (this._boundPageHide) {
				window.removeEventListener('pagehide', this._boundPageHide);
				this._boundPageHide = null;
			}

			// Step 1: Release screen wake lock (allow screen sleep again)
			this.releaseWakeLock();

			// Step 2: Play close sound before cleanup so audio system is still intact
			this.playSound('close');

			// Capture before cleanup() resets it
			const didEndSession = this.endSessionCalled;

			// Step 3: Clean up all resources (disconnect WebRTC session)
			this.cleanup();

			// Step 4: Reset state to idle
			this.setState('idle');

			// Step 5: Notify UI that conversation has ended
			this.callbacks.onConversationEnd(reason, { endSessionCalled: didEndSession });

			// GTM dataLayer: voice conversation ended
			var contactFields = Object.keys(this.contactMemory).filter(function(k) { return !!this.contactMemory[k]; }.bind(this));
			(window.dataLayer = window.dataLayer || []).push({
				event: 'sitestaffr_conversation_end',
				sitestaffr_mode: 'voice',
				sitestaffr_session_id: sessionIdSnapshot,
				sitestaffr_duration: conversationStartTimeSnapshot ? Math.floor((endTime - conversationStartTimeSnapshot) / 1000) : 0,
				sitestaffr_has_contact_info: contactFields.length > 0,
				sitestaffr_fields_captured: contactFields,
				sitestaffr_message_count: this.conversationTranscript.length,
			});

			// Step 6: Save transcript/finalize usage (fire-and-forget, async)
			// This happens AFTER disconnect/sound so the call feels "over" before persistence.
			if (this.conversationTranscript.length > 0) {
				this.saveTranscript(sessionIdSnapshot, conversationStartTimeSnapshot, endTime, realtimeUsageSnapshot, endedBySnapshot).catch(error => {
					console.error('SiteStaffr: Error during transcript save:', error);
				});
			} else {
				const duration = conversationStartTimeSnapshot ? Math.floor((endTime - conversationStartTimeSnapshot) / 1000) : 0;
				this.finalizeUsage(sessionIdSnapshot, duration).catch(error => {
					console.warn('SiteStaffr: Usage finalization failed after empty transcript:', error);
				});
			}
		}

		/**
		 * Save transcript to WordPress via AJAX.
		 *
		 * Sends conversation transcript directly to WordPress for database storage.
		 * Uses AJAX (same-origin) with nonce authentication.
		 */
		async saveTranscript(sessionId = null, conversationStartTime = null, endTime = null, usageTotals = null, endedBy = null) {

			// Calculate conversation duration in seconds
			const effectiveEndTime = endTime || Date.now();
			const effectiveStartTime = conversationStartTime || this.conversationStartTime;
			const duration = effectiveStartTime ? Math.floor((effectiveEndTime - effectiveStartTime) / 1000) : 0;
			const effectiveSessionId = sessionId || this.sessionId;
			const effectiveUsageTotals = (usageTotals && typeof usageTotals === 'object') ? usageTotals : this.realtimeUsageTotals;

			try {
				// Get nonce from localized script data (check both widget types)
				const nonce = window.sitestaffr_widget_data?.save_nonce || window.sitestaffr_button_data?.save_nonce || '';
				if (!nonce) {
					console.error('SiteStaffr: Missing save nonce - cannot save transcript');
					return;
				}

				// Build form data for AJAX request
				const formData = new FormData();
				const saveAction = (this.config.channelSource === 'button')
					? 'sitestaffr_button_save_conversation'
					: 'sitestaffr_save_conversation';
				formData.append('action', saveAction);
				formData.append('nonce', nonce);
				formData.append('session_id', effectiveSessionId || 'unknown');
				formData.append('duration', duration);
				formData.append('transcript', JSON.stringify(this.conversationTranscript));
				formData.append('realtime_usage', JSON.stringify(effectiveUsageTotals));
				formData.append('channel_source', this.config.channelSource || 'widget');
				formData.append('persona', this.config.persona || 'default');
				formData.append('contact_fields', JSON.stringify(this.contactMemory));
				formData.append('page_url', window.location.href);
				formData.append('page_title', document.title || '');
				if (endedBy) {
					formData.append('ended_by', endedBy);
				}

				// Send AJAX request to WordPress
				const response = await fetch(this.config.ajaxUrl, {
					method: 'POST',
					body: formData,
					credentials: 'same-origin'
				});

				if (!response.ok) {
					console.error('SiteStaffr: Transcript save failed - HTTP error:', response.status);
					return;
				}

				const responseText = await response.text();
				let data = null;
				try {
					data = JSON.parse(responseText);
				} catch (parseError) {
					console.error('SiteStaffr: Transcript save returned non-JSON response');
					return;
				}

				if (data.success) {
				} else {
					console.error('SiteStaffr: Transcript save not successful:', data);
				}
			} catch (error) {
				console.error('SiteStaffr: Transcript save error:', error);
			} finally {
				await this.finalizeUsage(effectiveSessionId, duration);
			}
		}

		/**
		 * Finalize usage in billing system via WordPress AJAX bridge.
		 */
		async finalizeUsage(sessionId, durationSeconds) {
			const safeSessionId = (typeof sessionId === 'string' && sessionId.trim()) ? sessionId.trim() : '';
			const safeDuration = Number.isFinite(durationSeconds) ? Math.max(0, Math.floor(durationSeconds)) : 0;

			if (!safeSessionId) {
				console.warn('SiteStaffr: Skipping usage finalization - missing session_id');
				return;
			}

			const nonce =
				window.sitestaffr_widget_data?.finalize_nonce ||
				window.sitestaffr_button_data?.finalize_nonce ||
				'';

			if (!nonce) {
				console.warn('SiteStaffr: Skipping usage finalization - missing finalize nonce');
				return;
			}

			try {
				const formData = new FormData();
				const capturedEmail = (this.contactMemory && typeof this.contactMemory.email === 'string')
					? this.contactMemory.email.trim()
					: '';
				const capturedPhone = (this.contactMemory && typeof this.contactMemory.phone === 'string')
					? this.contactMemory.phone.trim()
					: '';
				const finalizeAction = (this.config.channelSource === 'button')
					? 'sitestaffr_button_finalize_usage'
					: 'sitestaffr_finalize_usage';
				formData.append('action', finalizeAction);
				formData.append('nonce', nonce);
				formData.append('session_id', safeSessionId);
				formData.append('duration', String(safeDuration));
				formData.append('channel_source', this.config.channelSource || 'widget');
				if (capturedEmail) {
					formData.append('contact_email', capturedEmail);
				}
				if (capturedPhone) {
					formData.append('contact_phone', capturedPhone);
				}

				const response = await fetch(this.config.ajaxUrl, {
					method: 'POST',
					body: formData,
					credentials: 'same-origin'
				});

				if (!response.ok) {
					console.warn('SiteStaffr: Usage finalization HTTP error:', response.status);
					return;
				}

				const responseText = await response.text();
				let payload = null;
				try {
					payload = JSON.parse(responseText);
				} catch (parseError) {
					console.warn('SiteStaffr: Usage finalization returned non-JSON response');
					return;
				}

				if (payload.success) {
				} else {
					console.warn('SiteStaffr: Usage finalization rejected:', payload);
				}
			} catch (error) {
				console.warn('SiteStaffr: Usage finalization failed:', error);
			}
		}

		/**
		 * Toggle conversation (start/end)
		 */
		async toggleConversation() {
			if (!this.inConversation) {
				await this.startConversation();
			} else {
				this.endConversation('user_hangup');
			}
		}

		/**
		 * Clean up all resources
		 */
		cleanup() {
			// Close data channel
			if (this.dataChannel) {
				try {
					this.dataChannel.close();
				} catch (e) {}
				this.dataChannel = null;
			}

			// Close peer connection (this stops audio automatically)
			if (this.peerConnection) {
				try {
					this.peerConnection.close();
				} catch (e) {}
				this.peerConnection = null;
			}

			// Clean up audio element
			if (this.audioElement) {
				this.audioElement.srcObject = null;
				this.audioElement = null;
			}

			// Stop media stream
			if (this.mediaStream) {
				this.mediaStream.getTracks().forEach(track => track.stop());
				this.mediaStream = null;
			}

			// Release any cached blob URLs used for sound fallbacks.
			Object.values(this.cachedSoundBlobUrls || {}).forEach(blobUrl => {
				try {
					URL.revokeObjectURL(blobUrl);
				} catch (e) {}
			});
			this.cachedSoundBlobUrls = {};

			// Reset state
			this.shouldAutoHangup = false;
			this.endSessionCalled = false;
			this._endedByHint = null;
			this._endSessionMinDelay = 0;
			this.clearAutoHangupFallback();
			this.clearSessionDurationCap();
			this.greetingComplete = false;
			this.initialGreetingInProgress = false;
			this.clearSilenceTimer();
			this.clearUtteranceTimers();
			this.silenceChecksSent = 0;
			this.isUserSpeaking = false;
			this.utteranceViolationCount = 0;
			this.sessionId = null;
			this.conversationStartTime = null;
			this.isEndingConversation = false;
			this._reconnectAttempts = 0;
			this._rateLimitRetries = 0;
			this.realtimeUsageTotals = this.createEmptyRealtimeUsageTotals();
		}
	}

	// Export to window
	window.SiteStaffrCore = SiteStaffrCore;

})(window);
