<?php
namespace TeknoTok\AiTranslator\Utils;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Utility to parse HTML and translate text nodes block-by-block.
 */
class HtmlParser {
	/**
	 * Request-local native translations that should not be sent to AI.
	 *
	 * @var array
	 */
	private static $native_translated_texts = array();

	/**
	 * Remember a native WordPress/WooCommerce translation for this request.
	 *
	 * @param string $text Native translated text.
	 * @return void
	 */
	public static function remember_native_translation( $text ) {
		$text = trim( (string) $text );
		if ( '' === $text ) {
			return;
		}

		self::$native_translated_texts[ md5( $text ) ] = true;
	}

	/**
	 * Parse HTML, look up cache, translate remaining nodes, and reassemble.
	 *
	 * @param string $html         Raw HTML content.
	 * @param string $source_lang  Source language code.
	 * @param string $target_lang  Target language code.
	 * @param object $provider     Active AI provider instance.
	 * @param bool   $allow_remote Optional. Whether to allow remote translation requests. Default true.
	 * @param bool   $queue_misses Optional. Whether to queue uncached strings for background translation. Default false.
	 * @param bool   $queue_all_targets Optional. Whether misses should be queued for every configured target. Default true.
	 * @param bool   $shared_chrome_only Optional. Whether to only inspect header/nav/footer chrome. Default false.
	 * @return string Translated HTML.
	 * @throws \Exception
	 */
	public static function translate_html( $html, $source_lang, $target_lang, $provider, $allow_remote = true, $queue_misses = false, $queue_all_targets = true, $shared_chrome_only = false ) {
		if ( empty( $html ) ) {
			return $html;
		}

		// Setup DOM Document
		$dom = new \DOMDocument();
		libxml_use_internal_errors( true );
		// Load with UTF-8 encoding prefix
		$dom->loadHTML( '<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();

		$xpath = new \DOMXPath( $dom );
		
		// 1. Match text nodes (excluding scripts, styles, code, pre, textareas, admin bar, and anything with class 'notranslate' or translate='no')
		$text_nodes = $xpath->query( '//text()[
			not(ancestor::script) and 
			not(ancestor::style) and 
			not(ancestor::code) and 
			not(ancestor::pre) and 
			not(ancestor::textarea) and 
			not(ancestor::*[@id="wpadminbar"]) and 
			not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " notranslate ")]) and 
			not(ancestor::*[@translate="no"])
		]' );

		// 2. Match image alt attributes (excluding anything with class 'notranslate' or translate='no')
		$img_nodes = $xpath->query( '//img[@alt][
			not(ancestor::*[@id="wpadminbar"]) and 
			not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " notranslate ")]) and 
			not(ancestor::*[@translate="no"])
		]' );

		// 3. Match elements with title attributes (excluding anything with class 'notranslate' or translate='no')
		$title_nodes = $xpath->query( '//*[@title][
			not(ancestor::*[@id="wpadminbar"]) and 
			not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " notranslate ")]) and 
			not(ancestor::*[@translate="no"])
		]' );

		// 4. Match elements with placeholder attributes (excluding anything with class 'notranslate' or translate='no')
		$placeholder_nodes = $xpath->query( '//*[@placeholder][
			not(ancestor::*[@id="wpadminbar"]) and 
			not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " notranslate ")]) and 
			not(ancestor::*[@translate="no"])
		]' );

		// 5. Match elements with aria-label attributes (excluding anything with class 'notranslate' or translate='no')
		$aria_label_nodes = $xpath->query( '//*[@aria-label][
			not(ancestor::*[@id="wpadminbar"]) and 
			not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " notranslate ")]) and 
			not(ancestor::*[@translate="no"])
		]' );

		// 6. Match input elements with type="submit", "button", "reset" and a value attribute
		$input_value_nodes = $xpath->query( '//input[@type="submit" or @type="button" or @type="reset"][@value][
			not(ancestor::*[@id="wpadminbar"]) and 
			not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " notranslate ")]) and 
			not(ancestor::*[@translate="no"])
		]' );

		$nodes_to_translate = array();
		$payload = array();
		$payload_counter = 0;

		$raw_texts_to_lookup = array();

		// 1. Gather text nodes content for batch lookup
		foreach ( $text_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->nodeValue );
			if ( self::should_translate( $text, $target_lang ) ) {
				$raw_texts_to_lookup[] = $text;
			}
		}

		// 2. Gather image alt attributes
		foreach ( $img_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'alt' ) );
			if ( self::should_translate( $text, $target_lang ) ) {
				$raw_texts_to_lookup[] = $text;
			}
		}

		// 3. Gather title attributes
		foreach ( $title_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'title' ) );
			if ( self::should_translate( $text, $target_lang ) ) {
				$raw_texts_to_lookup[] = $text;
			}
		}

		// 4. Gather placeholder attributes
		foreach ( $placeholder_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'placeholder' ) );
			if ( self::should_translate( $text, $target_lang ) ) {
				$raw_texts_to_lookup[] = $text;
			}
		}

		// 5. Gather aria-label attributes
		foreach ( $aria_label_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'aria-label' ) );
			if ( self::should_translate( $text, $target_lang ) ) {
				$raw_texts_to_lookup[] = $text;
			}
		}

		// 6. Gather input values
		foreach ( $input_value_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'value' ) );
			if ( self::should_translate( $text, $target_lang ) ) {
				$raw_texts_to_lookup[] = $text;
			}
		}

		// Batch fetch translations from Cache
		$cached_translations = array();
		if ( ! empty( $raw_texts_to_lookup ) ) {
			$cached_translations = \TeknoTok\AiTranslator\Database\MemoryRepository::get_translations_batch( $raw_texts_to_lookup, $target_lang );
		}

		$total_elements = count( $raw_texts_to_lookup );
		$cached_count = 0;
		foreach ( $raw_texts_to_lookup as $text ) {
			$hash = md5( $text );
			if ( isset( $cached_translations[ $hash ] ) ) {
				$cached_count++;
			}
		}
		$miss_count = $total_elements - $cached_count;
		
		if ( $total_elements > 0 ) {
			Logger::log( sprintf( '  [HTML Parser] Extracted %d translatable elements from HTML content. Cache Hits: %d, Cache Misses: %d', $total_elements, $cached_count, $miss_count ), 'info' );
		}

		// Process text nodes
		foreach ( $text_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->nodeValue );
			if ( ! self::should_translate( $text, $target_lang ) ) {
				continue;
			}

			$hash = md5( $text );
			$cached = isset( $cached_translations[ $hash ] ) ? $cached_translations[ $hash ] : false;
			if ( false !== $cached ) {
				self::set_text_node_translation( $node, $cached );
			} else {
				$nodes_to_translate[ $payload_counter ] = array(
					'type' => 'text',
					'node' => $node
				);
				$payload[ $payload_counter ] = $text;
				$payload_counter++;
			}
		}

		// Process image alt attributes
		foreach ( $img_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'alt' ) );
			if ( ! self::should_translate( $text, $target_lang ) ) {
				continue;
			}

			$hash = md5( $text );
			$cached = isset( $cached_translations[ $hash ] ) ? $cached_translations[ $hash ] : false;
			if ( false !== $cached ) {
				$node->setAttribute( 'alt', $cached );
			} else {
				$nodes_to_translate[ $payload_counter ] = array(
					'type' => 'alt',
					'node' => $node
				);
				$payload[ $payload_counter ] = $text;
				$payload_counter++;
			}
		}

		// Process title attributes
		foreach ( $title_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'title' ) );
			if ( ! self::should_translate( $text, $target_lang ) ) {
				continue;
			}

			$hash = md5( $text );
			$cached = isset( $cached_translations[ $hash ] ) ? $cached_translations[ $hash ] : false;
			if ( false !== $cached ) {
				$node->setAttribute( 'title', $cached );
			} else {
				$nodes_to_translate[ $payload_counter ] = array(
					'type' => 'title',
					'node' => $node
				);
				$payload[ $payload_counter ] = $text;
				$payload_counter++;
			}
		}

		// Process placeholder attributes
		foreach ( $placeholder_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'placeholder' ) );
			if ( ! self::should_translate( $text, $target_lang ) ) {
				continue;
			}

			$hash = md5( $text );
			$cached = isset( $cached_translations[ $hash ] ) ? $cached_translations[ $hash ] : false;
			if ( false !== $cached ) {
				$node->setAttribute( 'placeholder', $cached );
			} else {
				$nodes_to_translate[ $payload_counter ] = array(
					'type' => 'placeholder',
					'node' => $node
				);
				$payload[ $payload_counter ] = $text;
				$payload_counter++;
			}
		}

		// Process aria-label attributes
		foreach ( $aria_label_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'aria-label' ) );
			if ( ! self::should_translate( $text, $target_lang ) ) {
				continue;
			}

			$hash = md5( $text );
			$cached = isset( $cached_translations[ $hash ] ) ? $cached_translations[ $hash ] : false;
			if ( false !== $cached ) {
				$node->setAttribute( 'aria-label', $cached );
			} else {
				$nodes_to_translate[ $payload_counter ] = array(
					'type' => 'aria-label',
					'node' => $node
				);
				$payload[ $payload_counter ] = $text;
				$payload_counter++;
			}
		}

		// Process input value attributes
		foreach ( $input_value_nodes as $node ) {
			if ( $shared_chrome_only && ! self::is_shared_chrome_node( $node ) ) {
				continue;
			}
			$text = trim( $node->getAttribute( 'value' ) );
			if ( ! self::should_translate( $text, $target_lang ) ) {
				continue;
			}

			$hash = md5( $text );
			$cached = isset( $cached_translations[ $hash ] ) ? $cached_translations[ $hash ] : false;
			if ( false !== $cached ) {
				$node->setAttribute( 'value', $cached );
			} else {
				$nodes_to_translate[ $payload_counter ] = array(
					'type' => 'value',
					'node' => $node
				);
				$payload[ $payload_counter ] = $text;
				$payload_counter++;
			}
		}

		if ( ! empty( $raw_texts_to_lookup ) && ! $allow_remote && $queue_misses ) {
			$queue_target_languages = $queue_all_targets ? self::get_queue_target_languages( $target_lang ) : array( sanitize_key( $target_lang ) );
			$unique_source_texts    = array_values( array_unique( $raw_texts_to_lookup ) );
			foreach ( $queue_target_languages as $queue_target_lang ) {
				$queued_cached_translations = \TeknoTok\AiTranslator\Database\MemoryRepository::get_translations_batch( $unique_source_texts, $queue_target_lang );
				foreach ( $unique_source_texts as $source_text ) {
					if ( isset( $queued_cached_translations[ md5( $source_text ) ] ) ) {
						continue;
					}
					\TeknoTok\AiTranslator\Database\JobRepository::add_string_job( $source_text, $queue_target_lang );
				}
			}
		}

		// Translate payload in batches
		if ( ! empty( $payload ) && $allow_remote ) {
			$chunks = array_chunk( $payload, 50, true );
			$chunk_count = count( $chunks );
			$chunk_idx = 1;
			foreach ( $chunks as $chunk ) {
				try {
					Logger::log( sprintf( '    [HTML Parser] Dispatching chunk %d/%d to AI provider (%d items)', $chunk_idx++, $chunk_count, count( $chunk ) ), 'info' );
					$translations = $provider->translate( $chunk, $source_lang, $target_lang );
					$success_chunk = 0;
					foreach ( $chunk as $index => $original_text ) {
						if ( isset( $translations[ $index ] ) ) {
							$translated_text = $translations[ $index ];
							\TeknoTok\AiTranslator\Database\MemoryRepository::save_translation( $original_text, $translated_text, $source_lang, $target_lang );
							$success_chunk++;
							
							$type = $nodes_to_translate[ $index ]['type'];
							$node = $nodes_to_translate[ $index ]['node'];
							
							if ( 'text' === $type ) {
								self::set_text_node_translation( $node, $translated_text );
							} elseif ( 'alt' === $type ) {
								$node->setAttribute( 'alt', $translated_text );
							} elseif ( 'title' === $type ) {
								$node->setAttribute( 'title', $translated_text );
							} elseif ( 'placeholder' === $type ) {
								$node->setAttribute( 'placeholder', $translated_text );
							} elseif ( 'aria-label' === $type ) {
								$node->setAttribute( 'aria-label', $translated_text );
							} elseif ( 'value' === $type ) {
								$node->setAttribute( 'value', $translated_text );
							}
						}
					}
					Logger::log( sprintf( '    [HTML Parser] Successfully translated %d/%d items in chunk.', $success_chunk, count( $chunk ) ), 'info' );
				} catch ( \Exception $e ) {
					Logger::log( 'HTML parser translation batch failed: ' . $e->getMessage(), 'error' );
					throw $e;
				}
			}
		}

		// Translate JSON-LD Schema data
		$json_ld_nodes = $xpath->query( '//script[@type="application/ld+json"]' );
		foreach ( $json_ld_nodes as $node ) {
			$json_text = trim( $node->nodeValue );
			if ( empty( $json_text ) ) {
				continue;
			}
			$data = json_decode( $json_text, true );
			if ( ! is_array( $data ) ) {
				continue;
			}
			$data_translated = self::translate_json_ld_array( $data, $source_lang, $target_lang, $provider, $allow_remote, $queue_misses, $queue_all_targets );
			$node->nodeValue = json_encode( $data_translated, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		}

		$output = $dom->saveHTML();
		$output = str_replace( '<?xml encoding="utf-8" ?>', '', $output );

		// Decode HTML entities (e.g. &middot; or Arabic decimal entities) generated by DOMDocument::saveHTML()
		// but preserve HTML special characters (<, >, &, ", ') to avoid breaking markup structure.
		$output = self::decode_safe_html_entities( $output );

		return trim( $output );
	}

	/**
	 * Repair cached translated HTML that was generated before text-node edge
	 * whitespace was preserved around inline links.
	 *
	 * @param string $html        Translated HTML.
	 * @param string $target_lang Target language code.
	 * @return string Repaired HTML.
	 */
	public static function repair_inline_anchor_spacing( $html, $target_lang = '' ) {
		if ( empty( $html ) || self::uses_unspaced_words( $target_lang ) || false === strpos( $html, '<a ' ) ) {
			return $html;
		}

		$dom = new \DOMDocument();
		libxml_use_internal_errors( true );
		$loaded = $dom->loadHTML( '<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();

		if ( ! $loaded ) {
			return $html;
		}

		$anchors = $dom->getElementsByTagName( 'a' );
		foreach ( $anchors as $anchor ) {
			if ( ! $anchor instanceof \DOMElement ) {
				continue;
			}

			self::repair_anchor_sibling_spacing( $anchor );
		}

		$output = $dom->saveHTML();
		$output = str_replace( '<?xml encoding="utf-8" ?>', '', $output );

		return self::decode_safe_html_entities( trim( $output ) );
	}

	/**
	 * Replace a text node while preserving source edge whitespace.
	 *
	 * Text nodes next to inline elements often carry the literal spaces that keep
	 * words and links separated. The parser translates the trimmed text value, so
	 * those edge spaces must be restored when writing the translation back.
	 *
	 * @param \DOMText $node            Text node to update.
	 * @param string   $translated_text Translated trimmed text.
	 * @return void
	 */
	private static function set_text_node_translation( $node, $translated_text ) {
		$original_text = (string) $node->nodeValue;
		$leading_space = '';
		$trailing_space = '';

		if ( preg_match( '/^\s+/u', $original_text, $matches ) ) {
			$leading_space = $matches[0];
		}

		if ( preg_match( '/\s+$/u', $original_text, $matches ) ) {
			$trailing_space = $matches[0];
		}

		$node->textContent = $leading_space . trim( (string) $translated_text ) . $trailing_space;
	}

	/**
	 * Repair spacing between an anchor and adjacent text nodes.
	 *
	 * @param \DOMElement $anchor Anchor element.
	 * @return void
	 */
	private static function repair_anchor_sibling_spacing( $anchor ) {
		$anchor_text = trim( (string) $anchor->textContent );
		if ( '' === $anchor_text ) {
			return;
		}

		$previous = $anchor->previousSibling;
		if ( $previous instanceof \DOMText && self::needs_inline_space( (string) $previous->nodeValue, $anchor_text ) ) {
			$previous->nodeValue .= ' ';
		}

		$next = $anchor->nextSibling;
		if ( $next instanceof \DOMText && self::needs_inline_space( $anchor_text, (string) $next->nodeValue ) ) {
			$next->nodeValue = ' ' . $next->nodeValue;
		}
	}

	/**
	 * Determine whether two neighboring text fragments need a separating space.
	 *
	 * @param string $left  Left fragment.
	 * @param string $right Right fragment.
	 * @return bool True when a space should be inserted.
	 */
	private static function needs_inline_space( $left, $right ) {
		if ( '' === $left || '' === $right || preg_match( '/\s$/u', $left ) || preg_match( '/^\s/u', $right ) ) {
			return false;
		}

		return 1 === preg_match( '/[\p{L}\p{N}]$/u', $left ) && 1 === preg_match( '/^[\p{L}\p{N}]/u', $right );
	}

	/**
	 * Whether the language commonly omits spaces between words.
	 *
	 * @param string $target_lang Target language code.
	 * @return bool True for languages where inserting spaces may be harmful.
	 */
	private static function uses_unspaced_words( $target_lang ) {
		$lang = strtolower( strtok( (string) $target_lang, '_' ) );
		return in_array( $lang, array( 'zh', 'ja', 'ko', 'th', 'lo', 'km', 'my' ), true );
	}

	/**
	 * Decode display entities without breaking HTML syntax.
	 *
	 * @param string $html HTML string.
	 * @return string HTML with safe entities decoded.
	 */
	private static function decode_safe_html_entities( $html ) {
		return preg_replace_callback(
			'/&(#(?:\d+|x[a-fA-F0-9]+)|[a-zA-Z0-9]+);/',
			function ( $matches ) {
				$entity = $matches[0];
				$char   = html_entity_decode( $entity, ENT_QUOTES, 'UTF-8' );
				if ( in_array( $char, array( '<', '>', '&', '"', "'" ), true ) ) {
					return $entity;
				}
				return $char;
			},
			$html
		);
	}

	/**
	 * Determine whether a DOM node belongs to shared site chrome.
	 *
	 * @param \DOMNode $node Node to inspect.
	 * @return bool True when the node is inside header, navigation, or footer chrome.
	 */
	private static function is_shared_chrome_node( $node ) {
		$current = ( $node instanceof \DOMText ) ? $node->parentNode : $node;
		$markers = array( 'header', 'nav', 'menu', 'navbar', 'navigation', 'topbar', 'footer', 'site-header', 'site-footer' );

		while ( $current instanceof \DOMElement ) {
			$tag = strtolower( $current->tagName );
			if ( in_array( $tag, array( 'header', 'nav', 'footer' ), true ) ) {
				return true;
			}

			$role = strtolower( $current->getAttribute( 'role' ) );
			if ( in_array( $role, array( 'banner', 'navigation', 'contentinfo' ), true ) ) {
				return true;
			}

			$identity = strtolower( trim( $current->getAttribute( 'id' ) . ' ' . $current->getAttribute( 'class' ) ) );
			foreach ( $markers as $marker ) {
				if ( false !== strpos( $identity, $marker ) ) {
					return true;
				}
			}

			$current = $current->parentNode;
		}

		return false;
	}

	/**
	 * Resolve target languages that should receive queued frontend string jobs.
	 *
	 * @param string $target_lang Active target language.
	 * @return array Language codes.
	 */
	private static function get_queue_target_languages( $target_lang ) {
		$target_lang = sanitize_key( $target_lang );
		$settings    = get_option( 'teknotok_ait_settings', array() );

		if ( isset( $settings['auto_translate'] ) && '0' === (string) $settings['auto_translate'] ) {
			return array_filter( array( $target_lang ) );
		}

		$source_lang = isset( $settings['source_language'] ) ? sanitize_key( $settings['source_language'] ) : '';
		$languages   = isset( $settings['target_languages'] ) && is_array( $settings['target_languages'] ) ? $settings['target_languages'] : array();
		$languages[] = $target_lang;

		$languages = array_map( 'sanitize_key', $languages );
		$languages = array_filter(
			array_unique( $languages ),
			function ( $language ) use ( $source_lang ) {
				return ! empty( $language ) && $language !== $source_lang;
			}
		);

		return array_values( $languages );
	}

	/**
	 * Determine if a string should be translated.
	 * Filters out empty/numeric content, shortcode/JSON patterns, HTML strings,
	 * and checks if the text is already written in the target language to prevent double translations.
	 *
	 * @param string $text        The text content.
	 * @param string $target_lang Target language code.
	 * @return bool True if the text should be translated, false otherwise.
	 */
	public static function should_translate( $text, $target_lang ) {
		if ( empty( $text ) || ! preg_match( '/[a-zA-Z\p{L}]/u', $text ) ) {
			return false;
		}
		if ( self::is_protected_identifier( $text ) ) {
			return false;
		}
		if ( isset( self::$native_translated_texts[ md5( trim( (string) $text ) ) ] ) ) {
			return false;
		}
		if ( preg_match( '/^\[[^\]]+\]$/', $text ) ) {
			return false;
		}
		if ( 0 === strpos( $text, '[MOCK' ) ) {
			return false;
		}
		if ( 0 === strpos( $text, '{' ) && '}' === substr( $text, -1 ) ) {
			return false;
		}
		if ( false !== strpos( $text, '<!--' ) || false !== strpos( $text, '-->' ) || false !== strpos( $text, '<div' ) || false !== strpos( $text, '</' ) ) {
			return false;
		}
		if ( preg_match( '/^[a-z0-9]+(?:[-_][a-z0-9]+)+$/', $text ) ) {
			return false;
		}

		// If text already contains the target language script, skip it to avoid double translation
		switch ( substr( $target_lang, 0, 2 ) ) {
			case 'ar':
				if ( preg_match( '/\p{Arabic}/u', $text ) ) {
					return false;
				}
				break;
			case 'zh':
				if ( preg_match( '/\p{Han}/u', $text ) ) {
					return false;
				}
				break;
			case 'ru':
				if ( preg_match( '/\p{Cyrillic}/u', $text ) ) {
					return false;
				}
				break;
			case 'ja':
				if ( preg_match( '/[\p{Hiragana}\p{Katakana}\p{Han}]/u', $text ) ) {
					return false;
				}
				break;
			case 'ko':
				if ( preg_match( '/\p{Hangul}/u', $text ) ) {
					return false;
				}
				break;
			case 'bn':
				if ( preg_match( '/\p{Bengali}/u', $text ) ) {
					return false;
				}
				break;
			case 'hi':
				if ( preg_match( '/\p{Devanagari}/u', $text ) ) {
					return false;
				}
				break;
		}

		return true;
	}

	/**
	 * Check whether text is a technical identifier that must stay unchanged.
	 *
	 * @param string $text Text to inspect.
	 * @return bool True when text should never be translated.
	 */
	public static function is_protected_identifier( $text ) {
		$text = trim( html_entity_decode( (string) $text, ENT_QUOTES, 'UTF-8' ) );
		if ( '' === $text ) {
			return true;
		}

		if ( preg_match( '/^mailto:/i', $text ) ) {
			return true;
		}

		if ( ( function_exists( 'is_email' ) && is_email( $text ) ) || preg_match( '/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/u', $text ) ) {
			return true;
		}

		if ( preg_match( '#^(?:https?:)?//#i', $text ) || preg_match( '#^(?:https?|ftp)://#i', $text ) ) {
			return true;
		}

		if ( preg_match( '/^(?:www\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?:\/[^\s]*)?$/i', $text ) ) {
			return true;
		}

		if ( preg_match( '/^[+#()0-9 .-]{7,}$/', $text ) && preg_match( '/\d{5,}/', $text ) ) {
			return true;
		}

		if ( preg_match( '/^@[a-z0-9._-]{2,}$/i', $text ) ) {
			return true;
		}

		if ( preg_match( '/^[a-z0-9]+(?:[-_][a-z0-9]+)+$/i', $text ) ) {
			return true;
		}

		if ( preg_match( '/^[a-z0-9._-]+\.(?:zip|pdf|docx?|xlsx?|csv|json|xml|txt|jpg|jpeg|png|gif|webp|svg|css|js|php|html?)$/i', $text ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Recursively walk a JSON-LD array and translate only whitelisted text fields.
	 *
	 * @param array  $array        The JSON-LD parsed array.
	 * @param string $source_lang  Source language.
	 * @param string $target_lang  Target language.
	 * @param object $provider     AI provider.
	 * @param bool   $allow_remote Allow remote requests.
	 * @param bool   $queue_misses Queue misses.
	 * @param bool   $queue_all_targets Queue all targets.
	 * @return array Translated JSON-LD array.
	 */
	private static function translate_json_ld_array( $array, $source_lang, $target_lang, $provider, $allow_remote, $queue_misses, $queue_all_targets ) {
		$whitelisted_keys = array(
			'name',
			'description',
			'headline',
			'alternativeHeadline',
			'caption',
			'keywords',
			'disambiguatingDescription',
			'reviewBody',
			'jobTitle',
			'itemName',
			'operatingSystem',
			'applicationCategory',
		);

		foreach ( $array as $key => $value ) {
			if ( is_array( $value ) ) {
				$array[ $key ] = self::translate_json_ld_array( $value, $source_lang, $target_lang, $provider, $allow_remote, $queue_misses, $queue_all_targets );
			} elseif ( is_string( $value ) && in_array( $key, $whitelisted_keys, true ) ) {
				$trimmed = trim( $value );
				if ( ! empty( $trimmed ) && self::should_translate( $trimmed, $target_lang ) ) {
					// Check translation cache first
					$cached = \TeknoTok\AiTranslator\Database\MemoryRepository::get_translation( $trimmed, $target_lang );
					if ( false !== $cached ) {
						$array[ $key ] = $cached;
					} else {
						// If allow_remote and provider is set, we translate dynamically
						if ( $allow_remote && $provider ) {
							try {
								$translated = $provider->translate( $trimmed, $source_lang, $target_lang );
								if ( is_string( $translated ) && ! empty( $translated ) ) {
									\TeknoTok\AiTranslator\Database\MemoryRepository::save_translation( $trimmed, $translated, $source_lang, $target_lang );
									$array[ $key ] = $translated;
								}
							} catch ( \Exception $e ) {
								Logger::log( 'JSON-LD inline translation error: ' . $e->getMessage(), 'error' );
							}
						} elseif ( $queue_misses ) {
							// Queue it up
							$queue_langs = $queue_all_targets ? self::get_queue_target_languages( $target_lang ) : array( sanitize_key( $target_lang ) );
							foreach ( $queue_langs as $q_lang ) {
								\TeknoTok\AiTranslator\Database\JobRepository::add_string_job( $trimmed, $q_lang );
							}
						}
					}
				}
			}
		}

		return $array;
	}
}
