/* global wpforms_ai_chat_element, WPFormsAIModal, wpf */

/**
 * @param this.modeStrings.learnMore
 * @param wpforms_ai_chat_element.dislike
 * @param wpforms_ai_chat_element.refresh
 * @param wpforms_ai_chat_element.confirm.refreshTitle
 * @param wpforms_ai_chat_element.confirm.refreshMessage
 * @param this.modeStrings.samplePrompts
 * @param this.modeStrings.errors.rate_limit
 * @param this.modeStrings.reasons.rate_limit
 * @param this.modeStrings.descrEndDot
 */

/**
 * The `WPFormsAIChatHTMLElement` element loader.
 *
 * @since 1.9.2
 */
( function() {
	// Additional modules provided by wpforms_ai_chat_element.
	const modules = wpforms_ai_chat_element.modules || [];

	// Import all modules dynamically.
	Promise.all( modules.map( ( module ) => import( module.path ) ) )
		.then( ( importedModules ) => {
			// Create the helper object dynamically.
			const helpers = {};
			let api;

			importedModules.forEach( ( module, index ) => {
				const moduleName = modules[ index ].name;
				if ( moduleName === 'api' ) {
					api = module.default();

					return;
				}
				helpers[ moduleName ] = module.default;
			} );

			window.WPFormsAi = {
				api,
				helpers,
			};

			// Register the custom HTML element.
			customElements.define( 'wpforms-ai-chat', WPFormsAIChatHTMLElement ); // eslint-disable-line no-use-before-define
		} )
		.catch( ( error ) => {
			wpf.debug( 'Error importing modules:', error );
		} );
}() );

/**
 * The WPForms AI chat.
 *
 * Custom HTML element class.
 *
 * @since 1.9.1
 */
class WPFormsAIChatHTMLElement extends HTMLElement {
	/**
	 * Whether the events have been initialized.
	 *
	 * @since 1.9.5
	 *
	 * @type {boolean}
	 */
	eventInitializes = false;

	/**
	 * Whether the prefilling has been submitted.
	 *
	 * @since 1.9.5
	 *
	 * @type {boolean}
	 */
	prefillSubmitted = false;

	/**
	 * The input (textarea) height.
	 *
	 * @type {Object}
	 */
	inputHeight = {
		min: 54,
		max: 95,
	};

	/**
	 * Element constructor.
	 *
	 * @since 1.9.1
	 */
	constructor() { // eslint-disable-line no-useless-constructor
		// Always call super() first in constructor.
		super();
	}

	/**
	 * Element connected to the DOM.
	 *
	 * @since 1.9.1
	 */
	connectedCallback() { // eslint-disable-line complexity
		// Init chat properties.
		this.chatMode = this.getAttribute( 'mode' ) ?? 'text';
		this.fieldId = this.getAttribute( 'field-id' ) ?? '';
		this.prefill = this.getAttribute( 'prefill' ) ?? '';
		this.autoSubmit = this.getAttribute( 'auto-submit' ) === 'true';
		this.modeStrings = wpforms_ai_chat_element[ this.chatMode ] ?? {};
		this.loadingState = false;

		// Init chats helpers according to the chat mode.
		this.modeHelpers = this.getHelpers( this );

		// Bail if chat mode helpers not found.
		if ( ! this.modeHelpers ) {
			console.error( `WPFormsAI error: chat mode "${ this.chatMode }" helpers not found` ); // eslint-disable-line no-console

			return;
		}

		// Render chat HTML.
		if ( ! this.innerHTML.trim() ) {
			this.innerHTML = this.getInnerHTML();
		}

		// Get chat elements.
		this.wrapper = this.querySelector( '.wpforms-ai-chat' );
		this.input = this.querySelector( '.wpforms-ai-chat-message-input input, .wpforms-ai-chat-message-input textarea' );
		this.welcomeScreenSamplePrompts = this.querySelector( '.wpforms-ai-chat-welcome-screen-sample-prompts' );
		this.sendButton = this.querySelector( '.wpforms-ai-chat-send' );
		this.stopButton = this.querySelector( '.wpforms-ai-chat-stop' );
		this.messageList = this.querySelector( '.wpforms-ai-chat-message-list' );

		// Flags.
		this.isTextarea = this.input.tagName === 'TEXTAREA';
		this.preventResizeInput = false;

		// Compact scrollbar for non-Mac devices.
		if ( ! navigator.userAgent.includes( 'Macintosh' ) ) {
			this.messageList.classList.add( 'wpforms-scrollbar-compact' );
		}

		// Bind events.
		this.events();

		// Init answers.
		this.initAnswers();

		// Init mode.
		if ( typeof this.modeHelpers.init === 'function' ) {
			this.modeHelpers.init();
		}

		// Auto-submit if enabled and prefill is provided
		if ( this.autoSubmit && this.prefill && ! this.prefillSubmitted ) {
			this.input.value = this.prefill;

			this.prefillSubmitted = true;

			setTimeout( () => this.sendMessage( true ), 250 );
		}
	}

