/// <reference path="./twx-chat-plus.js" />

function runQuery(message, isCommonQuestion = false){
	
	if(message.trim() == '') return;

	cancelSpeech();
	showTypingIndicator();
	setAvatarStatus('thinking');
	m_avatar && fadeOutAvatarFlashcards();

	if(m_allowStreaming) {
		runQueryStream(message, isCommonQuestion);
		return;
	}

	m_searchResults = [];
	
	var url = twxChat.ajax_url;
	var $container = jQuery('#twxchat-container');
	var assistantId = $container.attr('data-assistant-id');
	var knowledgeBaseSource = $container.attr('data-kb-source');
	var minSimilarity = $container.attr('data-min-similarity');
	var threadId = $container.attr('data-thread-id');
	threadId = (threadId == undefined || threadId.trim() == '' ? '' : threadId);
	var debug = isDebugOn();
	var llmModel = $container.attr('data-llm-model') ?? null;
	var chatbotId = $container.attr('data-id') ?? null;
	
	var userMessageNo = 0;
	if(assistantId == undefined || assistantId.trim() == ''){
		var ret = getMessages();
		userMessageNo = ret.userMessageNo;
	}

	/* Already capturing in Chat Log on server side
	pushGAEvent('twxchat_user_query', {
		twx_user_query: message, 
		twx_user_query_no: userMessageNo, 
		twx_user_thread: threadId,
		twx_user_query_voice_input: m_isVoiceInput
	});
	*/

	m_isQuerying = true;
	
	jQuery.ajax({
		url: url,
		type: 'POST',
		data: {
			action: 'twx_chat_query',
			nonce: twxChat.nonce,
			message: message,
			isCommonQuestion: isCommonQuestion,
			messageNo: userMessageNo,
			assistantId: (assistantId == undefined || assistantId.trim() == '' ? '' : assistantId),
			threadId: threadId,
			stream: m_allowStreaming,
			knowledgeBaseSource: (knowledgeBaseSource == undefined || knowledgeBaseSource.trim() == '' ? '' : knowledgeBaseSource),
			minSimilarity: (minSimilarity == undefined || minSimilarity.trim() == '' ? '' : minSimilarity),
			isVoiceInput: m_isVoiceInput,
			ttsVoice: (m_customVoiceProvider && m_customVoiceName ? `${m_customVoiceProvider}:${m_customVoiceName}` : ''),
			llmModel: llmModel,
			chatbotId: chatbotId,
		},
		success: function(response) {
			m_isQuerying = false;
			hideTypingIndicator();

			// If user has interrupted, dont allow agent to respond
			if(m_forceStopQuery) {
				m_forceStopQuery = false;
				return;
			}

			setAvatarStatus('responding');

			var parentElem = jQuery('#user-input').closest('.chatbot-container').find('.chat-messages');
			if(!parentElem || parentElem == undefined) return;
			
			if (response.success) {
				console.log('runQuery ajax success',response.data);
				
				// LLM answer
				var $botMsg;
				var sourceIdsString = '';

				if(response.data.answer) {
					if(response.data.error){
						parentElem.append('<div class="error-message">' + response.data.answer + '</div>');
					}else{

						const {before, after, foundData} = splitDataFromBotResponse(response.data.answer);
						const answer = before;
						if(foundData) sourceIdsString = after;

						parentElem.append('<div class="bot-message" data-user-message-no="' + userMessageNo + '"><div class="bot-message-text">' + answer + '</div><div class="bot-message-actions"></div></div>');
						$botMsg = parentElem.find('.bot-message').last();

						if (m_isVoiceInput || m_avatar) {
							// automatically speak response
							m_speakingBotMessageNo = userMessageNo;
							const speechText = getSpeechText(answer);
							startSpeech(speechText);
						}
					}
				}
				if(response.data.threadId) {
					jQuery('#twxchat-container').attr('data-thread-id', response.data.threadId);
				}
				// Show/save Debug info
				if(debug){
					if(response.data.threadId) {
						if(jQuery('#twx-debug-threadId')) jQuery('#twx-debug-threadId').text(response.data.threadId);
					}
					if(response.data.model) {
						if(jQuery('#twx-debug-model')) {
							jQuery('#twx-debug-model').text(response.data.model + ' (max tokens ' + response.data.model_max_tokens + ')');
						}
					}
					if(response.data.sql) parentElem.append('<div class="debug-message">SQL = ' + response.data.sql + '</div>');
					if(response.data.search_results) parentElem.append('<div class="debug-message">Results = ' + response.data.search_results + '</div>');
					if(response.data.source) parentElem.append('<div class="debug-message">Source = ' + response.data.source + '</div>');
					if(response.data.usage){
						var usage = JSON.parse(response.data.usage);
						var isTotalUsage = usage.is_total_usage ?? false;
						var inTokens = parseInt(usage.prompt_tokens);
						var outTokens = parseInt(usage.completion_tokens);
						var totTokens = parseInt(usage.total_tokens);
						var totCost =  parseFloat(usage.cost_total);
						var currency = usage.currency;
						var usageMsg = '';
						var estimatedTokens = '';
						if(isTotalUsage){
							usageMsg = 'Tot.In: '+inTokens+'. Tot.Out: '+outTokens+'. Tot.All: '+totTokens+' ('+currency+'$'+totCost+')';
						}else{
							// Single interaction usage
							if(!m_debug.usage){
								m_debug.usage = {"totTokens":totTokens, "totCost":totCost};
							}else{
								m_debug.usage.totTokens += totTokens;
								m_debug.usage.totCost += totCost;
							}
							if(response.data.estimated) estimatedTokens = 'Est: '+response.data.estimated.tokens+'. ';
							usageMsg = estimatedTokens+'In: '+inTokens+'. Out: '+outTokens+'. Tot: '+totTokens+'. Tot.All: '+m_debug.usage.totTokens+' ('+currency+'$'+m_debug.usage.totCost+')';
						}
						if(usageMsg.trim() != '') parentElem.append('<div class="debug-message">Token Usage: ' + usageMsg + '</div>');
					}
				}

				// get sources from ids/idsString
				let sources = null;
				if(sourceIdsString && isValidJSON(sourceIdsString)){
					let source_ids = [];
					const decoded = JSON.parse(sourceIdsString);
					if(decoded.source_ids) source_ids = decoded.source_ids;
					if(source_ids.length>0){
						try { sources = getSourcesFromIds(source_ids); }catch(error){  }
					}

				}else if(response.data.sourceIds && response.data.sourceIds.length>0){
					// If source ids provided by server
					try { sources = getSourcesFromIds(response.data.sourceIds); }catch(error){  }
				}

				// render controls
				renderBotMessageActionsAndControls($botMsg, m_isVoiceInput, m_allowFeedback, message, sources);
				updateButtons();

				// scroll to bottom
				scrollToChatbotMessagesEnd();

				// set user input focus
				jQuery('#user-input').focus();


			} else {
				console.log('runQuery ajax failed',response);
				parentElem.append('<div class="error-message">' + response.message + '</div>'); 
				setAvatarStatus('idle');
			}
		},
		error: function(xhr, status, error) {
			m_isQuerying = false;
			hideTypingIndicator();
			setAvatarStatus('idle');
			console.log('AJAX error:', status, error);
		}
	});		
}

function runQueryStream(message, isCommonQuestion = false) {

	var $parentElem = jQuery('#user-input').closest('.chatbot-container').find('.chat-messages');
	if(!$parentElem || $parentElem == undefined) {
		console.log('cannot find parent element for response');
		return;
	}

	var ret = getMessages();
	var userMessageNo = ret.userMessageNo;
	var $container = jQuery('#twxchat-container');
	var threadId = $container.attr('data-thread-id');
	threadId = (threadId == undefined || threadId.trim() == '' ? '' : threadId);
	var knowledgeBaseSource = $container.attr('data-kb-source');
	var minSimilarity = $container.attr('data-min-similarity');
	var llmModel = $container.attr('data-llm-model') ?? null;
	var chatbotId = $container.attr('data-id') ?? null;
	m_userInteractedConversationWidget = false;

	let params = {
		threadId: threadId,
		message: encodeURIComponent(message), // need to encode as travelling as GET param
		isCommonQuestion: isCommonQuestion,
		knowledgeBaseSource: (knowledgeBaseSource == undefined || knowledgeBaseSource.trim() == '' ? '' : knowledgeBaseSource),
		showSources: true,
		minSimilarity: (minSimilarity == undefined || minSimilarity.trim() == '' ? '' : minSimilarity),
		isVoiceInput: m_isVoiceInput,
		llmModel: llmModel,
		chatbotId: chatbotId,
		nonce: twxChat.nonce
	};

	var queryString = jQuery.param(params);
    let urlWithParams = twxChat.stream_handler + '?' + queryString;
	//let urlWithParams = twxChat.test_stream_server; // replace during testing

    // Create a new EventSource with the URL that includes the parameters including secure nonce
    m_streamEventSource = new EventSource(urlWithParams);
	m_isStreaming = true;
	m_speechTextBuffer = '';
	updateButtons();

	// create bot message to append stream response to
	$parentElem.append('<div class="bot-message" data-user-message-no="' + userMessageNo + '"><div class="bot-message-text"></div><div class="bot-message-actions"></div></div>');
	let $botMessageDiv = $parentElem.find('.bot-message').last();
	let $botMessageTextDiv = $botMessageDiv.find('.bot-message-text');

	let inlineSources = false; // TODO change this to true when we get inline sources working properly
	let hasJSONData = false;
	let jsonDataString = '';
	let source_ids = [];
	let user_fields = null;
	let buffer = '';

    // Event listener for handling streaming data chunks
    m_streamEventSource.onmessage = function(event) {

		try {
			hideTypingIndicator();
			if(m_avatar) setAvatarStatus('responding');
			
			if(!isValidJSON(event.data)){
				console.log('Data stream result is not valid JSON', event.data);
				return;
			}

			parsedData = JSON.parse(event.data);
			//console.log('Data Stream from server: ', parsedData);

			if(parsedData.error){
				$parentElem.append('<div class="error-message">' + parsedData.error.toString() + '</div>');
				return;
			}

			// if server force end of stream, eg: no relevant content matching query, then set flag
			let endOfStream = (parsedData.end_of_stream !== undefined && parsedData.end_of_stream);

			if(parsedData.answer && !endOfStream){
				$botMsgTextDiv.html(parsedData.answer);
				return;
			}

			// Natural end of stream, ie: LLM finished responding
			if(parsedData.choices && parsedData.choices[0].finish_reason != null){
				console.log("end of stream:", parsedData.choices[0].finish_reason);
				console.log(`${buffer}`);

				m_streamEventSource.close();
				m_isStreaming = false;

				stopChatbotMessagesScrollingAnimations(); // in case queued animations

				let $botMsg = $parentElem.find('.bot-message').last();
				if($botMsg){

					if(!$botMessageTextDiv) {
						console.log('cannot locate bot message text div');
						return;
					}
					let html = $botMessageTextDiv.html();

					// get sources
					if(inlineSources){
						source_ids = getSourceIdsFromBotResponse($botMessageTextDiv);
					}else{
						if(jsonDataString !== '' && isValidJSON(jsonDataString)){
							const decoded = JSON.parse(jsonDataString);
							if(decoded.source_ids) source_ids = decoded.source_ids;
							if(decoded.user_fields) user_fields = decoded.user_fields;
						}
					}

					let sources = null;
					if(source_ids.length > 0){
						try { sources = getSourcesFromIds(source_ids); }catch(error){  }
					}

					let properties = {botResponse: html};

					// user fields (Plus Plan)
					if(user_fields && window.TWXChatPlus) {
						properties.user_fields = user_fields;
						if(wideAvatarContainer() && hasUserFieldValues(user_fields)){
							const flashcardTitle = jQuery('#twxchat-container').data('user-fields').flashcard_title;
							if(flashcardTitle !== undefined && flashcardTitle){
								TWXChatPlus.displayUserFieldValues(user_fields, flashcardTitle);
							}
						}
						TWXChatPlus.invokeUpdateUserFieldsWebhook(twxChat, chatbotId, threadId, user_fields);
					}
					
					// render controls
					renderBotMessageActionsAndControls($botMsg, m_isVoiceInput, m_allowFeedback, message, sources);

					// update chat log with final bot response and sources
					if(chatbotId) properties.chatbotId = chatbotId;
					if(sources) properties.sources = sources;
					updateChatLog('upsert', threadId, userMessageNo, properties);
				}

				updateButtons();
				return;
			}

			// Streaming content received
			let contentChunk;
			if(endOfStream && parsedData.answer){
				contentChunk = parsedData.answer;
			}else if(parsedData.choices && parsedData.choices[0].delta && parsedData.choices[0].delta.content) {
				contentChunk = parsedData.choices[0].delta.content;
			}

			if(contentChunk){

				//console.log(`chunk: ${contentChunk}`);
				buffer += contentChunk;

				if(inlineSources){

					if(isProbablyValidHTML(buffer)) {
						$botMessageTextDiv.html(buffer);
					}else{
						//console.log('invalid HTML:',buffer);
					}

				}else{

					// detect and parse source_ids at end
					if(hasJSONData){
						// we're receiving JSON data now
						jsonDataString += contentChunk;
						return;
					}
	
					const {before, after, foundData} = splitDataFromBotResponse(contentChunk);
					contentChunk = before;
					if(foundData){
						jsonDataString = after;
						hasJSONData = true;
					}
	
					$botMessageTextDiv.append(contentChunk);
				}

				// auto scroll unless user has interacted
				if(!m_userInteractedConversationWidget){
					scrollToChatbotMessagesEnd();
				}

				if(m_isVoiceInput || m_avatar){
					m_speechTextBuffer += contentChunk;
					m_speakingBotMessageNo = userMessageNo;

					// Check if we have any complete sentences
					if (m_speechTextBuffer.match(/[.!?:]/)) {
						m_speechTextBuffer = pushTextSentencesToSpeechQueue(m_speechTextBuffer, false);
						processStreamingSpeechQueue();
					}
				}
				return;
			}

		}catch(error){
			console.log('runQueryStream:: onmessage error',error);
			return;
		}

    };

    // Handle error event
    m_streamEventSource.onerror = function(event) {
        console.log('EventSource.onerror:', event);

		if (event instanceof MessageEvent) {
			console.log('EventSource.onerror MessageEvent data:', event.data);
		}
		
		if (event.target && event.target.readyState) {
			console.log('m_streamEventSource.onerror readyState:', event.target.readyState);
			// readyState === 2 means permanently closed
		}
		
		if (m_streamEventSource.readyState === EventSource.CLOSED) {
			console.log('EventSource.onerror EventSource connection was closed.');
		} else if (m_streamEventSource.readyState === EventSource.CONNECTING) {
			console.log('EventSource.onerror Reconnecting...');
		}

		cancelStream();
		setAvatarStatus('idle'); 
    };

}

