<?php
/**
 * Readabler
 * Web accessibility for Your WordPress site.
 * Exclusively on https://1.envato.market/readabler
 *
 * @encoding        UTF-8
 * @version         2.0.12
 * @copyright       (C) 2018 - 2024 Merkulove ( https://merkulov.design/ ). All rights reserved.
 * @license         Envato License https://1.envato.market/KYbje
 * @contributors    Nemirovskiy Vitaliy (nemirovskiyvitaliy@gmail.com), Dmitry Merkulov (dmitry@merkulov.design)
 * @support         help@merkulov.design
 * @license         Envato License https://1.envato.market/KYbje
 **/

namespace Merkulove\Readabler;

use DOMDocument;
use DOMXPath;
use Exception;
use Google\Cloud\TextToSpeech\V1\AudioEncoding;
use Google\Cloud\TextToSpeech\V1\SynthesizeSpeechRequest;
use Google\Cloud\TextToSpeech\V1\TextToSpeechClient as TextToSpeechClient7;
use Google\Cloud\TextToSpeech\V1\Client\TextToSpeechClient as TextToSpeechClient8;
use Google\Cloud\TextToSpeech\V1\AudioConfig;
use Google\Cloud\TextToSpeech\V1\SynthesisInput;
use Google\Cloud\TextToSpeech\V1\VoiceSelectionParams;
use Merkulove\Readabler\Unity\Settings;

/** Exit if accessed directly. */
if ( ! defined( 'ABSPATH' ) ) {
	header( 'Status: 403 Forbidden' );
	header( 'HTTP/1.1 403 Forbidden' );
	exit;
}

final class SpeechGeneration {

	private static ?SpeechGeneration $instance = null;

	private function __construct() {
		if (
			'on' === Settings::get_instance()->options['text_to_speech'] ||
			'on' === Settings::get_instance()->options['profile_blind_users']
		) {
			if ( Tools::is_key_exists() && Tools::is_versions_compatible() ) {
				require_once Tools::get_vendor_path();
			}
			add_action( 'wp_ajax_readablergspeak', [ $this, 'gspeak' ] );
			add_action( 'wp_ajax_nopriv_readablergspeak', [ $this, 'gspeak' ] );
		}
	}

	/**
	 * Ajax front-end action hook here.
	 *
	 * @return void
	 **@since 1.0.0
	 * @access public
	 *
	 */
	public function gspeak() {

		check_ajax_referer( 'readabler-nonce', 'nonce' );

		if ( ! Tools::is_key_exists() || ! Tools::is_versions_compatible() ) {
			wp_die();
		}

		$options              = Settings::get_instance()->options;
		$options['html_lang'] = filter_input( INPUT_POST, 'lang' );

		$html = filter_input( INPUT_POST, 'text' );

		/** Remove muted elements by class "readabler-mute" or attribute readabler-mute="". */
		$html = $this->remove_muted_html( $html );

		/** Replace <span readabler-break=""></span> to <break time="200ms"/>. */
		$html = $this->replace_break_tag( $html );

		/** Clean HTML. */
		$html = XMLHelper::get_instance()->clean_html( $html );

		/** Strip all html tags, except SSML tags.  */
		$text = strip_tags( $html );

		/** Remove the space from the left and right sides.  */
		$text = trim( $text );

		/** Convert HTML entities to their corresponding characters: &quot; ⇒ " */
		$text = html_entity_decode( $text );

		/** Remove all tags */
		$text = strip_tags( $text);

		/** Prepare language configuration */
		$lang = $this->get_language( $options );

		try {

			$vendor_version = Tools::get_vendor_version();

			switch ( $vendor_version ) {

				case 7:

					/** Create a new TextToSpeechClient. */
					$client = new TextToSpeechClient7();

					/** Sets text to be synthesized. */
					$synthesisInputText = ( new SynthesisInput() )->setText( $text );

					/** Build the voice request, select the language. */
					$voice = ( new VoiceSelectionParams() )
						->setLanguageCode( $lang['language_code'] )
						->setName( $lang['voice_code'] );

					$response = $client->synthesizeSpeech(
						$synthesisInputText,
						$voice,
						$this->get_audio_config( $options )
					);

					break;

				case 8:
				default:

					/** Create a new TextToSpeechClient. */
					$client = new TextToSpeechClient8();

					/** Sets text to be synthesized. */
					$input = ( new SynthesisInput() )->setText( $text );

					$voice = ( new VoiceSelectionParams() )
						->setLanguageCode( $lang['language_code'] )
						->setName( $lang['voice_code'] );

					$request = ( new SynthesizeSpeechRequest() )
						->setInput( $input )
						->setVoice( $voice )
						->setAudioConfig( $this->get_audio_config( $options ) );

					$response = $client->synthesizeSpeech( $request );

					break;

			}

			$audioContent = $response->getAudioContent();

		} catch ( Exception $e ) {

			if ( $e->getCode() === 3 ) {

				$googleMsg  = $e->getMessage();
				$googleJson = json_decode( $googleMsg, true );
				wp_send_json_error( __( 'Google Cloud API error', 'readabler' ) . ': ' . ( $googleJson['message'] ?? __( 'Unknown error', 'readabler' ) ) );

			} else {

				wp_send_json_error( __( 'Error', 'readabler' ) . ': ' . $e->getMessage() );

			}

			wp_die();

		}

		/** Restore previous exception handler. */
		restore_exception_handler();

		// Encode audio to base64 string and send to front-end
		$base64Audio = base64_encode( $audioContent );
		wp_send_json_success( $base64Audio );

		wp_die();

	}