	/**
	 * Get initial innerHTML markup.
	 *
	 * @since 1.9.1
	 *
	 * @return {string} The inner HTML markup.
	 */
	getInnerHTML() {
		if ( this.modeStrings.chatHtml ) {
			return this.decodeHTMLEntities( this.modeStrings.chatHtml );
		}

		return `
			<div class="wpforms-ai-chat">
				<div class="wpforms-ai-chat-message-list">
					${ this.getWelcomeScreen() }
				</div>
				<div class="wpforms-ai-chat-message-input">
					${ this.getMessageInputField() }
					<button type="button" class="wpforms-ai-chat-send"></button>
					<button type="button" class="wpforms-ai-chat-stop wpforms-hidden"></button>
				</div>
			</div>
		`;
	}

	/**
	 * Get the message input field HTML.
	 *
	 * @since 1.9.2
	 *
	 * @return {string} The message input field markup.
	 */
	getMessageInputField() {
		if ( typeof this.modeHelpers.getMessageInputField === 'function' ) {
			return this.modeHelpers.getMessageInputField();
		}

		return `<textarea placeholder="${ this.modeStrings.placeholder }"></textarea>`;
	}

	/**
	 * Get the Welcome screen HTML markup.
	 *
	 * @since 1.9.1
	 *
	 * @return {string} The Welcome screen markup.
	 */
	getWelcomeScreen() {
		let content;

		if ( this.modeHelpers.isWelcomeScreen() ) {
			content = this.getWelcomeScreenContent();
		} else {
			this.messagePreAdded = true;
			content = this.modeHelpers.getWarningMessage();
		}

		return `
			<div class="wpforms-ai-chat-message-item item-primary">
				<div class="wpforms-ai-chat-welcome-screen">
					<div class="wpforms-ai-chat-header">
						<h3 class="wpforms-ai-chat-header-title">${ this.modeStrings.title }</h3>
						<span class="wpforms-ai-chat-header-description">${ this.modeStrings.description }
							<a href="${ this.modeStrings.learnMoreUrl }" target="_blank" rel="noopener noreferrer">${ this.modeStrings.learnMore }</a>
						</span>
					</div>
					${ content }
				</div>
			</div>
		`;
	}

	/**
	 * Get the welcome screen content.
	 *
	 * @since 1.9.4
	 *
	 * @return {string} The welcome screen content.
	 */
	getWelcomeScreenContent() {
		const samplePrompts = this.modeStrings?.samplePrompts,
			li = [];

		if ( ! samplePrompts && ! this.modeStrings?.initialChat ) {
			return '';
		}

		if ( samplePrompts ) {
			// Render sample prompts.
			for ( const i in samplePrompts ) {
				li.push( `
					<li>
						<i class="${ samplePrompts[ i ].icon }"></i>
						<a href="#">${ samplePrompts[ i ].title }</a>
					</li>
				` );
			}

			return `
				<ul class="wpforms-ai-chat-welcome-screen-sample-prompts">
					${ li.join( '' ) }
				</ul>
			`;
		}

		if ( this.prefill.length > 0 ) {
			return '';
		}

		this.messagePreAdded = true;

		return this.modeHelpers?.getInitialChat( this.modeStrings.initialChat );
	}

	/**
	 * Get the spinner SVG image.
	 *
	 * @since 1.9.1
	 *
	 * @return {string} The spinner SVG markup.
	 */
	getSpinnerSvg() {
		return `<svg class="wpforms-ai-chat-spinner-dots" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s; fill: currentColor;}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>`;
	}

	/**
	 * Add event listeners.
	 *
	 * @since 1.9.1
	 */
	events() {
		if ( this.eventInitializes ) {
			return;
		}

		this.sendButton.addEventListener( 'click', this.sendMessage.bind( this ) );
		this.stopButton.addEventListener( 'click', this.stopLoading.bind( this ) );
		this.input.addEventListener( 'keydown', this.keyDown.bind( this ) );
		this.input.addEventListener( 'keyup', this.keyUp.bind( this ) );
		this.bindWelcomeScreenEvents();
		document.addEventListener( 'wpformsAIChatBeforeAddAnswer', this.preAnswer.bind( this ) );

		this.eventInitializes = true;
	}

	/**
	 * Bind welcome screen events.
	 *
	 * @since 1.9.1
	 */
	bindWelcomeScreenEvents() {
		if ( this.welcomeScreenSamplePrompts === null ) {
			return;
		}

		// Click on the default item in the welcome screen.
		this.welcomeScreenSamplePrompts.querySelectorAll( 'li' ).forEach( ( li ) => {
			li.addEventListener( 'click', this.clickDefaultItem.bind( this ) );

			li.addEventListener( 'keydown', ( e ) => {
				if ( e.code === 'Enter' ) {
					e.preventDefault();
					this.clickDefaultItem( e );
				}
			} );
		} );
	}