function hasUserFieldValues(userFields) {
    return Object.values(userFields).some(val => String(val).trim() !== "");
}

function renderBotMessageActionsAndControls($botMsg, isCurrentlyPlaying, allowFeedback, userMessage, sources = null){

	if(!$botMsg) return;
	var $actions = $botMsg.find('.bot-message-actions');
	if(!$actions) return;

	var userMessageNo = $botMsg.data('user-message-no');

	// Create Actions
	var actionsHtml = getActionButtonsHtml(isCurrentlyPlaying, userMessageNo, allowFeedback, sources);
	$actions.html(actionsHtml);

	// Create Feedback controls
	if(allowFeedback && window.TWXChatPro){
		$botMsg.append(TWXChatPro.createFeedbackContainer(userMessage));
	}

	// Create Sources controls
	if(sources && sources.length>0){
		var sourcesHtml = getSourcesHtml(sources);
		if(sourcesHtml){
			var newSourcesContainer = jQuery('#sourcesContainer').clone(true);
			newSourcesContainer.attr('id','');
			newSourcesContainer.html((sourcesHtml == '' ? 'None' : sourcesHtml));
			$botMsg.append(newSourcesContainer);

			// Add Flashcards if not mobile and screen wide enough
			if(m_source_flashcards && m_avatar && !isMobile() && wideAvatarContainer()) {
				sources.forEach(source => {
					if(isValidUrl(source.source)) addAvatarFlashcard(source, userMessageNo);
				});
			}
		}
	}

}

function getBotResponseSpeakerHtml(isCurrentlyPlaying, userMessageNo){

	var stylePlayBtn = ' ';
	var stylePauseBtn = ' ';
	if(isCurrentlyPlaying){
		//stylePlayBtn = 'style="display:none"';
	}else{
		//stylePauseBtn = 'style="display:none"';
	}
	var playBtnHtml = '<button id="bot-message-play-button-'+userMessageNo+'"'+stylePlayBtn+'class="bot-message-play-button" onclick="onBotMessagePlayButtonClick('+userMessageNo+')"><img src="'+twxChat.images_path+'/tabler-icons/player-play.svg"/></button>';
	var pauseBtnHtml = '<button id="bot-message-pause-button-'+userMessageNo+'"'+stylePauseBtn+'class="bot-message-pause-button" onclick="onBotMessagePauseButtonClick('+userMessageNo+')"><img src="'+twxChat.images_path+'/tabler-icons/player-pause.svg"/></button>';

	return playBtnHtml+pauseBtnHtml;
}

function getActionButtonsHtml(isCurrentlyPlaying, userMessageNo, allowFeedback, sources){
	var html = getBotResponseSpeakerHtml(isCurrentlyPlaying, userMessageNo);

	if(allowFeedback && window.TWXChatPro){
		html += TWXChatPro.getFeedbackButtonHtml(userMessageNo);
	}
	if(sources != null){
		var numSourcesHtml = (sources.length>0 ? ' ('+sources.length+')': '');
		html += '<button class="bot-message-action-button wp-element-button" onclick="showHideSourcesContainer('+userMessageNo+')">Sources'+numSourcesHtml+'</button>';
	}
	return html;
}

function pushTextSentencesToSpeechQueue_AllowSSML(text, pushPartialSentencesToQueue = false) {
	// Replace <br/> with periods for pause clarity
	text = text.replace(/<br\s*\/?>/gi, '.');

	// Normalize multiple dots
	text = text.replace(/\.{2,}/g, '.');

	// Check for incomplete SSML tag (e.g., "<mark name='{"feature'")
	const incompleteTag = text.match(/<[^>]*$/);
	if (incompleteTag) {
		// Tag starts but not finished — wait for more input
		return text;
	}

	// Tokenize into SSML tags and text
	const tokens = text.split(/(<[^>]+>)/g);

	let currentSentence = '';
	const tagStack = [];

	const isSelfClosing = tag => /\/>$/.test(tag);
	const getTagName = tag => {
		const match = tag.match(/^<\/?([a-zA-Z0-9\-]+)/);
		return match ? match[1].toLowerCase() : null;
	};

	for (const token of tokens) {
		if (!token.trim()) continue;

		if (token.startsWith('<') && token.endsWith('>')) {
			// Tag
			const tagName = getTagName(token);

			if (!tagName) {
				currentSentence += token; // malformed, keep it
				continue;
			}

			if (token.startsWith('</')) {
				// Closing tag
				const last = tagStack[tagStack.length - 1];
				if (last === tagName) tagStack.pop();
			} else if (!isSelfClosing(token)) {
				// Opening tag
				tagStack.push(tagName);
			}

			currentSentence += token;
		} else {
			// Text: split by punctuation
			const parts = token.split(/([.!?:])/);
			for (let i = 0; i < parts.length; i += 2) {
				const textPart = parts[i];
				const punctuation = parts[i + 1] || '';

				currentSentence += textPart + punctuation;

				if (punctuation && tagStack.length === 0) {
					// Complete sentence and all tags closed
					m_speechQueue.push(currentSentence.trim());
					currentSentence = '';
				}
			}
		}
	}

	// Handle remaining partial sentence
	if (currentSentence.trim()) {
		if (pushPartialSentencesToQueue && tagStack.length === 0) {
			m_speechQueue.push(currentSentence.trim());
			return '';
		} else {
			// Return remainder to buffer (incomplete sentence or open tags)
			return currentSentence.trim();
		}
	}

	return '';
}

function pushTextSentencesToSpeechQueue(text, pushPartialSentencesToQueue = false) {
	
	// Replace <br/> with periods to prevent sentence joining
	text = text.replace(/<br\s*\/?>/gi, '.');

	// Normalize ellipses or multiple dots
	text = text.replace(/\.{2,}/g, '.');

	// Tokenize into text
	const tokens = text.split(/(<[^>]+>)/g); // keeps tags separate

	let currentSentence = '';

	for (const token of tokens) {
		if (!token.trim()) continue;

		if (token.startsWith('<') && token.endsWith('>')) {
			// Tag: append without breaking sentence
			currentSentence += token;
		} else {
			// Text: split further by punctuation
			const parts = token.split(/([.!?:])/);
			for (let i = 0; i < parts.length; i += 2) {
				const textPart = parts[i];
				const punctuation = parts[i + 1] || '';

				currentSentence += textPart + punctuation;

				if (punctuation) {
					// Complete sentence
					m_speechQueue.push(currentSentence.trim());
					currentSentence = '';
				}
			}
		}
	}

	if (currentSentence.trim()) {
		if (pushPartialSentencesToQueue) {
			m_speechQueue.push(currentSentence.trim());
			return '';
		} else {
			return currentSentence.trim(); // return partial
		}
	}

	return '';
}

function pushTextSentencesToSpeechQueueOLDEST(text, pushPartialSentencesToQueue = false){
	// Parse text into sentences for better speech flow and push to speech queue
	// If text is a partial sentence and we elect not to push to queue, return it. This would typically be used in streaming responses

	if (!text.match(/[.!?:]/)) {
		if(!pushPartialSentencesToQueue) return text;
	}

	// replace line breaks with full stop, otherwise we can get sentence combining during speech 
	// eg: "the end<br/>And now", would be spoken as "the endstart and now"
	if(text.includes("<br/>")){
		text = text.replace(/<br\s*\/?>/g, '.'); // replace all brs with a single .
	}

	// Replace multiple dots with single .
	text = text.replace(/\.{2,}/g, '.');

	const sentences = text.split(/([.!?:])/);

	// Process all complete sentences
	for (let i = 0; i < sentences.length - 2; i++) {
		const sentence = sentences[i] + (sentences[i + 1] || '');
		if (sentence.trim() && sentence !== '.') {
			m_speechQueue.push(sentence.trim());
		}
		i++; // Skip the punctuation we just added
	}

	if(!pushPartialSentencesToQueue) {
		return sentences[sentences.length - 1];
	}else{
		m_speechQueue.push(sentences[sentences.length - 1]);
		return '';
	}

}