	/**
	 * Replace <span readabler-break=""></span> to <break time="200ms"/>.
	 *
	 * @param $html_content string - HTML code.
	 *
	 * @return string
	 * @since 2.0.0
	 * @access public
	 */
	public function replace_break_tag( string $html_content ): string {

		/** Create a DOM object. */
		$html = new simple_html_dom();

		/** Load HTML from a string. */
		$html->load( $html_content );

		/** Foreach element wit attribute readabler-break="". */
		foreach ( $html->find( '[readabler-break]' ) as $el ) {

			/** Do replacements. */
			$break_tag = $el->outertext;
			$break_tag = str_replace( '<span readabler-break=""', '<break', $break_tag );

			$el->outertext = $break_tag;
		}

		/** Return result. */
		return $html->save();

	}

	/**
	 * Remove muted elements by class "readabler-mute" or attribute readabler-mute="".
	 *
	 * @param $post_content - Post/Page content.
	 *
	 * @return string
	 * @since 2.0.0
	 * @access public
	 *
	 **/
	public function remove_muted_html( $post_content ): string {

		/** Hide DOM parsing errors. */
		libxml_use_internal_errors( true );
		libxml_clear_errors();

		/** Load the possibly malformed HTML into a DOMDocument. */
		$dom          = new DOMDocument();
		$dom->recover = true;
		$dom->loadHTML( '<?xml encoding="UTF-8"><body id="repair">' . $post_content . '</body>' ); // input UTF-8.

		$selector = new DOMXPath( $dom );

		/** Remove all elements with readabler-mute="" attribute. */
		foreach ( $selector->query( '//*[@readabler-mute]' ) as $e ) {
			$e->parentNode->removeChild( $e );
		}

		/** Remove all elements with class="readabler-mute". */
		foreach ( $selector->query( '//*[contains(attribute::class, "readabler-mute")]' ) as $e ) {
			$e->parentNode->removeChild( $e );
		}

		/** HTML without muted tags. */
		$body = $dom->documentElement->lastChild;

		return trim( XMLHelper::get_instance()->get_inner_html( $body ) );
	}

	/**
	 * Returns language properties
	 *
	 * @param $options
	 *
	 * @return array
	 */
	private function get_language( $options ): array {

		/** Prepare language and language code */
		if ( isset( $options['multi'] ) && 'on' === $options['multi'] ) {

			$language_code = $this->get_lang_code( $options );
			$voice_code    = $language_code . '-Standard-A';

		} else {

			$language_code = $options['language-code'];
			$voice_code    = $options['language'];

		}

		return [ 'language_code' => $language_code, 'voice_code' => $voice_code ];

	}

	/**
	 * Validate and return language code
	 *
	 * @param $options
	 *
	 * @return int|mixed|string|string[]
	 */
	private function get_lang_code( $options ) {

		// Get locale and list of languages
		$languages = Config::$languages;

		// Replace _ by - in the language code
		$language_code = str_replace( '_', '-', $options['html_lang'] ?? '' );

		// Get basic language from locale
		$base_locale = ! strstr( $language_code, '-' ) ?
			$language_code : // Base language without dash
			strstr( $language_code, '-', true ); // Base language with dash

		/** Check is voice exists in the Google Voices list */
		if ( array_key_exists( $language_code, $languages ) ) {

			return $language_code;

			/** Check is basic languages from locale exist in the Google Voices list */
		} else if ( $this->preg_array_key_exists( '/(' . $base_locale . '-)/', $languages ) ) {

			// Find firs language with similar base language code
			foreach ( $languages as $lang_key => $lang_value ) {
				if ( strpos( $lang_key, $base_locale ) === 0 ) {
					return $lang_key;
				}
			}

		}

		return $options['language_code'];


	}

	/**
	 * @param $pattern
	 * @param $array
	 *
	 * @return int
	 */
	private function preg_array_key_exists( $pattern, $array ): int {

		// extract the keys.
		$keys = array_keys( $array );

		// convert the preg_grep() returned array to int, and return
		// the creted value of preg_grep() will be an array of values
		// that match the pattern.
		return (int) preg_grep( $pattern, $keys );

	}

	/**
	 * Get audio config
	 *
	 * @param $options
	 *
	 * @return AudioConfig
	 */
	private function get_audio_config( $options ): AudioConfig {

		return ( new AudioConfig() )
			->setAudioEncoding( AudioEncoding::MP3 )
			->setEffectsProfileId( [ $options['audio-profile'] ] )
			->setSpeakingRate( $options['speaking-rate'] )
			->setPitch( $options['pitch'] )
			->setSampleRateHertz( 24000 );

	}

	public static function get_instance(): ?SpeechGeneration {
		if ( self::$instance == null ) {
			self::$instance = new SpeechGeneration();
		}

		return self::$instance;
	}

}