	/**
	 * Init all answers.
	 *
	 * @since 1.9.2
	 */
	initAnswers() {
		if ( ! this.modeStrings.chatHtml ) {
			return;
		}

		this.wpformsAiApi = this.getAiApi();

		this.messageList.querySelectorAll( '.wpforms-chat-item-answer' ).forEach( ( answer ) => {
			this.initAnswer( answer );
		} );
	}

	/**
	 * Keyboard `keyUp` event handler.
	 *
	 * @since 1.9.1
	 *
	 * @param {KeyboardEvent} e The keyboard event.
	 */
	keyUp( e ) { // eslint-disable-line complexity
		if ( typeof this.modeHelpers.resizeInput === 'function' ) {
			this.modeHelpers.resizeInput( e );
		} else {
			this.resizeInput( e );
		}

		switch ( e.code ) {
			case 'Enter':
				// Send a message on the ` Enter ` key press.
				// In the case of the textarea, `Shift + Enter` adds a new line.
				if ( ! this.isTextarea || ( this.isTextarea && ! e.shiftKey ) ) {
					e.preventDefault();
					this.sendMessage();
				}

				break;

			case 'ArrowUp':
				// Navigate through the chat history.
				// In the case of the textarea, `Ctrl + Up` is used.
				if ( ! this.isTextarea || ( this.isTextarea && e.ctrlKey ) ) {
					e.preventDefault();
					this.arrowUp();
				}
				break;

			case 'ArrowDown':
				// Navigate through the chat history.
				// In the case of the textarea, `Ctrl + Down` is used.
				if ( ! this.isTextarea || ( this.isTextarea && e.ctrlKey ) ) {
					e.preventDefault();
					this.arrowDown();
				}
				break;

			default:
				// Update the chat history.
				this.history.update( { question: this.input.value } );
		}
	}

	/**
	 * Detect the Enter key press.
	 * Prevent resizing the input if an Enter key is pressed without Shift.
	 *
	 * @since 1.9.5
	 *
	 * @param {KeyboardEvent} e The keyboard event.
	 */
	keyDown( e ) {
		this.preventResizeInput = e.code === 'Enter' && ! e.shiftKey;

		if ( this.preventResizeInput ) {
			e.preventDefault();
			this.setInputHeight( this.inputHeight.min );
		}
	}

	/**
	 * Resize textarea while added new lines.
	 *
	 * @since 1.9.5
	 */
	resizeInput() {
		if ( this.preventResizeInput ) {
			this.preventResizeInput = false;

			return;
		}

		// Reset style to get the correct scroll height.
		this.input.style.height = '';
		this.input.style.paddingTop = '10px';
		this.input.style.paddingBottom = '10px';

		let height;
		const scrollHeight = this.input.scrollHeight;

		// Calculate the height based on the scroll height.
		height = Math.min( scrollHeight, this.inputHeight.max );
		height = Math.max( height, this.inputHeight.min );

		this.setInputHeight( height );
	}

	/**
	 * Set textarea height.
	 *
	 * @since 1.9.5
	 *
	 * @param {number} height The height.
	 */
	setInputHeight( height ) {
		// Adjust padding based on the height.
		if ( height <= this.inputHeight.min ) {
			this.input.style.paddingTop = '';
			this.input.style.paddingBottom = '';
		}

		// Set the height.
		this.input.style.height = height + 'px';
		this.style.setProperty( '--wpforms-ai-chat-input-height', height + 'px' );
	}

	/**
	 * Send a question message to the chat.
	 *
	 * @since 1.9.1
	 *
	 * @param {boolean} allowHTML Whether to allow HTML in the message.
	 */
	sendMessage( allowHTML = false ) {
		let message = this.input.value;

		if ( ! message ) {
			return;
		}

		if ( ! allowHTML ) {
			message = this.htmlSpecialChars( message );
		}

		// Fire event before sending the message.
		this.triggerEvent( 'wpformsAIChatBeforeSendMessage', { fieldId: this.fieldId, mode: this.chatMode } );

		this.addFirstMessagePre();
		this.welcomeScreenSamplePrompts?.remove();

		this.resetInput();
		this.addMessage( message, true );
		this.startLoading();

		if ( message.trim() === '' ) {
			this.addEmptyResultsError();

			return;
		}

		if ( typeof this.modeHelpers.prepareMessage === 'function' ) {
			message = this.modeHelpers.prepareMessage( message );
		}

		this.getAiApi()
			.prompt( message, this.sessionId )
			.then( this.addAnswer.bind( this ) )
			.catch( this.apiResponseError.bind( this ) );
	}