function updateButtons(){
	// enable/disable/show/hide buttons as needed
	//console.log('updateButtons');

	//jQuery('.voice-input-button').style('display') = (streaming ? 'none' : ''); // prop('disabled', streaming);
	jQuery('.voice-input-button').css('display', m_isStreaming ? 'none' : '');
	jQuery('.send-button').css('display', m_isStreaming ? 'none' : '');
	jQuery('.cancel-stream-button').css('display', m_isStreaming ? '' : 'none');

	jQuery('.voice-input-button').prop('disabled', m_isStreaming);
	jQuery('.send-button').prop('disabled', m_isStreaming);
	jQuery('.cancel-stream-button').prop('disabled', !m_isStreaming);
	
	// Bot Message Play/Pause buttons
	//bot-message-play-button
	var $botMessagePlayButtons;
	var $botMessagePauseButtons;
	if(m_speakingBotMessageNo == 0){
		$botMessagePlayButtons = jQuery('.bot-message-play-button');
		$botMessagePauseButtons = jQuery('.bot-message-pause-button');
	}else{
		var $botMsg = jQuery('.bot-message[data-user-message-no="'+m_speakingBotMessageNo+'"]').last();
		if($botMsg){
			$botMessagePlayButtons = $botMsg.find('.bot-message-play-button');
			$botMessagePauseButtons = $botMsg.find('.bot-message-pause-button');
		}
	}
	
	if(m_isSpeechPaused){
		$botMessagePlayButtons.show();
		$botMessagePauseButtons.hide();
		if(m_avatar){
			jQuery('#avatar-play').show();
			jQuery('#avatar-pause').hide();
		}
	}else{
		if(m_isSpeaking){
			$botMessagePauseButtons.show();
			$botMessagePlayButtons.hide();
			if(m_avatar){
				jQuery('#avatar-pause').show();
				jQuery('#avatar-play').hide();
			}
		}else{
			$botMessagePauseButtons.hide();
			$botMessagePlayButtons.show();
			if(m_avatar){
				jQuery('#avatar-play').hide();
				jQuery('#avatar-pause').hide();
			}
		}
	}

	if(m_avatar){
		if(m_isListening){
			jQuery('#avatar-microphone-off').hide();
			jQuery('#avatar-microphone-on').show();
		}else{
			jQuery('#avatar-microphone-off').show();
			jQuery('#avatar-microphone-on').hide();
		}

		if(m_style === 'floating'){

			if(jQuery('#avatar-container').hasClass('fullscreen')){
				// maximised avatar with previous floating style
				jQuery('#avatar-minimize').show();
				jQuery('#avatar-maximize').hide();
				if(jQuery('#avatar-conversation-widget').is(':visible')){
					jQuery('#avatar-conversation-on').show();
					jQuery('#avatar-conversation-off').hide();
				}else{
					jQuery('#avatar-conversation-on').hide();
					jQuery('#avatar-conversation-off').show();
				}
				jQuery('#user-utterance').show();
			}else{
				// floating avatar (bottom right)
				jQuery('#avatar-maximize').show();
				jQuery('#avatar-minimize').hide();
				jQuery('#avatar-conversation-on').hide();
				jQuery('#avatar-conversation-off').hide();
				jQuery('#user-utterance').hide();
			}
		}
	
	}
}

function scrollToChatbotMessagesEnd() {

	if(m_avatar){
		const $container = jQuery('#avatar-conversation-content');
		if($container.length){
			$container.stop().animate({
				scrollTop: $container[0].scrollHeight
			}, 100);
		}
		return;
	}
	
	// non-avatar, check if floating
	const $floatingContainer = jQuery('#twx-chat-floating');
	if($floatingContainer.length){
		$floatingContainer.animate({
			scrollTop: $floatingContainer[0].scrollHeight
		}, 300); // 300ms smooth scroll animation
		return;
	} 

	// non-avatar, non-floating
	var box = jQuery('.chatbot-container')[0];
    //var box = document.getElementById('twxchat-container');
    if (!box) return; // Ensure box exists before proceeding
    
    var boxHeight = box.offsetHeight;
    var boxTop = box.getBoundingClientRect().top + window.scrollY; // Use getBoundingClientRect() to account for any page scroll
    var viewportHeight = window.innerHeight;

    // Calculate scroll position to place the bottom of the box just outside of the viewport
    var scrollPosition = boxTop + boxHeight - viewportHeight;

    // Ensure scroll position doesn't go negative, meaning we would scroll past the content
    scrollPosition = Math.max(scrollPosition, 0); 

    // Scroll to that position
    window.scrollTo({ top: scrollPosition, behavior: 'instant' }); // No animation for instant scroll
}

function stopChatbotMessagesScrollingAnimations() {
	jQuery('#avatar-conversation-content').stop(true, true);
	jQuery('#twx-chat-floating').stop(true, true);
}

function getMessages() {
	// returns all current messages in conversation and also current user message no.
	ret = {
		messages: [],
		userMessageNo: 0,
	};

	jQuery('.chat-messages').children().each(function() {
		var role = jQuery(this).hasClass('user-message') ? 'user' :  (jQuery(this).hasClass('bot-message') ? 'assistant' : null);
		if(role == 'user') ret.userMessageNo++;
		var content = jQuery(this).text().trim();
		if(content != '' && role != null) ret.messages.push({role: role, content: content});
	});
	return ret;
}

function getSourcesFromIds(source_ids, includeFeedbackSources = false) {

	var sources = null;

    // synchronously get sources from server
    jQuery.ajax({
        url: twxChat.ajax_url,
        type: 'POST',
        async: false,  // This makes the request synchronous
		data: {
			action: 'twx_chat_getKnowledgeBaseSources',
			nonce: twxChat.nonce,
			source_ids: source_ids,
			include_feedback_sources: includeFeedbackSources
		},
		dataType: 'json',
        success: function(response) {
			if (response.success && response.data.sources) sources = response.data.sources;
        },
        error: function(xhr, status, error) {
            console.log('getSourcesFromIds Error:', error);
			throw error;
        }
    });
	return sources;
}

function getSourcesHtml(sources){

	var sourcesHtml = '';
	for (var item of sources) {
		const similarity = (item.similarity_score ? '&nbsp;('+(item.similarity_score.toFixed(2)*100)+'%)' : '');
		if(item.source.endsWith('_feedback')){
			//sourcesHtml += '<li>Feedback item created/modified on '+item.last_modified+'</li>';
		}else{
			if(item.source.startsWith('gdrive_')){
				// is Google Drive document living in uploads folder
				const extract = s => (m = s.match(/^gdrive_(.+)\.([a-z0-9]+)_page(\d+)$/i)) ? { filename: m[1] + "." + m[2], page: +m[3] } : null;  
				var fileInfo = extract(item.source);
				var source_url = twxChat.google_drive_upload_path+'/'+fileInfo.filename+'#page='+fileInfo.page;
				sourcesHtml += '<li><a data-source-id="'+item.source_id+'" href="'+source_url+'" target="_new">'+item.source+'</a>'+similarity+'</li>';
			}else{
				if(isImageUrl(item.source)){
					sourcesHtml += '<li><img data-source-id="'+item.source_id+'" src="'+item.source+'" alt="'+item.title+'"></li>';
				}else{
					sourcesHtml += '<li><a data-source-id="'+item.source_id+'" href="'+item.source+'" target="_new">'+(item.title ? item.title : item.source)+'</a>'+similarity+'</li>';
				}
			}
		}
	}
	return sourcesHtml;

}

function getSpeechText(htmlString) {
	if (!htmlString) return '';

	// Replace <br/> with period for better pauses
	htmlString = htmlString.replace(/<br\s*\/?>/gi, '. ');

	// Allowed SSML tags to preserve
	let allowedTags = [];
	if(m_avatar){
		allowedTags = [
			'mark', 'break', 'prosody', 'emphasis', 'voice', 'lang',
			's', 'p', 'phoneme', 'say-as', 'sub', 'w'
		];
	}

	// Remove all HTML tags except allowed tags
	htmlString = htmlString.replace(/<\/?([a-zA-Z0-9\-]+)([^>]*)>/g, (match, tagName, attrs) => {
		if (allowedTags.includes(tagName.toLowerCase())) {
			return match; // Keep allowed SSML tag
		}
		return ''; // Remove all others
	});

	// Remove markdown-like **bold**
	htmlString = htmlString.replace(/\*\*/g, '');

	// Normalize multiple dots
	htmlString = htmlString.replace(/\.{2,}/g, '.');

	// Trim extra spaces
	return htmlString.trim();
}

function showTypingIndicator() {
	// Create a new div for the typing indicator
	var typingIndicator = jQuery('<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>');

	// Append the typing indicator to the chat messages container
	jQuery('.chat-messages').append(typingIndicator);
}

function hideTypingIndicator() {
	// Remove the typing indicator from the chat messages container
	jQuery('.typing-indicator').remove();
}

function closePopup() {
	jQuery('#productPopup').fadeOut();
}

function showHideSourcesContainer(userMessageNo){
	
	const $sourcesContainer = jQuery('.bot-message[data-user-message-no="'+userMessageNo+'"]').find('.sources-container').first();
	if($sourcesContainer.legnth == 0) return;

	if ($sourcesContainer.is(':visible')) {
		$sourcesContainer.hide();
	} else {
		$sourcesContainer.show();
	}		
}

/*
function resetAllBotMessageSpeakerButtons() {
	
	jQuery('.bot-message-speaker-button img').each(function() {
		jQuery(this).attr('src',twxChat.images_path+'/tabler-icons/player-play.svg');
	});
	jQuery('.bot-message-speaker-button').each(function() {
		jQuery(this).removeClass('active');
	});
	
}
*/

function onBotMessagePlayButtonClick(userMessageNo){

	if(!m_useCustomVoice && !m_vaSpeechEventsTested && m_va){
		m_va.testAgentSpeechEvents().then((isWorking) => {
			m_vaSpeechEventsTested = true;
			if(!isWorking) onVASpeechEventsNotWorking();
		});
		return; // user may have to click Play button again, TODO fix
	}
	
	let playFromStart = false;
	if(m_isSpeechPaused){
		if(userMessageNo == m_speakingBotMessageNo){
			setAvatarStatus('responding');
			resumeSpeech();
		}else{
			playFromStart = true;
		}
	}else{
		playFromStart = true;
	}

	if(playFromStart){
		// Play from start
		cancelSpeech();
		var $botText = jQuery('.bot-message[data-user-message-no="'+userMessageNo+'"]').find('.bot-message-text').first();
		if($botText){
			m_speakingBotMessageNo = userMessageNo;
			setAvatarStatus('responding');
			m_isSpeaking = true;
			updateButtons();
			const speechText = getSpeechText($botText.html());
			startSpeech(speechText);

		}
	}
}

function onBotMessagePauseButtonClick(userMessageNo){

	if(m_isSpeechPaused) return;

	//var voice = jQuery('#twxchat-container').attr('data-voice');
	//var useCustomVoice = (voice == undefined || voice.trim() == '' ? false : true);
	/*
	if(m_customVoiceProvider && m_customVoiceName && m_avatar === null){
		var $botText = jQuery('.bot-message[data-user-message-no="'+userMessageNo+'"]').find('.bot-message-text').first();
		if($botText){
			var textAll = getSpeechText($botText.html());
			speakTextCustomVoice(userMessageNo, textAll);
		}
		return;
	}
	*/
	pauseSpeech();
}

function setCustomVoicePlayer(){

	// dynamically add audio player as some Wordpress themes interfere with this and affects playing

	var $parent = jQuery('#twx-chat-custom-voice-player-container');
	// look for direct child of parent
	if($parent.children('#custom-voice-player').length > 0){
		return;
	}
	// setup child
	var audioElem = jQuery('<audio id="custom-voice-player" controls style="display:none"><source id="custom-voice-source" src="" type="audio/mpeg"></audio>');
	$parent.append(audioElem);

	// need to set m_customVoicePlayer as standard HTML element, not jQuery object so we can .play .pause etc. Why can't we do that with m_audio ??
	m_customVoicePlayer = document.getElementById('custom-voice-player');
	m_customVoicePlayer.addEventListener('ended', function() {
		m_isSpeaking = false;
		if(m_allowStreaming || m_isStreaming){
			processStreamingSpeechQueue();
		}else{
			setAvatarStatus('idle');
			updateButtons();
		}
	});

}

function speakTextCustomVoiceNonStreaming(messageNo, text) {
	
	setCustomVoicePlayer();
	
	if(messageNo < 1){
		jQuery('#custom-voice-source').attr('src','/plugins/content/twx_chat/output/null.mp3');
		if(m_customVoicePlayer){
			if (!m_customVoicePlayer.paused) m_customVoicePlayer.pause();
			m_customVoicePlayer.load(); // Load the new source
			m_customVoicePlayer.play(); // Play the new source
		}
		return;
	}

	var voice = `${m_customVoiceProvider}:${m_customVoiceName}`;
	var url = twxChat.ajax_url;
	var threadId = jQuery('#twxchat-container').attr('data-thread-id');
	
	jQuery.ajax({
		url: url,
		type: 'POST',
		data: {
			action: 'twx_chat_createTTSFile',
			nonce: twxChat.nonce,
			text: text,
			threadId: threadId,
			messageNo: messageNo,
			ttsVoice: voice
		},
		success: function(response) {
			
			if (response.success) {
				if(response.data.voiceOutputFileName){
					jQuery('#custom-voice-source').attr('src',twxChat.mp3_output_path+'/'+response.data.voiceOutputFileName);
					if(m_customVoicePlayer){
						if (!m_customVoicePlayer.paused) m_customVoicePlayer.pause();
						m_customVoicePlayer.load(); // Load the new source
						m_customVoicePlayer.play(); // Play the new source
					}
				}
				
			} else {
				console.log('createTTSFile ajax failed',response);
				// use default browser voice
				m_va && m_va.speak(text);
			}
		},
		error: function(xhr, status, error) {
			console.log('AJAX error:', status, error);
			// use default browser voice
			m_va && m_va.speak(text);
		}
	});		
	
}

async function speakTextOpenAITTS(text, voice = "nova", onEnd = null) {

	console.log(`speakTextOpenAITTS:: voice: ${voice}`);

	const body = JSON.stringify({ text, voice, nonce: twxChat.nonce });
	const url = twxChat.openai_tts_handler;
	const response = await fetch(url, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: body
	});

	if (!response.ok) {
		console.error("speakTextOpenAITTS:: TTS Proxy error:", await response.text());
		if (onEnd) onEnd(new Error("Proxy TTS failed"));
		return;
	}

	const reader = response.body.getReader();
	const chunks = [];
	while (true) {
		const { done, value } = await reader.read();
		if (done) break;
		chunks.push(value);
	}

	const blob = new Blob(chunks, { type: "audio/mpeg" });
	const audioURL = URL.createObjectURL(blob);

	//TODO maybe. Can we utilise m_customVoicePlayer here instead of m_audio? Apparently <audio/> and Audio() are the same internal object
	m_audio = new Audio(audioURL);

	m_audio.onended = () => onEnd?.();
	m_audio.onerror = (e) => onEnd?.(new Error("Audio playback error"));

	m_audio.play();
}

function splitDataFromBotResponse(botResponse){

	const delim = '§';
	let before = '';
	let after = '';
	let foundData = false;

	if(botResponse === delim) {
		foundData = true;
	}else{
		before = botResponse;
		const lastDelimIndex = botResponse.lastIndexOf(delim);
		if(lastDelimIndex !== -1){
			foundData = true;
			before = botResponse.slice(0, lastDelimIndex);
			after = botResponse.slice(lastDelimIndex + 1).trim();
		}
	}

	return {before, after, foundData};
}

function splitBotResponseFromData(data){

	const delim = ']';
	let before = '';
	let after = '';
	let foundBotResponse = false;

	if(data === delim) {
		foundBotResponse = true;
		before = data;
	}else{
		before = data;
		const lastDelimIndex = data.lastIndexOf(delim);
		if(lastDelimIndex !== -1){
			foundBotResponse = true;
			before = data.slice(0, lastDelimIndex + 1);
			after = data.slice(lastDelimIndex + 1).trim();
		}
	}

	return {before, after, foundBotResponse};
	
}

function getSourceIdsFromBotResponse($container) {
    const ids = new Set();

    $container.find('span.inline-source').each(function () {
        const data = jQuery(this).attr('data-source-id');
        if (data) {
            data.split(',').forEach(id => {
                ids.add(id.trim());
            });
        }
    });

    return Array.from(ids);
}


function onSynthSpeechComplete(){
	m_isSpeaking = false;
	if(m_allowStreaming || m_isStreaming){
		processStreamingSpeechQueue();
	}else{
		setAvatarStatus('idle');
		updateButtons();
	}
}

function onOpenAITTSStreamComplete(err){
	if(err !== undefined) console.log("onOpenAITTSComplete:: error:",err);
	m_isSpeaking = false;
	// Assume m_allowStreaming is true otherwise we wouldnt get here
	if(m_isStreaming){
		processStreamingSpeechQueue();
	}else{
		if(m_speechQueue.length>0){
			//combine all messages remaining and speak them all using TTS streaming
			m_speechQueue = [m_speechQueue.join(" ")];
			processStreamingSpeechQueue();
		}else{
			updateButtons();
		}
	}
}

function processStreamingSpeechQueue() {
	// used in streaming to speak chunks of text in a queue. The queue may be appended to asyncronously
	//console.log(`processStreamingSpeechQueue() queue.length: ${m_speechQueue.length}`);
	
	if(m_speechQueue.length === 0) {
		// Auto-listen after finished speaking
		if(m_va && m_va.isContinuousListening() && !m_va.isListening()){
			// likely first time listening, restart listening
			if(m_avatar){
				if(!m_avatar.isSpeaking()) startAvatarListening();
			}else{
				m_isListening = true;
				m_va.isMicrophoneAvailable() && m_va.listen();
			}
		}else{
			if(TWXVoiceAgent.isAndroid() && m_va && !m_va.isListening() && m_avatar){
				// For Android we will start listening after agent speech once until timeout
				startAvatarListening();
			}
		} 

		setAvatarStatus('idle');
		m_avatar && m_avatar.lookAtCamera();
		m_isSpeaking = false;
		updateButtons();

		return;
	}
	
	if (m_speechQueue.length === 0 || m_isSpeaking || m_isSpeechPaused) {
		return;
	}
	
	let text = m_speechQueue.shift();
	if(text === '.') text = ''; //Sometimes the sentence parser can create a sentence with a single .

	//console.log(`processStreamingSpeechQueue() text: '${text}'`);
	const speechText = getSpeechText(text);
	startSpeech(speechText);
}

function openVoiceDialog() {
	jQuery('#voice-input-dialog').show();
}

function closeVoiceDialog() {
	jQuery('#voice-input-dialog').hide();
}

function cancelStream() {
	if (m_streamEventSource) {
		m_streamEventSource.close();
		m_streamEventSource = null;
	}
	if(m_isStreaming){
		// append actions
		var $botMsg = jQuery('.bot-message').last();
		var message = jQuery('.user-message').last().text();
		renderBotMessageActionsAndControls($botMsg, m_isVoiceInput, m_allowFeedback, message, null);
	}
	m_isStreaming = false;
	updateButtons();
}

function cancelQuery(){
	// Prevent any LLM query from responding
	m_forceStopQuery = true;
}

function isSafari(){
	return (navigator.vendor.indexOf('Apple') > -1);
}

function startSpeech(text){
	// common start speech fn
	console.log('startSpeech() text:',text); 

	m_isSpeaking = true;

	// Stop Listening if Agent interrupt not allowed
	if(m_va && !m_va.isAllowAgentInterrupt()){
		m_va.stopListening();
		m_isListening = false;
	}

	updateButtons();

	if(m_avatar){
		m_avatar.speak(text);
	}else{
		if(m_useCustomVoice){
			if(m_customVoiceProvider === 'openai' && (m_isStreaming || m_allowStreaming)){
				speakTextOpenAITTS(text, m_customVoiceName, onOpenAITTSStreamComplete);
			}else {
				speakTextCustomVoiceNonStreaming(m_speakingBotMessageNo, text);
			}
		}else{
			m_va && m_va.speak(text);
		}
	}
}

function cancelSpeech(andUpdateButtons = true) {
	// force cancel any speaking events
	try {
		if(m_avatar){
			m_avatar.stopSpeech();
		}else{
			if(m_useCustomVoice){
				if(m_audio){
					m_audio.pause();
					m_audio.currentTime = 0;
				}
				if(m_customVoicePlayer){
					m_customVoicePlayer.pause();
					m_customVoicePlayer.currentTime = 0;
				}
			}else{
				m_va && m_va.stopSpeaking();
			}
		}
	} catch (error) {
		console.log('error cancelling speech:',error);
	}
	m_isSpeaking = false;
	m_isSpeechPaused = false;
	m_speakingBotMessageNo = 0;
	m_speechQueue = [];
	m_speechTextBuffer = '';
	andUpdateButtons && updateButtons(); // this might be false if calling function also performing updateButtons
}

function resumeSpeech() {
	if (m_isSpeechPaused) {
		if(m_avatar){
			m_avatar.resumeSpeech();
		}else{
			if(m_useCustomVoice){
				m_audio && m_audio.play();
				m_customVoicePlayer && m_customVoicePlayer.play(); // there is no resume() method
			}else{
				m_va && m_va.resumeSpeaking();
			}
		}
		m_isSpeechPaused = false;
		m_isSpeaking = true;
	}
	updateButtons();
}

function pauseSpeech() {

	if (m_isSpeaking) {
		m_isSpeechPaused = true;
		m_isSpeaking = false;
		if(m_avatar){
			m_avatar.pauseSpeech();
		}else{
			if(m_useCustomVoice){
				m_audio && m_audio.pause();
				m_customVoicePlayer && m_customVoicePlayer.pause();
			}else{
				m_va && m_va.pauseSpeaking();
			}
		}
	}
	updateButtons();
}

function onVASpeechEventsNotWorking(){
	// Called if Synth Utterance Speech Events are not working
	// If streaming chatbot, downgrade to nearest Local Service is best option
	if(m_allowStreaming && m_va){
		m_va.downgradeVoiceToLocal();
	}
}

function onUserHyperlinkClick(url, messageNo){

	var threadId = jQuery('#twxchat-container').attr('data-thread-id');
	threadId = (threadId == undefined || threadId.trim() == '' ? '' : threadId);
	pushGAEvent('twxchat_source_click', {twxchat_source_url: url, twxchat_user_thread: threadId});
	var properties = {userHyperlinkClicks: {url:url, datetime:getLocalDateTime()}};
	// As this is an append request, only include hyperlinks in the properties
	updateChatLog('append', threadId, messageNo, properties);
}

function getLocalDateTime() {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-based
    const day = String(now.getDate()).padStart(2, '0');
    const hours = String(now.getHours()).padStart(2, '0');
    const minutes = String(now.getMinutes()).padStart(2, '0');
    const seconds = String(now.getSeconds()).padStart(2, '0');
    
    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}

function updateChatLog(updateType, threadId, messageNo, properties){

	// asyncronously update chat log property
	var url = twxChat.ajax_url;
	
	jQuery.ajax({
		url: url,
		type: 'POST',
		data: {
			action: 'twx_chat_updateChatLog',
			nonce: twxChat.nonce,
			updateType: updateType,
			threadId: threadId,
			messageNo: messageNo,
			properties: JSON.stringify(properties)
		},
		success: function(response) {
			
			if (response.success) {
				//console.log('updateChatLog ajax success',response.data);
			} else {
				console.log('updateChatLog ajax failed',response);
				//parentElem.append('<div class="error-message">' + response.error.message + '</div>'); 
			}
		},
		error: function(xhr, status, error) {
			console.log('AJAX error:', status, error);
			//parentElem.append('<div class="error-message">' + error.toString() + '</div>'); 
		}
	});		
	
}

function pushGAEvent(eventName, data = {}){
	
	if (typeof gtag === 'function') {
		data.debug_mode = isDebugOn(); // allows us to see event in DebugView in GA
		gtag('event', eventName, data);
	} else {
		console.warn('gtag not available — event not sent:', eventName);
	}
}

function logDeviceInfo() {
	const ua = navigator.userAgent;
	const platform = navigator.platform;
	const vendor = navigator.vendor;
  
	console.log('🖥️ Device Info:');
	console.log('User Agent:', ua);
	console.log('Platform:', platform);
	console.log('Vendor:', vendor);
  
	// Optional: Try to extract browser name (very basic)
	let browser = 'Unknown';
	if (ua.includes('Firefox')) browser = 'Firefox';
	else if (ua.includes('Edg')) browser = 'Edge';
	else if (ua.includes('Chrome')) browser = 'Chrome';
	else if (ua.includes('Safari')) browser = 'Safari';
  
	// Optional: Try to extract OS (basic)
	let os = 'Unknown';
	if (ua.includes('Windows')) os = 'Windows';
	else if (ua.includes('Mac OS')) os = 'macOS';
	else if (ua.includes('Android')) os = 'Android';
	else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
	else if (ua.includes('Linux')) os = 'Linux';
  
	console.log('Detected Browser:', browser);
	console.log('Detected OS:', os);
	console.log(`isMobile: ${isMobile() ? 'true' : 'false'}`);
}
  