	/**
	 * AI API error handler.
	 *
	 * @since 1.9.2
	 *
	 * @param {Object|string} error The error object or string.
	 */
	apiResponseError( error ) { // eslint-disable-line complexity
		const cause	= error?.cause ?? null;

		this.triggerEvent( 'wpformsAIChatBeforeError', { fieldId: this.fieldId } );

		// Handle the rate limit error.
		if ( cause === 429 ) {
			this.addError(
				this.modeStrings.errors.rate_limit || wpforms_ai_chat_element.errors.rate_limit,
				this.modeStrings.reasons.rate_limit || wpforms_ai_chat_element.reasons.rate_limit
			);

			return;
		}

		// Handle the Internal Server Error.
		if ( cause === 500 ) {
			this.addEmptyResultsError();

			return;
		}

		this.addError(
			error.message || this.modeStrings.errors.default || wpforms_ai_chat_element.errors.default,
			this.modeStrings.reasons.default || wpforms_ai_chat_element.reasons.default
		);

		wpf.debug( 'WPFormsAI error: ', error );
	}

	/**
	 * Before the first message.
	 *
	 * @since 1.9.1
	 */
	addFirstMessagePre() {
		if ( this.sessionId || this.messagePreAdded ) {
			return;
		}

		this.messagePreAdded = true;

		const divider = document.createElement( 'div' );

		divider.classList.add( 'wpforms-ai-chat-divider' );
		this.messageList.appendChild( divider );
	}

	/**
	 * Click on the default item in the welcome screen.
	 *
	 * @since 1.9.1
	 *
	 * @param {Event} e The event object.
	 */
	clickDefaultItem( e ) {
		const li = e.target.nodeName === 'LI' ? e.target : e.target.closest( 'li' );
		const message = li.querySelector( 'a' )?.textContent;

		e.preventDefault();

		if ( ! message ) {
			return;
		}

		this.input.value = message;

		// Update the chat history.
		this.history.push( { question: message } );

		this.sendMessage();
	}

	/**
	 * Click on the dislike button.
	 *
	 * @since 1.9.1
	 *
	 * @param {Event} e The event object.
	 */
	clickDislikeButton( e ) {
		const button = e.target;
		const answer = button?.closest( '.wpforms-chat-item-answer' );

		if ( ! answer ) {
			return;
		}

		button.classList.add( 'clicked' );
		button.setAttribute( 'disabled', true );

		const responseId = answer.getAttribute( 'data-response-id' );

		this.wpformsAiApi.rate( false, responseId );
	}

	/**
	 * Click on the refresh button.
	 *
	 * @since 1.9.1
	 */
	async clickRefreshButton() {
		const refreshConfirm = () => {
			// Restore the welcome screen.
			this.prefill = '';
			this.messageList.innerHTML = this.getWelcomeScreen();
			this.welcomeScreenSamplePrompts = this.querySelector( '.wpforms-ai-chat-welcome-screen-sample-prompts' );
			this.bindWelcomeScreenEvents();
			this.scrollMessagesTo( 'top' );

			// Clear the session ID.
			this.wpformsAiApi = null;
			this.sessionId = null;
			this.messagePreAdded = null;
			this.wrapper.removeAttribute( 'data-session-id' );

			// Clear the chat history.
			this.history.clear();

			// Fire the event after refreshing the chat.
			this.triggerEvent( 'wpformsAIChatAfterRefresh', { fieldId: this.fieldId } );
		};

		const refreshCancel = () => {
			// Fire the event when refresh is canceled.
			this.triggerEvent( 'wpformsAIChatCancelRefresh', { fieldId: this.fieldId } );
		};

		// Fire the event before refresh confirmation is opened.
		this.triggerEvent( 'wpformsAIChatBeforeRefreshConfirm', { fieldId: this.fieldId } );

		// Open a confirmation modal.
		WPFormsAIModal.confirmModal( {
			title: wpforms_ai_chat_element.confirm.refreshTitle,
			content: wpforms_ai_chat_element.confirm.refreshMessage,
			onConfirm: refreshConfirm,
			onCancel: refreshCancel,
		} );
	}

	/**
	 * Start loading.
	 *
	 * @since 1.9.1
	 */
	startLoading() {
		this.loadingState = true;
		this.sendButton.classList.add( 'wpforms-hidden' );
		this.stopButton.classList.remove( 'wpforms-hidden' );
		this.input.setAttribute( 'disabled', true );
		this.input.setAttribute( 'placeholder', this.modeStrings.waiting );
	}

	/**
	 * Stop loading.
	 *
	 * @since 1.9.1
	 */
	stopLoading() {
		this.loadingState = false;
		this.messageList.querySelector( '.wpforms-chat-item-answer-waiting' )?.remove();
		this.sendButton.classList.remove( 'wpforms-hidden' );
		this.stopButton.classList.add( 'wpforms-hidden' );
		this.input.removeAttribute( 'disabled' );
		this.input.setAttribute( 'placeholder', this.modeStrings.placeholder );
		this.input.focus();
	}