function isDebugOn() {
	// return true if shortcode attribute debug="true" exists
	var debug = jQuery('#twxchat-container').attr('data-twx-debug');
	return (debug !== undefined && debug == 'true');
}

function beep() {
    var snd = new Audio("data:audio/wav;base64,//uQRAAAAWMSLwUIYAAsYkXgoQwAEaYLWfkWgAI0wWs/ItAAAGDgYtAgAyN+QWaAAihwMWm4G8QQRDiMcCBcH3Cc+CDv/7xA4Tvh9Rz/y8QADBwMWgQAZG/ILNAARQ4GLTcDeIIIhxGOBAuD7hOfBB3/94gcJ3w+o5/5eIAIAAAVwWgQAVQ2ORaIQwEMAJiDg95G4nQL7mQVWI6GwRcfsZAcsKkJvxgxEjzFUgfHoSQ9Qq7KNwqHwuB13MA4a1q/DmBrHgPcmjiGoh//EwC5nGPEmS4RcfkVKOhJf+WOgoxJclFz3kgn//dBA+ya1GhurNn8zb//9NNutNuhz31f////9vt///z+IdAEAAAK4LQIAKobHItEIYCGAExBwe8jcToF9zIKrEdDYIuP2MgOWFSE34wYiR5iqQPj0JIeoVdlG4VD4XA67mAcNa1fhzA1jwHuTRxDUQ//iYBczjHiTJcIuPyKlHQkv/LHQUYkuSi57yQT//uggfZNajQ3Vmz+Zt//+mm3Wm3Q576v////+32///5/EOgAAADVghQAAAAA//uQZAUAB1WI0PZugAAAAAoQwAAAEk3nRd2qAAAAACiDgAAAAAAABCqEEQRLCgwpBGMlJkIz8jKhGvj4k6jzRnqasNKIeoh5gI7BJaC1A1AoNBjJgbyApVS4IDlZgDU5WUAxEKDNmmALHzZp0Fkz1FMTmGFl1FMEyodIavcCAUHDWrKAIA4aa2oCgILEBupZgHvAhEBcZ6joQBxS76AgccrFlczBvKLC0QI2cBoCFvfTDAo7eoOQInqDPBtvrDEZBNYN5xwNwxQRfw8ZQ5wQVLvO8OYU+mHvFLlDh05Mdg7BT6YrRPpCBznMB2r//xKJjyyOh+cImr2/4doscwD6neZjuZR4AgAABYAAAABy1xcdQtxYBYYZdifkUDgzzXaXn98Z0oi9ILU5mBjFANmRwlVJ3/6jYDAmxaiDG3/6xjQQCCKkRb/6kg/wW+kSJ5//rLobkLSiKmqP/0ikJuDaSaSf/6JiLYLEYnW/+kXg1WRVJL/9EmQ1YZIsv/6Qzwy5qk7/+tEU0nkls3/zIUMPKNX/6yZLf+kFgAfgGyLFAUwY//uQZAUABcd5UiNPVXAAAApAAAAAE0VZQKw9ISAAACgAAAAAVQIygIElVrFkBS+Jhi+EAuu+lKAkYUEIsmEAEoMeDmCETMvfSHTGkF5RWH7kz/ESHWPAq/kcCRhqBtMdokPdM7vil7RG98A2sc7zO6ZvTdM7pmOUAZTnJW+NXxqmd41dqJ6mLTXxrPpnV8avaIf5SvL7pndPvPpndJR9Kuu8fePvuiuhorgWjp7Mf/PRjxcFCPDkW31srioCExivv9lcwKEaHsf/7ow2Fl1T/9RkXgEhYElAoCLFtMArxwivDJJ+bR1HTKJdlEoTELCIqgEwVGSQ+hIm0NbK8WXcTEI0UPoa2NbG4y2K00JEWbZavJXkYaqo9CRHS55FcZTjKEk3NKoCYUnSQ0rWxrZbFKbKIhOKPZe1cJKzZSaQrIyULHDZmV5K4xySsDRKWOruanGtjLJXFEmwaIbDLX0hIPBUQPVFVkQkDoUNfSoDgQGKPekoxeGzA4DUvnn4bxzcZrtJyipKfPNy5w+9lnXwgqsiyHNeSVpemw4bWb9psYeq//uQZBoABQt4yMVxYAIAAAkQoAAAHvYpL5m6AAgAACXDAAAAD59jblTirQe9upFsmZbpMudy7Lz1X1DYsxOOSWpfPqNX2WqktK0DMvuGwlbNj44TleLPQ+Gsfb+GOWOKJoIrWb3cIMeeON6lz2umTqMXV8Mj30yWPpjoSa9ujK8SyeJP5y5mOW1D6hvLepeveEAEDo0mgCRClOEgANv3B9a6fikgUSu/DmAMATrGx7nng5p5iimPNZsfQLYB2sDLIkzRKZOHGAaUyDcpFBSLG9MCQALgAIgQs2YunOszLSAyQYPVC2YdGGeHD2dTdJk1pAHGAWDjnkcLKFymS3RQZTInzySoBwMG0QueC3gMsCEYxUqlrcxK6k1LQQcsmyYeQPdC2YfuGPASCBkcVMQQqpVJshui1tkXQJQV0OXGAZMXSOEEBRirXbVRQW7ugq7IM7rPWSZyDlM3IuNEkxzCOJ0ny2ThNkyRai1b6ev//3dzNGzNb//4uAvHT5sURcZCFcuKLhOFs8mLAAEAt4UWAAIABAAAAAB4qbHo0tIjVkUU//uQZAwABfSFz3ZqQAAAAAngwAAAE1HjMp2qAAAAACZDgAAAD5UkTE1UgZEUExqYynN1qZvqIOREEFmBcJQkwdxiFtw0qEOkGYfRDifBui9MQg4QAHAqWtAWHoCxu1Yf4VfWLPIM2mHDFsbQEVGwyqQoQcwnfHeIkNt9YnkiaS1oizycqJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU=");  
    snd.play();
}

function playGlassTapSound() {
    const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    const oscillator = audioCtx.createOscillator();
    const gain = audioCtx.createGain();

    // Setup a short, high-pitched "ping"
    oscillator.type = 'sine';       // 'sine' or 'triangle' works well for glassy sounds
    oscillator.frequency.setValueAtTime(1800, audioCtx.currentTime); // high-pitched tone

    // Envelope to simulate a tap (fast decay)
    gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);

    oscillator.connect(gain);
    gain.connect(audioCtx.destination);

    oscillator.start();
    oscillator.stop(audioCtx.currentTime + 0.13); // short sound
}

function isValidJSON(str) {
    // First, check if it starts with either '[' or '{' (valid JSON structures)
    if (str.startsWith('[') || str.startsWith('{')) {
        try {
            JSON.parse(str);  // Try parsing it to see if it succeeds
            return true;  // If parsing succeeds, it's valid JSON
        } catch (e) {
            return false;  // If parsing fails, it's not valid JSON
        }
    }
    return false;  // If it doesn't start with valid JSON structure, return false
}

function isValidUrl(string) {
  try {
    new URL(string);
    return true;
  } catch (_) {
    return false;
  }
}

function isValidHTML(html) {
	const $test = jQuery('<div></div>').html(html);
	return $test.html() === html;
}

function isProbablyValidHTML(html) {
	const wrapper = document.createElement('div');
	try {
	  wrapper.innerHTML = html;
	  return true; // If it sets without throwing, it's likely valid
	} catch (e) {
	  return false;
	}
}

function getFormattedTime() {
	const now = new Date();
  
	// Get Sydney time components
	const formatter = new Intl.DateTimeFormat('en-AU', {
	  timeZone: 'Australia/Sydney',
	  hour: '2-digit',
	  minute: '2-digit',
	  second: '2-digit',
	  hour12: false
	});
  
	const parts = formatter.formatToParts(now);
	let hour, minute, second;
  
	for (const part of parts) {
	  if (part.type === 'hour') hour = part.value;
	  if (part.type === 'minute') minute = part.value;
	  if (part.type === 'second') second = part.value;
	}
  
	// Get fractional millis from performance.now()
	const millis = String(Math.floor(performance.now() % 1000)).padStart(3, '0');
  
	return `${hour}:${minute}:${second}.${millis}`;
}
	  
function turnAvatarMicrophoneOn(){
	console.log('turn microphone ON');
	//m_va && m_va.stopListening();
	//BUG if was speaking, and click here, getting rapid loop of start/stop listening in console
	if(m_va && m_va.isContinuousListening() && m_va.isListening()){
		setVAIgnoreUserSpeech(false);
	}else{
		startAvatarListening();
	}
}

function turnAvatarMicrophoneOff(){
	console.log('turn microphone OFF');
	m_isListening = false;
	updateButtons();
	setAvatarStatus('idle');

	if(m_va && m_va.isContinuousListening() && m_va.isListening()){
		setVAIgnoreUserSpeech(true);
	}else{
		m_va && m_va.stopListening();
	}

}

function toggleAvatarCamera($clickedBtn){

	switch($clickedBtn.attr('id')){
		case 'avatar-camera-off':
			console.log('turn camera ON');
			break;
		case 'avatar-camera-on':
			console.log('turn camera OFF');
			break;
	}
	jQuery(".avatar-control-btn.camera").toggle();
}

function setVAIgnoreUserSpeech(ignore) {

	// different to startAvatarListening etc as only sets flag to ignore user speech rather than stop/start the recognition object

	if(!ignore)	{
		m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
		m_avatarExitTimer && clearTimeout(m_avatarExitTimer);
	
		if(m_va.isMicrophoneAvailable()){
			m_isListening = true;
			setAvatarStatus((m_va.isContinuousListening() ? 'idle' : 'listening'));
			updateButtons();
			m_va && m_va.setIgnoreUserSpeech(ignore);
		}else{
			console.log('setVAIgnoreUserSpeech() no microphone. cannot change listening. setting to idle');
			setAvatarStatus('idle');
			updateButtons();
		}
	
	}else{

		m_isListening = false;
		updateButtons();
		setAvatarStatus('idle');
		m_va && m_va.setIgnoreUserSpeech(ignore);
	}

}

function startAvatarListening() {

	//console.log('startAvatarListening()');
	cancelSpeech(false);
	m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
	m_avatarExitTimer && clearTimeout(m_avatarExitTimer);

	if(m_va.isMicrophoneAvailable()){
		m_isListening = true;
		setAvatarStatus((m_va.isContinuousListening() ? 'idle' : 'listening'));
		updateButtons();
		m_va.listen();
	}else{
		console.log('startAvatarListening() no microphone. cannot start listening. setting to idle');
		setAvatarStatus('idle');
		updateButtons();
	}

}

function startAvatarIdleTimer(millis = 7000){
	if(m_avatar === null) return;
	m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
	m_avatarIdleTimer = setTimeout(function() {
		m_avatar.lookRandom();
		let randomMillis = Math.floor(Math.random() * (8000 - 5000 + 1)) + 5000; // between 5000 and 8000 ms
		startAvatarIdleTimer(randomMillis);
	}, millis);

}

function isMobile(){
	return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
}

function startAvatarExitTimer(){

	console.log('startAvatarExitTimer()');

	// Set Exit Timeout to save battery on mobile devices
	m_avatarExitTimer && clearTimeout(m_avatarExitTimer);
	m_avatarExitTimer = setTimeout(function() {
		console.log('idle timeout. quitting avatar');
		exitAvatar();
	}, 120000); // 2 mins idle timeout then quit

}

function maximizeAvatar(){
	jQuery('#avatar-container').addClass('fullscreen');
	jQuery('body').addClass('fullscreen-lock'); // prevents background scrolling
	jQuery(window).trigger('resize');
	m_avatar.resize();
	// show conversation panel if large screen
	if(wideAvatarContainer()) {
		jQuery('#avatar-conversation-widget').css('display','flex'); // flex allows inner scrolling
		scrollToChatbotMessagesEnd();
	}
	updateButtons();
}

function minimizeAvatar(){
	jQuery('#avatar-container').removeClass('fullscreen');
	jQuery('body').removeClass('fullscreen-lock');
	fadeOutAvatarFlashcards(true);
	jQuery(window).trigger('resize');
	m_avatar.resize();
	updateButtons();
}