	/**
	 * Keyboard `ArrowUp` key event handler.
	 *
	 * @since 1.9.1
	 */
	arrowUp() {
		const prev = this.history.prev()?.question;

		if ( typeof prev !== 'undefined' ) {
			this.input.value = prev;
		}
	}

	/**
	 * Keyboard `ArrowDown` key event handler.
	 *
	 * @since 1.9.1
	 */
	arrowDown() {
		const next = this.history.next()?.question;

		if ( typeof next !== 'undefined' ) {
			this.input.value = next;
		}
	}

	/**
	 * Get AI API object instance.
	 *
	 * @since 1.9.1
	 *
	 * @return {Object} The AI API object.
	 */
	getAiApi() {
		if ( this.wpformsAiApi ) {
			return this.wpformsAiApi;
		}

		// Attempt to get the session ID from the element attribute OR the data attribute.
		// It is necessary to restore the session ID after restoring the chat element.
		this.sessionId = this.wrapper.getAttribute( 'data-session-id' ) || null;

		// Create a new AI API object instance.
		this.wpformsAiApi = window.WPFormsAi.api( this.chatMode, this.sessionId );

		return this.wpformsAiApi;
	}

	/**
	 * Scroll a message list to given edge.
	 *
	 * @since 1.9.1
	 *
	 * @param {string} edge The edge to scroll to; `top` or `bottom`.
	 */
	scrollMessagesTo( edge = 'bottom' ) {
		if ( edge === 'top' ) {
			this.messageList.scrollTop = 0;

			return;
		}

		if ( this.messageList.scrollHeight - this.messageList.scrollTop < 22 ) {
			return;
		}

		this.messageList.scrollTop = this.messageList.scrollHeight;
	}

	/**
	 * Add a message to the chat.
	 *
	 * @since 1.9.1
	 *
	 * @param {string}  message    The message to add.
	 * @param {boolean} isQuestion Whether it is a question.
	 * @param {Object}  response   The response data, optional.
	 * @param {boolean} preMessage Add a preliminary message, optional.
	 */
	addMessage( message, isQuestion, response = null, preMessage = false ) {
		const { messageList } = this;
		const element = document.createElement( 'div' );

		element.classList.add( 'wpforms-chat-item' );

		if ( preMessage ) {
			element.classList.add( 'wpforms-chat-item-pre-message' );
			element.classList.add( 'pre-active' );
		}

		messageList.appendChild( element );

		if ( isQuestion ) {
			// Add a question.
			element.innerHTML = message;
			element.classList.add( 'wpforms-chat-item-question' );

			// Add a waiting spinner.
			const spinnerWrapper = document.createElement( 'div' ),
				spinner = document.createElement( 'div' );

			spinnerWrapper.classList.add( 'wpforms-chat-item-answer-waiting' );
			spinner.classList.add( 'wpforms-chat-item-spinner' );
			spinner.innerHTML = this.getSpinnerSvg();
			spinnerWrapper.appendChild( spinner );
			messageList.appendChild( spinnerWrapper );

			// Add an empty chat history item.
			this.history.push( {} );
		} else {
			// Add an answer.
			const itemContent = document.createElement( 'div' );

			itemContent.classList.add( 'wpforms-chat-item-content' );
			element.appendChild( itemContent );

			// Remove the waiting spinner.
			messageList.querySelector( '.wpforms-chat-item-answer-waiting' )?.remove();

			// Remove the active class from the previous answer.
			this.messageList.querySelector( '.wpforms-chat-item-answer.active' )?.classList.remove( 'active' );
			this.messageList.querySelector( '.wpforms-chat-item-answer.pre-active' )?.classList.add( 'active' );
			this.messageList.querySelector( '.wpforms-chat-item-answer.pre-active' )?.classList.remove( 'pre-active' );

			// Update element classes and attributes.
			element.classList.add( 'wpforms-chat-item-answer' );
			element.classList.add( 'active' );
			element.classList.add( 'wpforms-chat-item-typing' );
			element.classList.add( 'wpforms-chat-item-' + this.chatMode );
			element.setAttribute( 'data-response-id', response?.responseId ?? '' );

			// Update the answer in the chat history.
			this.history.update( { answer: message } );

			// Type the message with the typewriter effect.
			if ( ! preMessage ) {
				this.typeText( itemContent, message, this.addedAnswer.bind( this ) );

				return;
			}

			itemContent.innerHTML = message;
		}

		this.scrollMessagesTo( 'bottom' );
	}

	/**
	 * Add an error to the chat.
	 *
	 * @since 1.9.1
	 *
	 * @param {string} errorTitle  The error title.
	 * @param {string} errorReason The error title.
	 */
	addError( errorTitle, errorReason ) {
		this.addNotice( errorTitle, errorReason );
	}