function exitAvatar(centreText = 'Start'){

	// Quit out back to Avatar splash screen
	m_isStreaming && cancelStream();
	m_isQuerying && cancelQuery();
	m_isSpeaking && cancelSpeech();
	m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
	m_avatarExitTimer && clearTimeout(m_avatarExitTimer);
	m_va && m_va.stopListening();
	m_va = null;
	// stop avatar and return to greeting image
	m_avatar && m_avatar.exit();
	m_avatar = null; // prevents events after exit

	jQuery('.avatar-control-btn').hide(); // finally hide all control buttons
	jQuery('#avatar-status').hide();
	jQuery('#user-utterance').text('').hide();
	jQuery('.chatbot-container').hide();
	fadeOutAvatarFlashcards(true);
	jQuery('#avatar-greeting-button').hide(); // in case exit before start

	// load screen and spinner
	jQuery('#avatar-loadScreen').show();
	jQuery('#avatar-loader').addClass('hidden');
	jQuery('#avatar-loader-progress').addClass('hidden');
	
	jQuery('#avatar-greeting-loadScreen').show();
	jQuery('#avatar-start-text').text(centreText).show();
	jQuery('#avatar-container').removeClass('fullscreen');
	jQuery('body').removeClass('fullscreen-lock');

	if(m_style === 'floating'){
		jQuery('#floating-avatar-minimize').show();
	}else{
		jQuery(window).scrollTop(jQuery('#avatar-loadScreen').offset().top);
	}

}

function loadAvatar() {

	// Load Avatar Assets - Progress Bar should show

	if(!m_avatar || m_avatarLoading) return;

	m_avatarLoading = true;

	// show spinner
	jQuery('#avatar-loader').removeClass('hidden');
	jQuery('#avatar-loader-progress').removeClass('hidden');

	// hide start button
	jQuery('#avatar-start-text').hide();
	jQuery('#floating-avatar-minimize').hide();

	// Ignore some animations to decrease load time
	let ignoreAnimations = [
		TWXChatAvatar.OptionalAnimations.Gesture, 
		TWXChatAvatar.OptionalAnimations.Emote
	];

	m_avatar.load(ignoreAnimations).then((loadResult) => {

		if(loadResult.error){
			exitAvatar(loadResult.error);
			return;
		}

		console.log('m_avatar initialized');

		// Unhide default controls, others will already be hidden by CSS
		let controls = ['avatar-exit'];
		if(isDebugOn()) controls.push('avatar-debug-console-off');

		controls.forEach(id => {
			jQuery(`#${id}`).css('display','inline-block');
		});

		// Control click events
		jQuery('.avatar-control-btn.camera').click(function() { toggleAvatarCamera(jQuery(this)); });

		// Show conversation panel
		jQuery('#avatar-conversation-off').on('click', function() {
			jQuery(this).hide();
			jQuery('#avatar-conversation-widget').css('display','flex'); // Allows inner scrolling
			fadeOutAvatarFlashcards();
			//wide screen, zoom back a little
			if(wideAvatarContainer()) {
				m_avatar.zoomCamera('headUpperTorso',true);
			}
			jQuery('#avatar-conversation-on').show();
		});

		// hide conversation panel
		jQuery('#avatar-conversation-on').on('click', function() {
			jQuery(this).hide();
			jQuery('#avatar-conversation-widget').hide();
			//wide screen, zoom in a little
			if(wideAvatarContainer()) {
				m_avatar.zoomCamera('headShoulders',true);
			}
			jQuery('#avatar-conversation-off').show();
		});

		// Send user message button click
		jQuery('#avatar-user-send-btn').click(function() {
			let $inputField = jQuery('#user-input');
			if(!$inputField) return;
			let message = $inputField.val();
			if(message.length == 0) return;
			$inputField.val('');
			jQuery('#user-utterance').text('');
			var messageNo = jQuery('.chat-messages:first .user-message').length + 1;
			$inputField.closest('.chatbot-container').find('.chat-messages').append('<div class="user-message" data-message-no="' + messageNo + '">' + message + '</div>');
			runQuery(message);
		});

		// Detect User Glass Tap/Drag feature
		const $canvas = jQuery('#renderCanvas, #avatar-flashcards-widget');
		let canvasDragLookBackTimer = null;

		$canvas.on('pointerdown', function(e) {
			//console.log(`pointerdown: ${x},${y}`);
			// Only when Idle
			if(!m_avatar || jQuery('#avatar-status').text() !== 'idle') return;
			m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
			canvasDragLookBackTimer && clearTimeout(canvasDragLookBackTimer);
			m_isDraggingOnCanvas = true;
			playGlassTapSound();
			const canvasRect = $canvas[0].getBoundingClientRect();
			const x = e.clientX - canvasRect.left;
			const y = e.clientY - canvasRect.top;
			m_avatar.lookAtCoords(x, y);
			// pointerup/pointercancel events will restore to Idle
		});

		$canvas.on('pointermove', function(e) {
			//console.log(`pointermove: ${x},${y}`);
			if (!m_isDraggingOnCanvas || !m_avatar) return;
			const canvasRect = $canvas[0].getBoundingClientRect();
			const x = e.clientX - canvasRect.left;
			const y = e.clientY - canvasRect.top;
			m_avatar.lookAtCoords(x, y);
		});

		jQuery(document).on('pointerup pointercancel', function() {
			//console.log(`pointerup pointercancel`);
			if(!m_isDraggingOnCanvas || !m_avatar) return;
			m_isDraggingOnCanvas = false;
			canvasDragLookBackTimer = setTimeout(function () {
				m_avatar.lookAtCamera();
				// if tap count > 5, m_avatar.speak('Having fun?'); reset counter
				setAvatarStatus('idle');
			}, 1000);
		});

		// Hide the load screen and show the avatar scene etc
		if(m_style === 'embedded') {
			//jQuery('#avatar-container').addClass('fullscreen'); // maximize avatar scene if embedded
			maximizeAvatar();
		} 
		jQuery('#avatar-greeting-loadScreen').hide();
		jQuery('#avatar-loadScreen').hide();

		window.dispatchEvent(new Event('resize')); // force canvas resize to fit
		//updateButtons();

		jQuery("#user-input").val(''); // For some reason this gets populated with 'Hello' in Safari
		jQuery("#user-input").prop("disabled", false);

		m_avatar.lookAtCamera();
		m_avatarLoading = false;
		setAvatarStatus('idle');

		if(!m_userHasClicked){
			jQuery('#avatar-greeting-button').show(); // user must click a button to say greeting and start listening
		}else{
			startAvatar();
		}

	});

}

function startAvatar(){

	// Start Avatar Talking/Listening etc

	// Start Listening and speak greeting etc
	jQuery('#avatar-greeting-button').hide();

	if(TWXVoiceAgent.isSafari()) {
		// Safari allow immediate listening
		document.getElementById('avatar-microphone-off').style.display = 'inline-block';
		document.getElementById('avatar-microphone-on').style.display = 'none';
	}else{
		// Dont start Listening yet for Windows or Android as we get self-interruptions
		document.getElementById('avatar-microphone-on').style.display = 'inline-block';
		document.getElementById('avatar-microphone-off').style.display = 'none';
	}

	// show Avatar status
	document.getElementById('avatar-status').style.display = 'inline-block';

	// show Min/Max buttons
	if(m_style === 'floating'){
		jQuery('#avatar-maximize').css('display','inline-block');
	}else{
		// show conversation panel if wide enough
		if(wideAvatarContainer()) {
			jQuery('#avatar-conversation-widget').css('display','flex'); // Allows inner scrolling
			document.getElementById('avatar-conversation-on').style.display = 'inline-block';
		}else{
			document.getElementById('avatar-conversation-off').style.display = 'inline-block';
		}

		document.getElementById('user-utterance').style.display = 'inline-block';
	}

	// allow some time to go by to ensure microphone is on thus allowig Safari volume boost
	setTimeout(() => {
		setAvatarStatus('greeting');
		m_avatar.lookAtCamera().then(() => {
			let text = jQuery('#avatar-greeting-conversation').text();
			//text += `. And look over here <mark name='{"feature":"PointOfInterestFeature","method":"setTargetByName","args":["Flashcards"]}'/> at the flash cards. I should be looking at them now.`;
			/*
			text = `Hello. I'm Cristine, a Sumerian host. It's nice to meet you. 
			And look over here <mark name="{&quot;feature&quot;:&quot;PointOfInterestFeature&quot;,
			&quot;method&quot;:&quot;setTargetByName&quot;,&quot;args&quot;:[&quot;ConversationPanelFooter&quot;]}" /> 
			at the flash cards. I should be looking at them now.`;
			*/

			m_avatar.speak(text); // After Speak, will Listen as needed
		});
	},600);
}

function setAvatarStatus(status, timerMs = 20000){
	if(m_avatar === null) return;
	jQuery('#avatar-status').text(status);
	m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
	m_avatarExitTimer && clearTimeout(m_avatarExitTimer);
	switch(status){
		case 'idle':
			startAvatarIdleTimer(timerMs);
			isMobile() && startAvatarExitTimer();
			break;
		case 'listening':
			m_avatar.lookAtCamera();
			break;
		case 'thinking':
			m_avatar.lookThinking();
			break;
		case 'responding':
			m_avatar.lookAtCamera();
			break;
		case 'greeting':
			m_avatar.lookAtCamera();
			break;
	}
}

function wideAvatarContainer(){
	const $container = jQuery('#avatar-container');
	if(!$container) return false;
	//return ($container.width() > 767);
	return ($container.width() > 960);
}

function onWindowResize(){

	if(m_avatar === null) return;

	if(!wideAvatarContainer()){
		// turn off conversation widget and zoom to head and shoulders only
		m_avatar.zoomCamera('headShoulders',true);
		if (jQuery('#avatar-conversation-widget').is(":visible")) {
			jQuery('#avatar-conversation-on').click();
		}
      } else {
		m_avatar.zoomCamera('headUpperTorso',true);
	}	

	// If Wordpress Admin bar is present, shift controls down
	const $wpAdminBar = jQuery('#wpadminbar');
	if($wpAdminBar){
		let adminBarHeight = 0;
		var topPos = jQuery('#avatar-exit')[0].getBoundingClientRect().top;
		if(topPos === 0){
			adminBarHeight = $wpAdminBar.outerHeight();
		}
		jQuery('#avatar-maximize').css({top: `${adminBarHeight}px`});
		jQuery('#avatar-minimize').css({top: `${adminBarHeight}px`});
		jQuery('#avatar-exit').css({top: `${adminBarHeight}px`});
		jQuery('#avatar-status').css({top: `${adminBarHeight}px`});
		jQuery('#user-utterance').css({top: `${adminBarHeight}px`});
	}
}

function parseMarkdownLink(input) {
	const imageRegex = /^!\[([^\]]+)\]\(([^)]+)\)$/;
	const linkRegex = /^\[([^\]]+)\]\(([^)]+)\)$/;
  
	let match;
  
	if ((match = input.match(imageRegex))) {
	  return {
		title: match[1],
		link: match[2],
		type: 'img'
	  };
	} else if ((match = input.match(linkRegex))) {
	  return {
		title: match[1],
		link: match[2],
		type: 'link'
	  };
	} else {
	  return {
		title: null,
		link: null,
		type: 'not_markdown_link'
	  };
	}
}

function isImageUrl(text) {
	try {
	  const url = new URL(text);
	  const pathname = url.pathname.toLowerCase();
  
	  // Only consider it an image if it ends with a known image extension
	  const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.tiff'];
	  const isImage = imageExtensions.some(ext => pathname.endsWith(ext));

	  return isImage;
	} catch (e) {
	  // Invalid URL (e.g. plain text)
	  return false;
	}
}

function fadeOutAvatarFlashcards(force = false){

	let selector = '#avatar-flashcards-widget .flashcard:not(.preventTimeout)';
	if(force) selector = '#avatar-flashcards-widget .flashcard';
	jQuery(selector).fadeOut(300, function() {
		const $card = jQuery(this);
		$card.remove();
	});
	if(force) jQuery('#avatar-flashcards-widget').hide();
}

function addAvatarFlashcard(source, userMessageNo, preventTimeout = false) {
	m_avatarFlashcardsTimer && clearTimeout(m_avatarFlashcardsTimer);

	let type = 'link';
	if(source.type) {
		type = source.type;
	}else{
		if (isImageUrl(source.source)) type = 'image';
	}

	jQuery('#avatar-flashcards-widget').show();
	const $widget = jQuery('#avatar-flashcards-widget');
	const maxFlashcards = 20;

	// Dim existing cards
	$widget.find('.flashcard').css({
		background: '#e0e0e0',
		opacity: 0.4
	});

	// Create card
	let $card = jQuery('<div class="flashcard"></div>');
	if(preventTimeout) $card.addClass('preventTimeout');
	if(source.id) $card.attr('id',source.id);

	// Card styles
	$card.css({
		background: 'white',
		color: '#333',
		padding: '10px',
		borderRadius: '8px',
		boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
		marginBottom: '10px',
		opacity: 0,
		transform: 'translateY(-10px)',
		transition: 'opacity 0.3s ease, transform 0.3s ease',
		position: 'relative' // allow absolute positioning for close button
	});

	// Create and style close button
	const $closeBtn = jQuery('<button class="flashcard-close-btn">✕</button>');
	$closeBtn.css({
		position: 'absolute',
		top: '5px',
		right: '10px',
		background: 'transparent',
		border: 'none',
		fontSize: '16px',
		cursor: 'pointer',
		color: '#999',
		zIndex: 1
	});
	// Add close button to card
	$card.append($closeBtn);

	// Close button handler with animation
	$closeBtn.on('click', function () {
		$card.css({
			opacity: 0,
			transform: 'translateX(100px)',
			transition: 'opacity 0.3s ease, transform 0.3s ease'
		});
		setTimeout(() => {
			$card.remove();
		}, 300);
	});

	// Add content based on type
	if (type === 'image') {
		if (source.title) {
			$card.append(`<div style="font-weight: bold; margin-bottom: 5px;">${source.title}</div>`);
		}
		$card.append(`<img src="${source.source}" alt="${source.title || ''}" style="max-width:100%; border-radius:6px;" />`);

	} else if (type === 'link') {
		$card.append(`<a data-source-id="${source.source_id}" data-user-message-no="${userMessageNo}" href="${source.source}" target="_blank" style="font-weight:bold; color:blue; text-decoration:underline;">${source.title || source.source}</a>`);
		source.blurb && $card.append(`<div>${source.blurb}</div>`);
		appendSourceThumnailToFlashcard($card, source.source);

	} else if (source.html) {
		if (source.title) {
			$card.append(`<div style="font-weight: bold; margin-bottom: 5px;">${source.title}</div>`);
		}
		$card.append(source.html);
	}

	// Prepend new card
	$widget.prepend($card);

	// Animate in
	setTimeout(() => {
		$card.css({ opacity: 1, transform: 'translateY(0)' });
	}, 10);

	// Limit flashcards to max
	const $cards = $widget.find('.flashcard');
	if ($cards.length > maxFlashcards) {
		$cards.last().remove();
	}

	// Scroll to top
	$widget.scrollTop(0);
}

function appendSourceThumnailToFlashcard($card, target_url){

	jQuery.ajax({
		url: twxChat.ajax_url,
		type: 'POST',
		data: {
			action: 'twx_chat_fetchThumbnail',
			nonce: twxChat.nonce,
			target_url: target_url,
		},
		dataType: 'json',
		success: function(response) {
			//console.log('appendSourceThumnailToFlashcard response:', response);
			if (response.success && response.data.thumbnail) {
                const img = jQuery('<img>', {
                    src: response.data.thumbnail,
                    alt: 'Thumbnail',
                    style: 'max-width:50%; margin-top:10px; border-radius:6px;'
                });				
				$card.append(img);
			}
		},
		error: function(xhr, status, error) {
			console.log('appendSourceThumnailToFlashcard Error:', error);
			throw error;
		}
	});

}

function initAvatar(){

	// Initializes all components/listeners ready for Avatar start, including loading 3d model assets etc
	console.log('initAvatar');

	let avatar_character = jQuery('#twxchat-container').attr('data-avatar-character') ?? '';
	let avatar_voice = jQuery('#twxchat-container').attr('data-avatar-voice') ?? '';
	
	try {
		const streamingSpeech = !isSafari();
		m_avatar = new TWXChatAvatar(avatar_character, avatar_voice, twxChat.avatar_background_url, streamingSpeech); // class from js/twx-chat-avatar.js
	} catch (error) {
		console.log('error creating avatar:',error);
		return;
	}

	m_avatar.onSpeechComplete((event) => {

		//console.log('m_avatar.onSpeechComplete');

		// Start Flashcards timer
		if(m_source_flashcards){
			m_userInteractedAvatarFlashcards = false;
			m_avatarFlashcardsTimer && clearInterval(m_avatarFlashcardsTimer);
			m_avatarFlashcardsTimer = setTimeout(() => {
				fadeOutAvatarFlashcards();
			}, 8000);
		}

		if(m_allowStreaming){
			// must stop speech first otherwise no utterance occurs from avatar
			m_avatar.stopSpeech().then(() => {
				m_isSpeaking = false; // otherwise processStreamingSpeechQueue exits
				processStreamingSpeechQueue(); // will set to idle/listening once empty queue and avatar not speaking
			});
		}else{
			setAvatarStatus('idle');
			m_isSpeaking = false;
			updateButtons();
			if(m_va && m_va.isContinuousListening() && !m_va.isListening()){
				// restart listening
				startAvatarListening();
			}
		}
	});

	let smoothedPercent = 0;
	const smoothingFactor = 0.1; // between 0 (slow) and 1 (no smoothing)
	const debugOn = isDebugOn();

	m_avatar.onLoadingProgress((event) => {
		const rawPercent = event.detail.percent;
	
		// Apply exponential smoothing
		const newSmoothed = smoothedPercent + smoothingFactor * (rawPercent - smoothedPercent);
	
		// Ensure the progress never decreases
		if (newSmoothed > smoothedPercent) {
			smoothedPercent = newSmoothed;
		}
	
		const displayPercent = Math.round(smoothedPercent);
	
		if (debugOn) {
			let msg = event.detail.fileInfo.name + ' (' + displayPercent + '%)';
			jQuery('#avatar-loader-progress').text(msg);
		} else {
			jQuery('#avatar-loader-progress').text(displayPercent + '%');
		}
	});

	jQuery('#avatar-microphone-on').on('click', function() {
		turnAvatarMicrophoneOff();
	});

	jQuery('#avatar-microphone-off').on('click', function() {
		turnAvatarMicrophoneOn();
	});

	jQuery('#avatar-play').on('click', function() {
		resumeSpeech();
	});

	jQuery('#avatar-pause').on('click', function() {
		pauseSpeech();
	});

	jQuery('#avatar-maximize').on('click', function() {
		maximizeAvatar();
	});

	jQuery('#avatar-minimize').on('click', function() {
		minimizeAvatar();
	});

	jQuery('#avatar-exit').on('click', function() {
		exitAvatar();
	});

	jQuery('#avatar-debug-console-off').on('click', function() {
		jQuery('#avatar-debug-console-widget').show();
		jQuery(".avatar-control-btn.debug-console").toggle();
	});

	jQuery('#avatar-debug-console-on').on('click', function() {
		jQuery('#avatar-debug-console-widget').hide();
		jQuery(".avatar-control-btn.debug-console").toggle();
	});

	jQuery('#avatar-greeting-button').on('click', function() {
		m_userHasClicked = true;
		startAvatar();
	});

	jQuery('#avatar-start-text').on('click', function() {
		// Start from beginning, almost

		const preserve = ['m_userHasClicked'];
		initGlobals(preserve);
		main();
	});

	// Make avatar look at user typing, only in larger screens
	// Nice but Was slowing typing speed into input field
	/*
	if(wideAvatarContainer()){
		let typingTimer;
		jQuery('#user-input').on("input", function() {
			clearTimeout(typingTimer);
			m_avatarIdleTimer && clearTimeout(m_avatarIdleTimer);
			m_avatarExitTimer && clearTimeout(m_avatarExitTimer);
			
			m_avatar.lookAt('ConversationPanelFooter');

			typingTimer = setTimeout(function() {
				m_avatar.lookAtCamera();
				setAvatarStatus('idle');
			}, 2000);
		});
	}
	*/

	// Load Avatar Assets
	setTimeout(() => {
		loadAvatar();
	},300);
	
}

function initVoiceAgent(isAvatar){
	
	// Initialise Voice Agent which handles Voice Recognition (listening) and voice synthesis if needed
	const continuousListening = isAvatar && !TWXVoiceAgent.isAndroid(); // All non-Android avatars can use continuous listening, ie: no need to manually turn Mic ON
	const allowAgentInterrupt = isAvatar && TWXVoiceAgent.isSafari(); // Android and Windows not great at listening while Agent speaking (they self-interrupt)

	let params = {
		continuousListening: continuousListening,
		allowAgentInterrupt: allowAgentInterrupt,
		voiceName: m_synthVoiceName
	}; 
	m_va = new TWXVoiceAgent(params);

	// STT
	m_va.onInterimUserSpeechTranscribed((event) => {
		//console.log(`onInterimUserSpeechTranscribed. transcript: "${event.detail.transcript}"`);
		
		// heard words from user, cancel any current speech from agent
		cancelSpeech();

		// cancel any 'thinking' stream/query
		m_isStreaming && cancelStream();
		m_isQuerying && cancelQuery();

		m_isVoiceInput = true;
		if(m_avatar === null) {
			jQuery('.voice-input-background-effect').css('opacity', 1);
		}else{
			setAvatarStatus('listening'); // avatar looks at camera within this fn
			jQuery('#user-utterance').text(event.detail.transcript);
		}
	});

	m_va.onFinalUserSpeechTranscribed((event) => {
		console.log('onFinalUserSpeechTranscribed:',event.detail.transcript);
		if(!event.detail.transcript) return;
		let transcript = event.detail.transcript;
		let $inputField = jQuery('#user-input');
		$inputField.val('');
		var messageNo = jQuery('.chat-messages:first .user-message').length + 1;
		$inputField.closest('.chatbot-container').find('.chat-messages').append('<div class="user-message" data-message-no="' + messageNo + '">' + transcript + '</div>');
		if(m_avatar === null) {
			closeVoiceDialog(); // just in case browser (ie: Safari) doesnt get the onspeechend event
		}else{
			jQuery('#user-utterance').text(transcript);
		}
		runQuery(transcript);
	});

	m_va.onListenEnd((event) => {
		//console.log('onListenEnd() event');
		if(m_avatar === null) {
			jQuery('.voice-input-background-effect').css('opacity', 0);
			closeVoiceDialog();
		}else{
			m_isListening = false;
			updateButtons();
		}
	});

	// TTS
	if(!isAvatar){
		// Avatar will have its own TTS
		m_va && m_va.onAgentVoiceNotFound((event) => {
			//
		});

		m_va && m_va.onAgentSpeechStart((event) => {
			//event.detail.text;
		});

		m_va && m_va.onAgentSpeechEnd((event) => {
			onSynthSpeechComplete();
		});
	}

}

function setSystemVoice(voice){

	let provider = null;
	let voiceName = voice;
	
	if (voice.includes(':')) {
		[provider, voiceName] = voice.split(':', 2);
	}
	
	if(provider === 'openai'){
		m_customVoiceName = voiceName;
		m_customVoiceProvider = provider;
		m_useCustomVoice = true;
		console.log(`Custom voice requested: ${voice}`);
	}else{
		m_synthVoiceName = voiceName;
		console.log(`Browser voice requested: ${voiceName}`);
	}
}

function initChatbot() {
	
	jQuery("#user-input").removeAttr("disabled");
	updateButtons();

	// Close button click event
	jQuery('.close-button').on('click', function() {
		closePopup();
	});
}