	/**
	 * Add a warning to the chat.
	 *
	 * @since 1.9.2
	 *
	 * @param {string} warningTitle  The warning title.
	 * @param {string} warningReason The warning reason.
	 */
	addWarning( warningTitle, warningReason ) {
		this.addNotice( warningTitle, warningReason, 'warning' );
	}

	/**
	 * Add a notice to the chat.
	 *
	 * @since 1.9.2
	 *
	 * @param {string} title  The notice title.
	 * @param {string} reason The notice reason.
	 * @param {string} type   The notice type.
	 */
	addNotice( title, reason, type = 'error' ) {
		let content = ``;

		// Bail if loading was stopped.
		if ( ! this.loadingState ) {
			return;
		}

		if ( title ) {
			content += `<h4>${ title }</h4>`;
		}

		if ( reason ) {
			content += `<span>${ reason }</span>`;
		}

		const chatItem = document.createElement( 'div' );
		const itemContent = document.createElement( 'div' );

		chatItem.classList.add( 'wpforms-chat-item' );
		chatItem.classList.add( 'wpforms-chat-item-' + type );
		itemContent.classList.add( 'wpforms-chat-item-content' );
		chatItem.appendChild( itemContent );

		this.messageList.querySelector( '.wpforms-chat-item-answer-waiting' )?.remove();
		this.messageList.appendChild( chatItem );

		// Add the error to the chat.
		// Type the message with the typewriter effect.
		this.typeText( itemContent, content, () => {
			this.stopLoading();
		} );
	}

	/**
	 * Add an empty results error to the chat.
	 *
	 * @since 1.9.1
	 */
	addEmptyResultsError() {
		this.addError(
			this.modeStrings.errors.empty || wpforms_ai_chat_element.errors.empty,
			this.modeStrings.reasons.empty || wpforms_ai_chat_element.reasons.empty
		);
	}

	/**
	 * Add a prohibited code warning to the chat.
	 *
	 * @since 1.9.2
	 */
	addProhibitedCodeWarning() {
		this.addWarning(
			this.modeStrings.warnings.prohibited_code || wpforms_ai_chat_element.warnings.prohibited_code,
			this.modeStrings.reasons.prohibited_code || wpforms_ai_chat_element.reasons.prohibited_code
		);
	}

	/**
	 * Display a speech bubble with a pre-answer message if it is available.
	 *
	 * @since 1.9.7
	 *
	 * @param {Object} event The event object.
	 */
	preAnswer( event ) {
		const { response } = event.detail;

		if ( ! response.preMessage ) {
			return;
		}

		// Sanitize response.
		const sanitizedResponse = this.sanitizeResponse( { ...response } );

		if ( ! sanitizedResponse.preMessage || ! sanitizedResponse.preMessage.title || ! sanitizedResponse.preMessage.text ) {
			return;
		}

		const preAnswerHTML = `<h4>${ sanitizedResponse.preMessage.title }</h4><span>${ sanitizedResponse.preMessage.text }</span>`;
		this.addMessage( preAnswerHTML, false, sanitizedResponse, true );
	}

	/**
	 * Add an answer to the chat.
	 *
	 * @since 1.9.1
	 *
	 * @param {Object} response The response data to add.
	 */
	addAnswer( response ) {
		// Bail if loading was stopped.
		if ( ! this.loadingState || ! response ) {
			return;
		}

		// Output processing time to console if available.
		if ( response.processingData ) {
			wpf.debug( 'WPFormsAI processing data:', response.processingData );
		}

		// Sanitize response.
		const sanitizedResponse = this.sanitizeResponse( { ...response } );

		if ( this.hasProhibitedCode( response, sanitizedResponse ) ) {
			this.triggerEvent( 'wpformsAIChatBeforeError', { fieldId: this.fieldId } );

			this.addProhibitedCodeWarning();

			return;
		}

		const answerHTML = this.modeHelpers.getAnswer( sanitizedResponse );

		if ( ! answerHTML ) {
			this.triggerEvent( 'wpformsAIChatBeforeError', { fieldId: this.fieldId } );

			this.addEmptyResultsError();

			return;
		}

		// Store the session ID from response.
		this.sessionId = response.sessionId;

		// Set the session ID to the chat wrapper data attribute.
		this.wrapper.setAttribute( 'data-session-id', this.sessionId );

		// Fire the event before adding the answer to the chat.
		this.triggerEvent( 'wpformsAIChatBeforeAddAnswer', { chat: this, response: sanitizedResponse } );

		// Add the answer to the chat.
		this.addMessage( answerHTML, false, sanitizedResponse );

		this.triggerEvent( 'wpformsAIChatAfterAddAnswer', { fieldId: this.fieldId } );
	}

	/**
	 * Check if the response has a prohibited code.
	 *
	 * @since 1.9.2
	 *
	 * @param {Object} response          The response data.
	 * @param {Array}  sanitizedResponse The sanitized response data.
	 *
	 * @return {boolean} Whether the answer has a prohibited code.
	 */
	hasProhibitedCode( response, sanitizedResponse ) {
		if ( typeof this.modeHelpers.hasProhibitedCode === 'function' ) {
			return this.modeHelpers.hasProhibitedCode( response, sanitizedResponse );
		}

		return false;
	}