function initGlobals(preserve = []) {

    if (!preserve.includes('m_debug')) m_debug = {};
    if (!preserve.includes('m_style')) m_style = '';
    if (!preserve.includes('m_userHasClicked')) m_userHasClicked = false;
    if (!preserve.includes('m_isVoiceInput')) m_isVoiceInput = false;
    if (!preserve.includes('m_isSpeaking')) m_isSpeaking = false;
    if (!preserve.includes('m_isListening')) m_isListening = false;
    if (!preserve.includes('m_forceStopQuery')) m_forceStopQuery = false;
    if (!preserve.includes('m_speakingBotMessageNo')) m_speakingBotMessageNo = 0;
    if (!preserve.includes('m_isSpeechPaused')) m_isSpeechPaused = false;
    if (!preserve.includes('m_isStreaming')) m_isStreaming = false;
    if (!preserve.includes('m_isQuerying')) m_isQuerying = false;
    if (!preserve.includes('m_streamEventSource')) m_streamEventSource = null;
    if (!preserve.includes('m_userInteractedConversationWidget')) m_userInteractedConversationWidget = false;
    if (!preserve.includes('m_userInteractedAvatarFlashcards')) m_userInteractedAvatarFlashcards = false;
    if (!preserve.includes('m_source_flashcards')) m_source_flashcards = true;
    if (!preserve.includes('m_avatarFlashcardsTimer')) m_avatarFlashcardsTimer = null;
    if (!preserve.includes('m_speechTextBuffer')) m_speechTextBuffer = '';
    if (!preserve.includes('m_speechQueue')) m_speechQueue = [];
    if (!preserve.includes('m_allowFeedback')) m_allowFeedback = false;
    if (!preserve.includes('m_allowStreaming')) m_allowStreaming = false;
    if (!preserve.includes('m_useCustomVoice')) m_useCustomVoice = false;
    if (!preserve.includes('m_synthVoiceName')) m_synthVoiceName = null;
    if (!preserve.includes('m_customVoiceName')) m_customVoiceName = null;
    if (!preserve.includes('m_customVoiceProvider')) m_customVoiceProvider = null;
    if (!preserve.includes('m_customVoicePlayer')) m_customVoicePlayer = null;
    if (!preserve.includes('m_avatar')) m_avatar = null;
    if (!preserve.includes('m_avatarLoading')) m_avatarLoading = false;
    if (!preserve.includes('m_avatarIdleTimer')) m_avatarIdleTimer = null;
    if (!preserve.includes('m_avatarExitTimer')) m_avatarExitTimer = null;
    if (!preserve.includes('m_va')) m_va = null;
    if (!preserve.includes('m_vaSpeechEventsTested')) m_vaSpeechEventsTested = false;
    if (!preserve.includes('m_audio')) m_audio = null;
    if (!preserve.includes('m_lastConsoleIdx')) m_lastConsoleIdx = -1;
}

function main() {

	// Main Start Function

	const $container = jQuery('#twxchat-container');

	// Style
	m_style = $container.attr('data-style');

	// Flashcards usage
	const flashcards = $container.attr('data-flashcards');
	m_source_flashcards = (flashcards !== undefined && flashcards == 'true');
	
	// Check if streaming allowed
	const dataStream = $container.attr('data-stream');
	m_allowStreaming = (dataStream !== undefined && dataStream == 'true');

	// Detect Allow feedback
	const allowFeedback = $container.attr('data-allow-feedback') ?? 'false';
	if(allowFeedback === 'logged_in_editor'){
		if($container.attr('data-user-can-edit') === 'true'){
			m_allowFeedback = true;
		}else{
			m_allowFeedback = false;
		}
	}else{
		m_allowFeedback = (allowFeedback !== undefined && allowFeedback === 'true');
	} 

	// Debug Console Widget Setup
	if(isDebugOn() && jQuery('#avatar-debug-console-widget').length>0){
		(function() {
			const originalLog = console.log;
		
			console.log = function(...args) {
				// Append to widget and scroll bottom
				const logContent = args.map(arg => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : arg)).join(" ");
				let $consoleContent = jQuery('#avatar-debug-console-content');
				$consoleContent.append(`<div class="debug-console-line">${getFormattedTime()}: ${logContent}</div>`);
				$consoleContent.scrollTop($consoleContent[0].scrollHeight);				

				// Call the original console.log
				originalLog.apply(console, args);
			};
		})();

		setInterval(() => {
			// every 5 seconds upload console logs to server

			const $lines = jQuery('.debug-console-line');
			const logEntries = [];
		  
			for (let i = m_lastConsoleIdx + 1; i < $lines.length; i++) {
				logEntries.push($lines.eq(i).text());
			}
			m_lastConsoleIdx = $lines.length-1;

			(logEntries.length>0) && $.ajax({
				url: twxChat.ajax_url,
				type: 'POST',
				data: { 
					action: 'twx_chat_uploadConsoleLog',
					nonce: twxChat.nonce,
					logEntries: logEntries,
				},
				error: function (jqXHR, textStatus, errorThrown) {
					// Restore logs to buffer if failed
				}
			});
			

		}, 5000);
	}

	// Check if Avatar or regular chatbot
	const chatbotType = jQuery('#twxchat-container').attr('data-type') ?? '';

	if(chatbotType === 'avatar'){

		if(isDebugOn()) logDeviceInfo();

		// detect user interaction on flashcards
		jQuery('#avatar-flashcards-widget').on('mousedown touchstart wheel', function() {
			m_userInteractedAvatarFlashcards = true;
			m_avatarFlashcardsTimer && clearTimeout(m_avatarFlashcardsTimer);
		});

		// If floating, setup events & styles
		if(m_style === 'floating'){
			jQuery('#twx-chat-minimized').on('click', function() {
				jQuery('#twx-chat-minimized').hide();
				jQuery('#twx-chat-floating').show();
			});
		
			// Minimize the floating chatbot
			jQuery('#floating-avatar-minimize').on('click', function() {
				jQuery('#twx-chat-floating').hide();
				jQuery('#twx-chat-minimized').show();
			});	

			isMobile() && jQuery('#avatar-greeting-button').text('Start');

			const watermarkHtml = jQuery('#twx-chat-watermark').html();
			if(watermarkHtml === undefined) jQuery('#twx-chat-floating').css('padding-bottom','10px');
		}

		// Speech to Text and Listening setup
		initVoiceAgent(true);
		initAvatar();
	}else{

		// Synth Voice used for Non-Avatars
		let voice = jQuery('#twxchat-container').attr('data-voice') ?? null;
		voice && setSystemVoice(voice);
		// Speech to Text and Listening setup
		initVoiceAgent(false);

		jQuery('.voice-input-button').on('click', function() {
			if(!m_useCustomVoice && !m_vaSpeechEventsTested && m_va){
				m_va.testAgentSpeechEvents().then((isWorking) => {
					m_vaSpeechEventsTested = true;
					if(!isWorking) onVASpeechEventsNotWorking();
				});
			}else{
				cancelSpeech();
			}

			try{
				if(m_customVoicePlayer && !m_customVoicePlayer.paused) m_customVoicePlayer.pause();
			}catch(error){
			}
			
			if(m_va.isMicrophoneAvailable()){
				openVoiceDialog();
				m_va.listen();
			}else{
				alert('Microphone not available');
			}
		});
	
		jQuery('.cancel-voice-input').on('click', function() {
			closeVoiceDialog();
			m_va.stopListening();
			//m_stt.stop();
		});
	
		jQuery('.cancel-stream-button').on('click', function() {
			cancelStream();
			cancelSpeech();
		});
	
		// Expand the floating chatbot
		jQuery('#expand-chatbot, #twx-chat-avatar-min').on('click', function() {
			jQuery('#twx-chat-minimized').hide(); // Hide minimized mode
			jQuery('#twx-chat-floating').show();  // Show expanded mode
		});
	
		// Minimize the floating chatbot
		jQuery('#minimize-chatbot').on('click', function() {
			jQuery('#twx-chat-floating').hide();   // Hide expanded mode
			jQuery('#twx-chat-minimized').show();  // Show minimized mode
		});	

		initChatbot();
	}
}

jQuery(document).ready(function($) {
	
	// Init Globals
	initGlobals();

	// Define events

	// Window resize
	$(window).resize(function () {
		onWindowResize();
	});

	// Handle user input and AJAX request to server
	$('#user-input').keypress(function(e) {
		if (e.which == 13) {
			let message = $(this).val();
			$(this).val('');
			var messageNo = jQuery('.chat-messages:first .user-message').length + 1;
			$(this).closest('.chatbot-container').find('.chat-messages').append('<div class="user-message" data-message-no="' + messageNo + '">' + message + '</div>');
			m_isVoiceInput = false;
			jQuery('#user-utterance').text('');
			runQuery(message);
		}
	});

	// Send button click event
	$('.send-button').click(function() {
		let inputField = $('#user-input');
		if(!inputField) return;
		let message = inputField.val();
		if(message.length == 0) return;
		inputField.val('');
		var messageNo = jQuery('.chat-messages:first .user-message').length + 1;
		inputField.closest('.chatbot-container').find('.chat-messages').append('<div class="user-message" data-message-no="' + messageNo + '">' + message + '</div>');
		m_isVoiceInput = false;
		runQuery(message);
	});

	$('.common-questions button').on('click', function() {
		var question = $(this).text();
		let inputField = $('#user-input');
		if(!inputField) return;
		var messageNo = jQuery('.chat-messages:first .user-message').length + 1;
		inputField.closest('.chatbot-container').find('.chat-messages').append('<div class="user-message" data-message-no="' + messageNo + '">' + question + '</div>');
		m_isVoiceInput = false;
		runQuery(question, true);
	});

	// Bot Response/Flashcard source click event handler
	$(document).on('click', 'span[data-source-id], a[data-source-id]', function (e) {
		e.preventDefault();

		const $el = $(this);
		const url = $el.attr('href') || ''; // fallback if no href present
		let messageNo = $el.data('user-message-no') || $el.closest('[data-user-message-no]').attr('data-user-message-no');

		if (url.startsWith('http')) {
			// Open the URL immediately
			window.open(url, '_blank');

			// Delay analytics or other work to avoid blocking navigation
			setTimeout(() => {
				if (messageNo) onUserHyperlinkClick(url, messageNo);
			}, 100);
		}
	});

	// detect user interaction during stream
	$('#avatar-conversation-widget').on('mousedown touchstart wheel', function() {
		m_userInteractedConversationWidget = true;
	});

	// Detect Plan
	if(window.TWXChatPro){
		console.log('Pro Plan user');
	}else if(window.TWXChatPlus){
		console.log('Plus Plan user');
	}else{
		console.log('Free Plan user');
	}

	main();

});

// Globals vars
let m_debug;
let m_style;
let m_userHasClicked; // Audio can only be played after user click on page occurs
let m_isVoiceInput;
let m_isSpeaking; // chatbot/avatar is speaking
let m_isListening; // chatbot/avatar is listening to user
let m_forceStopQuery; // used to stop agent from responding after query, eg: after user interrupt
let m_speakingBotMessageNo;
let m_isSpeechPaused;
let m_isStreaming;
let m_isQuerying;
let m_streamEventSource;
let m_userInteractedConversationWidget; // used to detect user interaction with conversation panel to prevent auto scroll etc
let m_userInteractedAvatarFlashcards; // used to detect user interaction with flashcards to prevent auto hide etc
let m_source_flashcards;
let m_avatarFlashcardsTimer;
let m_speechTextBuffer;
let m_speechQueue;
let m_allowFeedback;
let m_allowStreaming;
let m_useCustomVoice;
let m_synthVoiceName;
let m_customVoiceName;
let m_customVoiceProvider;
let m_customVoicePlayer;
let m_avatar; // TWXChatAvatar
let m_avatarLoading;
let m_avatarIdleTimer;
let m_avatarExitTimer;
let m_va; // TWXVoiceAgent for Synth Voices and Listening
let m_vaSpeechEventsTested;
let m_audio; // For OpenAI (and possibly other) TTS services
let m_lastConsoleIdx;
let m_isDraggingOnCanvas = false;