	/**
	 * Sanitize response.
	 *
	 * @since 1.9.2
	 *
	 * @param {Object} response The response data to sanitize.
	 *
	 * @return {Object} The sanitized response.
	 */
	sanitizeResponse( response ) {
		if ( typeof this.modeHelpers.sanitizeResponse === 'function' ) {
			return this.modeHelpers.sanitizeResponse( response );
		}

		return response;
	}

	/**
	 * The added answer callback.
	 *
	 * @since 1.9.1
	 *
	 * @param {HTMLElement} element The answer element.
	 */
	addedAnswer( element ) {
		// Add answer buttons when typing is finished.
		element.innerHTML += this.getAnswerButtons();
		element.parentElement.classList.remove( 'wpforms-chat-item-typing' );

		this.stopLoading();
		this.initAnswer( element );

		// Added answer callback.
		this.modeHelpers.addedAnswer( element );

		// Fire the event when the answer added to the chat.
		this.triggerEvent( 'wpformsAIChatAddedAnswer', { chat: this, element } );
	}

	/**
	 * Init answer.
	 *
	 * @since 1.9.2
	 *
	 * @param {HTMLElement} element The answer element.
	 */
	initAnswer( element ) {
		if ( ! element ) {
			return;
		}

		// Prepare answer buttons and init the tooltips.
		element.querySelectorAll( '.wpforms-help-tooltip' ).forEach( ( icon ) => {
			let title = icon.getAttribute( 'title' );

			if ( ! title ) {
				title =	icon.classList.contains( 'dislike' ) ? wpforms_ai_chat_element.dislike : '';
				title = icon.classList.contains( 'refresh' ) ? wpforms_ai_chat_element.refresh : title;

				icon.setAttribute( 'title', title );
			}

			icon.classList.remove( 'tooltipstered' );
		} );

		wpf.initTooltips( element );

		// Add event listeners.
		element.addEventListener( 'click', this.setActiveAnswer.bind( this ) );

		element.querySelector( '.wpforms-ai-chat-answer-button.dislike' )
			?.addEventListener( 'click', this.clickDislikeButton.bind( this ) );

		element.querySelector( '.wpforms-ai-chat-answer-button.refresh' )
			?.addEventListener( 'click', this.clickRefreshButton.bind( this ) );
	}

	/**
	 * Set an active answer.
	 *
	 * @since 1.9.2
	 *
	 * @param {Event} e The event object.
	 */
	setActiveAnswer( e ) {
		let answer = e.target.closest( '.wpforms-chat-item-answer' );

		answer = answer || e.target;

		if ( answer.classList.contains( 'active' ) ) {
			return;
		}

		this.messageList.querySelector( '.wpforms-chat-item-answer.active' )?.classList.remove( 'active' );
		answer.classList.add( 'active' );

		const responseId = answer.getAttribute( 'data-response-id' );

		if ( this.modeHelpers.setActiveAnswer ) {
			this.modeHelpers.setActiveAnswer( answer );
		}

		// Trigger the event.
		this.triggerEvent( 'wpformsAIChatSetActiveAnswer', { chat: this, responseId } );
	}

	/**
	 * Get the answer buttons HTML markup.
	 *
	 * @since 1.9.1
	 *
	 * @return {string} The answer buttons HTML markup.
	 */
	getAnswerButtons() {
		return `
			<div class="wpforms-ai-chat-answer-buttons">
				${ this.modeHelpers.getAnswerButtonsPre() }
				<div class="wpforms-ai-chat-answer-buttons-response">
					<button type="button" class="wpforms-ai-chat-answer-button dislike wpforms-help-tooltip" data-tooltip-position="top" title="${ wpforms_ai_chat_element.dislike }"></button>
					<button type="button" class="wpforms-ai-chat-answer-button refresh wpforms-help-tooltip" data-tooltip-position="top" title="${ wpforms_ai_chat_element.refresh }">
						<i class="fa fa-trash-o"></i>
					</button>
				</div>
			</div>
		`;
	}

	/**
	 * Type text into an element with the typewriter effect.
	 *
	 * @since 1.9.1
	 *
	 * @param {HTMLElement} element          The element to type into.
	 * @param {string}      text             The text to type.
	 * @param {Function}    finishedCallback The callback function to call when typing is finished.
	 */
	typeText( element, text, finishedCallback ) {
		const chunkSize = 5;
		const chat = this;
		let index = 0;
		let content = '';

		/**
		 * Type a single character.
		 *
		 * @since 1.9.1
		 */
		function type() {
			const chunk = text.substring( index, index + chunkSize );

			content += chunk;
			// Remove a broken HTML tag from the end of the string.
			element.innerHTML = content.replace( /<[^>]{0,300}$/g, '' );
			index += chunkSize;

			if ( index < text.length && chat.loadingState ) {
				// Recursive call to output the next chunk.
				setTimeout( type, 20 );
			} else if ( typeof finishedCallback === 'function' ) {
				// Call the callback function when typing is finished.
				chat.triggerEvent( 'wpformsAIChatAfterTypeText', { chat } );

				finishedCallback( element );
			}

			chat.scrollMessagesTo( 'bottom' );
		}

		type();
	}

	/**
	 * Get the `helpers` object, according to the chat mode.
	 *
	 * @since 1.9.1
	 *
	 * @param {WPFormsAIChatHTMLElement} chat Chat element.
	 *
	 * @return {Object} Choices helpers object.
	 */
	getHelpers( chat ) {
		const helpers = window.WPFormsAi.helpers;

		return helpers[ chat.chatMode ]( chat ) ?? null;
	}

	/**
	 * Reset the message input field.
	 *
	 * @since 1.9.2
	 */
	resetInput() {
		this.input.value = '';

		if ( this.modeHelpers.resetInput ) {
			this.modeHelpers.resetInput();
		}
	}

	/**
	 * Escape HTML special characters.
	 *
	 * @since 1.9.1
	 *
	 * @param {string} html HTML string.
	 *
	 * @return {string} Escaped HTML string.
	 */
	htmlSpecialChars( html ) {
		return html.replace( /[<>]/g, ( x ) => '&#0' + x.charCodeAt( 0 ) + ';' );
	}

	/**
	 * Decode HTML entities.
	 *
	 * @since 1.9.2
	 *
	 * @param {string} html Encoded HTML string.
	 *
	 * @return {string} Decoded HTML string.
	 */
	decodeHTMLEntities( html ) {
		const txt = document.createElement( 'textarea' );

		txt.innerHTML = html;

		return txt.value;
	}

	/**
	 * Wrapper to trigger a custom event and return the event object.
	 *
	 * @since 1.9.1
	 *
	 * @param {string} eventName Event name to trigger (custom or native).
	 * @param {Object} args      Trigger arguments.
	 *
	 * @return {Event} Event object.
	 */
	triggerEvent( eventName, args = {} ) {
		const event = new CustomEvent( eventName, { detail: args } );

		document.dispatchEvent( event );

		return event;
	}

	/**
	 * Chat history object.
	 *
	 * @since 1.9.1
	 */
	history = {
		/**
		 * Chat history data.
		 *
		 * @since 1.9.1
		 *
		 * @type {Array}
		 */
		data: [],

		/**
		 * Chat history pointer.
		 *
		 * @since 1.9.1
		 *
		 * @type {number}
		 */
		pointer: 0,

		/**
		 * Default item.
		 *
		 * @since 1.9.1
		 *
		 * @type {Object}
		 */
		defaultItem: {
			question: '',
			answer: null,
		},

		/**
		 * Get history data by pointer.
		 *
		 * @since 1.9.1
		 *
		 * @param {number|null} pointer The history pointer.
		 *
		 * @return {Object} The history item.
		 */
		get( pointer = null ) {
			if ( pointer ) {
				this.pointer = pointer;
			}

			if ( this.pointer < 1 ) {
				this.pointer = 0;
			} else if ( this.pointer >= this.data.length ) {
				this.pointer = this.data.length - 1;
			}

			return this.data[ this.pointer ] ?? {};
		},

		/**
		 * Get history data by pointer.
		 *
		 * @since 1.9.1
		 *
		 * @return {Object} The history item.
		 */
		prev() {
			this.pointer -= 1;

			return this.get();
		},

		/**
		 * Get history data by pointer.
		 *
		 * @since 1.9.1
		 *
		 * @return {Object} The history item.
		 */
		next() {
			this.pointer += 1;

			return this.get();
		},

		/**
		 * Push an item to the chat history.
		 *
		 * @since 1.9.1
		 *
		 * @param {Object} item The item to push.
		 *
		 * @return {void}
		 */
		push( item ) {
			if ( item.answer ) {
				this.data[ this.data.length - 1 ].answer = item.answer;

				return;
			}

			this.data.push( { ...this.defaultItem, ...item } );
			this.pointer = this.data.length - 1;
		},

		/**
		 * Update the last history item.
		 *
		 * @since 1.9.1
		 *
		 * @param {Object} item The updated history item.
		 *
		 * @return {void}
		 */
		update( item ) {
			const lastKey = this.data.length > 0 ? this.data.length - 1 : 0;
			const lastItem = this.data[ lastKey ] ?? this.defaultItem;

			this.pointer = lastKey;
			this.data[ lastKey ] = { ...lastItem, ...item };
		},

		/**
		 * Clear the chat history.
		 *
		 * @since 1.9.1
		 */
		clear() {
			this.data = [];
			this.pointer = 0;
		},
	};
}
