<?php
// Prevent direct access to this file
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Speech Processor Class
 * Handles the speech synthesis process with filters and actions
 */
class Mementor_TTS_Processor {
    /**
     * Instance of the class
     */
    private static $instance = null;

    /**
     * Per-request stash of extraction context, keyed by post_id. Set at the top
     * of generate_audio(); later read and cleared by speech-builder's
     * log_generation() so a single enriched remote-debug event can replace the
     * previous pair of "content_extracted" + "generate_success" events.
     */
    public static $last_gen_ctx = array();

    /**
     * Speech Builder instance
     */
    private $speech_builder;

    /**
     * SaaS API client instance
     */
    private $api_client;

    /**
     * Debug mode
     */
    private $debug_mode;

    /**
     * Default voice ID from ElevenLabs
     */
    const DEFAULT_VOICE_ID = 'UgBBYS2sOqTuMpoF3BR0'; // Default voice

    /**
     * Constructor
     */
    private function __construct() {
        // Initialize debug mode
        $this->debug_mode = get_option('mementor_tts_debug_mode', '0') === '1';

        // Initialize the SaaS API client
        require_once plugin_dir_path(__FILE__) . 'class-mementor-tts-api-client.php';
        $this->api_client = Mementor_TTS_Api_Client::get_instance();

        // Try to initialize the speech builder if available
        try {
            if (file_exists(plugin_dir_path(__FILE__) . 'class-mementor-tts-speech-builder.php')) {
                require_once plugin_dir_path(__FILE__) . 'class-mementor-tts-speech-builder.php';
                if (class_exists('Mementor_TTS_Speech_Builder')) {
                    $this->speech_builder = Mementor_TTS_Speech_Builder::get_instance();
                }
            }
        } catch (Exception $e) {
            $this->log_message('Error initializing speech builder: ' . $e->getMessage(), 'error');
            // Continue without speech builder
            $this->speech_builder = null;
        }

        // Add initialization hook
        add_action('init', array($this, 'init'));
    }

    /**
     * Initialize the processor
     * This runs after WordPress core is loaded
     */
    public function init() {
        // Now WordPress core is fully loaded, we can use all functions
        if (!function_exists('wp_upload_dir')) {
            return;
        }

        // Set up the upload directory
        $upload_dir = wp_upload_dir();
        if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
            $this->log_message('Upload directory is not writable: ' . $upload_dir['error'], 'error');
            return;
        }

        // Create our upload directory if it doesn't exist
        $tts_upload_dir = $upload_dir['basedir'] . '/text-to-speech-tts';
        if (!file_exists($tts_upload_dir)) {
            wp_mkdir_p($tts_upload_dir);
        }
    }

    /**
     * Log a message based on debug mode
     *
     * @param string $message The message to log
     * @param string $level The log level (info, error, warning)
     */
    private function log_message($message, $level = 'info') {
        // Always log errors, but only log other levels if debug mode is enabled
        if ($level === 'error' || ($this->debug_mode && defined('WP_DEBUG') && WP_DEBUG)) {
            if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                $prefix = 'TTS Plugin';
                if ($level === 'error') {
                    $prefix .= ' Error';
                } elseif ($level === 'warning') {
                    $prefix .= ' Warning';
                }
                if (function_exists('wp_debug_log')) {
                    wp_debug_log($prefix . ': ' . $message);
                }
            }
        }
    }

    /**
     * Get instance of the class
     */
    public static function get_instance() {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Process text to speech
     */
    public function process_text($text, $post_id, $element_uid, $voice_id = null, $options = array()) {
        // Remove any existing outer speak tags to avoid double processing, preserve inner SSML
        $text = preg_replace('#</?speak>#i', '', $text);
        
        // Replace NIAB with --Important Note--
        $text = str_replace('NIAB', '--Important Note--', $text);
        
        // FIRST: Clean up any double dashes that might cause duplication
        // This preserves intentional pauses (--) before we process single hyphens
        $text = preg_replace('/\\s*--+\\s*/', ' -- ', $text);
        
        // THEN: Replace single hyphens between words with spaces for more natural speech
        // This prevents ElevenLabs from treating word-connecting hyphens as pauses
        // Pattern: word-word becomes word word, but -- (already processed) are preserved
        $text = preg_replace('/(\\w)-(\\w)/', '$1 $2', $text);
        
        // Allow filtering of text before processing, but prevent duplication
        $text = apply_filters('mementor_tts_before_process', $text, $post_id);
        
        // Clean up empty parts that might have been introduced by filters
        $text_parts = explode(' -- ', $text);
        $text_parts = array_filter(array_map('trim', $text_parts)); // Remove empty parts
        // REMOVED array_unique() - it was removing duplicate paragraphs in Fusion Builder posts
        $text = implode(' -- ', $text_parts);
        
        // Number normalization is handled server-side by TextNormalizationService
        // (locale-aware: converts European formats like 8,52% to Dutch/German/etc. words)

        // Apply word replacements before custom pauses
        $text = $this->apply_word_replacements($text);
        
        // Apply custom pauses to the text
        $text = $this->apply_custom_pauses($text);
        
        // Generate SSML without duplicating content
        $ssml = '<speak><break time="0.65s" />' . $text . '</speak>';
        
        // Allow modification of SSML without duplicating content
        $ssml = apply_filters('mementor_tts_before_synthesis', $ssml, $post_id);
        
        // Remove any nested speak tags that might have been added
        $ssml = preg_replace('/<speak>(?:.*?)<speak>(.*?)</speak>(?:.*?)</speak>/', '<speak>$1</speak>', $ssml);

        // Get voice ID if not provided
        if (empty($voice_id)) {
            $this->log_message('Voice ID is empty, checking for custom voice ID...');
            
            // Check if paid plan user has a custom voice ID
            if (ttswp_has_paid_plan()) {
                $custom_voice_id = get_option('mementor_tts_custom_voice_id', '');
                if (!empty($custom_voice_id)) {
                    $voice_id = $custom_voice_id;
                    $this->log_message('Using custom voice ID: ' . $voice_id);
                }
            }
            
            // If no custom voice ID, use the selected voice
            if (empty($voice_id)) {
                $voice_id = get_option('mementor_tts_voice', '');
                $this->log_message('Using default selected voice: ' . $voice_id);
            }
            
        } else {
            $this->log_message('Voice ID provided: ' . $voice_id);
        }

        // Allow modification of voice settings
        $options = apply_filters('mementor_tts_voice_settings', $options, $post_id);

        // Trigger before synthesis action
        do_action('mementor_tts_before_synthesis', $post_id, $element_uid, $voice_id);

        // Build filename
        $post_slug = sanitize_title( get_the_title( $post_id ) );
        $lang = isset($options['language_code']) ? $options['language_code'] : 'en';
        $options['filename'] = ($post_slug ?: 'audio') . '-' . $post_id . '-' . $lang . '.mp3';

        // Convert to speech via SaaS API
        $response = $this->api_client->synthesize($ssml, $voice_id, $options);

        if (is_wp_error($response)) {
            do_action('mementor_tts_synthesis_error', $response->get_error_message(), $post_id, $element_uid);
            return false;
        }

        // Save audio (CDN URL for paid, local download for free)
        $language_code = isset($options['language_code']) ? $options['language_code'] : '';
        $audio_url = $this->api_client->save_audio($post_id, $response, $language_code);
        if (is_wp_error($audio_url)) {
            do_action('mementor_tts_save_error', $audio_url->get_error_message(), $post_id, $element_uid);
            return false;
        }

        // Update speech element
        $this->speech_builder->update_element_audio($post_id, $element_uid, $audio_url);

        // Trigger after synthesis action
        do_action('mementor_tts_after_synthesis', $post_id, $element_uid, $audio_url);

        return $audio_url;
    }

    /**
     * Process multiple elements in batch
     */
    public function process_batch($post_id, $batch_size = 5) {
        // Get pending elements
        $elements = $this->speech_builder->get_pending_elements($post_id, $batch_size);

        foreach ($elements as $element) {
            $this->process_text(
                $element->content,
                $post_id,
                $element->element_uid,
                $element->voice_id,
                json_decode($element->options, true)
            );

            // Allow other processes to run
            sleep(1);
        }

        return count($elements);
    }

    /**
     * Apply custom pause settings to text
     * 
     * @param string $text The text with -- separators
     * @param array $element_types Array tracking what type each element is
     * @return string Text with custom SSML break tags
     */
    private function apply_custom_pauses($text, $element_types = array()) {
        // Get pause settings - use defaults if not on paid plan
        if (ttswp_has_paid_plan()) {
            $paragraph_pause = floatval(get_option('mementor_tts_pause_between_paragraphs', 0.6));
            $heading_pause = floatval(get_option('mementor_tts_pause_after_headings', 0.6));
        } else {
            $paragraph_pause = 0.6;
            $heading_pause = 0.6;
        }
        
        // Split text by -- separator
        $parts = explode(' -- ', $text);
        $result = '';
        
        // Process each part and add appropriate pause
        for ($i = 0; $i < count($parts); $i++) {
            $result .= trim($parts[$i]);
            
            // Add pause after each part except the last one
            if ($i < count($parts) - 1) {
                // Determine pause duration based on element type
                $pause_duration = $paragraph_pause; // Default
                
                if (isset($element_types[$i])) {
                    $element_type = $element_types[$i];
                    if (in_array($element_type, array('h2', 'h3', 'h4', 'h5', 'h6'))) {
                        $pause_duration = $heading_pause;
                    }
                }
                
                $result .= ' <break time="' . $pause_duration . 's" /> ';
            }
        }
        
        return $result;
    }

    /**
     * Convert a number to its English spoken form
     *
     * @param int|float $number The number to convert
     * @return string The number in words
     */
    private function number_to_words($number) {
        if (class_exists('NumberFormatter')) {
            $fmt = new \NumberFormatter('en', \NumberFormatter::SPELLOUT);
            return $fmt->format($number);
        }

        // Minimal fallback for small numbers if intl extension is missing
        $ones = array('', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
                       'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
                       'seventeen', 'eighteen', 'nineteen');
        $tens = array('', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety');

        $n = (int) $number;
        if ($n < 0) return 'minus ' . $this->number_to_words(-$n);
        if ($n === 0) return 'zero';
        if ($n < 20) return $ones[$n];
        if ($n < 100) return $tens[(int)($n / 10)] . ($n % 10 ? ' ' . $ones[$n % 10] : '');
        if ($n < 1000) return $ones[(int)($n / 100)] . ' hundred' . ($n % 100 ? ' ' . $this->number_to_words($n % 100) : '');
        if ($n < 1000000) return $this->number_to_words((int)($n / 1000)) . ' thousand' . ($n % 1000 ? ' ' . $this->number_to_words($n % 1000) : '');
        if ($n < 1000000000) return $this->number_to_words((int)($n / 1000000)) . ' million' . ($n % 1000000 ? ' ' . $this->number_to_words($n % 1000000) : '');
        return $this->number_to_words((int)($n / 1000000000)) . ' billion' . ($n % 1000000000 ? ' ' . $this->number_to_words($n % 1000000000) : '');
    }

    /**
     * Normalize numbers, currency, and percentages into spoken words
     * so TTS engines pronounce them naturally.
     *
     * Handles: $1,234  $1234  1,234  1234  12.5%  1st/2nd/3rd/4th  1.5  etc.
     *
     * @param string $text The text to process
     * @return string Text with numbers converted to spoken words
     */
    private function normalize_numbers($text) {
        // 1. Currency: $1,234.56 or $1234 (with optional cents)
        $text = preg_replace_callback(
            '/\$\s?([\d,]+)(?:\.([\d]{1,2}))?/',
            function ($m) {
                $whole = (int) str_replace(',', '', $m[1]);
                $words = $this->number_to_words($whole);
                if ($whole === 1) {
                    $words .= ' dollar';
                } else {
                    $words .= ' dollars';
                }
                if (!empty($m[2])) {
                    $cents = (int) $m[2];
                    if (strlen($m[2]) === 1) $cents *= 10; // $1.5 → 50 cents
                    if ($cents > 0) {
                        $words .= ' and ' . $this->number_to_words($cents);
                        $words .= ($cents === 1) ? ' cent' : ' cents';
                    }
                }
                return $words;
            },
            $text
        );

        // 2. Percentages: 2% or 12.5%
        $text = preg_replace_callback(
            '/(\d+(?:\.\d+)?)\s?%/',
            function ($m) {
                $num = (float) $m[1];
                if (floor($num) == $num) {
                    return $this->number_to_words((int) $num) . ' percent';
                }
                $parts = explode('.', $m[1]);
                return $this->number_to_words((int) $parts[0]) . ' point ' .
                       implode(' ', array_map(function($d) { return $this->number_to_words((int) $d); }, str_split($parts[1]))) .
                       ' percent';
            },
            $text
        );

        // 3. Ordinals: 1st, 2nd, 3rd, 4th, 21st, etc.
        $text = preg_replace_callback(
            '/\b(\d+)(st|nd|rd|th)\b/i',
            function ($m) {
                $n = (int) $m[1];
                if (class_exists('NumberFormatter')) {
                    $fmt = new \NumberFormatter('en', \NumberFormatter::SPELLOUT);
                    $fmt->setTextAttribute(\NumberFormatter::DEFAULT_RULESET, '%spellout-ordinal');
                    return $fmt->format($n);
                }
                return $this->number_to_words($n) . $m[2];
            },
            $text
        );

        // 4. Plain large numbers with commas: 1,234 or 1,234,567
        //    (only when not already handled by currency above)
        $text = preg_replace_callback(
            '/\b(\d{1,3}(?:,\d{3})+)\b/',
            function ($m) {
                $num = (int) str_replace(',', '', $m[1]);
                return $this->number_to_words($num);
            },
            $text
        );

        // 5. Standalone large numbers (4+ digits, no commas) that TTS may mispronounce
        //    e.g. 22200 → "twenty-two thousand two hundred"
        //    Skip numbers that look like years (1900-2099) in natural context
        $text = preg_replace_callback(
            '/\b(\d{4,})\b/',
            function ($m) {
                $num = (int) $m[1];
                // Preserve 4-digit numbers in year range — TTS handles these fine
                if ($num >= 1900 && $num <= 2099 && strlen($m[1]) === 4) {
                    return $m[0];
                }
                return $this->number_to_words($num);
            },
            $text
        );

        return $text;
    }

    /**
     * Apply word replacements to text
     *
     * @param string $text The text to process
     * @return string Text with word replacements applied
     */
    private function apply_word_replacements($text) {
        if (!ttswp_has_paid_plan()) {
            return $text;
        }
        
        // Get word replacements
        $replacements = get_option('mementor_tts_word_replacements', array());
        if (!is_array($replacements) || empty($replacements)) {
            return $text;
        }
        
        // Apply each replacement
        foreach ($replacements as $replacement) {
            if (isset($replacement['original']) && isset($replacement['replacement'])) {
                $original = $replacement['original'];
                $quoted = preg_quote($original, '/');

                // Only use \b when the boundary character is a word character (letter/digit/underscore).
                // Non-word characters like "." or "$" break \b because \b expects a word↔non-word transition.
                $leading  = preg_match('/^\w/', $original) ? '\b' : '';
                $trailing = preg_match('/\w$/', $original) ? '\b' : '';

                $pattern = '/' . $leading . $quoted . $trailing . '/i';
                $text = preg_replace($pattern, $replacement['replacement'], $text);
            }
        }
        
        return $text;
    }

    /**
     * Save audio file to disk
     */
    private function save_audio_file($audio_data, $post_id, $element_uid = '', $language_code = 'en') {
        // Get WordPress upload directory information
        $upload_dir = wp_upload_dir();
        if ($upload_dir['error']) {
            $this->log_message('Failed to get upload directory: ' . esc_html($upload_dir['error']), 'error');
            return false;
        }
        $audio_dir = $upload_dir['basedir'] . '/text-to-speech-tts/';
        $audio_url_base = $upload_dir['baseurl'] . '/text-to-speech-tts/';

        // Create directory if it doesn't exist
        if (!file_exists($audio_dir)) {
            if (!wp_mkdir_p($audio_dir)) {
                $this->log_message('Failed to create directory: ' . esc_html($audio_dir), 'error');
                return false;
            }
        }
        
        // Ensure language code is safe for filename
        $language_code = sanitize_file_name($language_code ?: 'en');

        // Generate filename with language code
        if ($post_id > 0) {
            $post_title = get_the_title($post_id);
            $slug = sanitize_title($post_title);
            // Limit slug length to avoid excessively long filenames
            $slug = substr($slug, 0, 80);
            $filename_base = $slug . '-' . intval($post_id);
        } elseif (!empty($element_uid)) {
            // For shortcodes or other non-post content
            $filename_base = sanitize_file_name($element_uid);
        } else {
            // Fallback if neither post_id nor element_uid is provided
            $this->log_message('Missing post_id or element_uid for filename generation', 'warning');
            $filename_base = 'tts-audio-' . uniqid();
        }
        $filename = $filename_base . '-' . $language_code . '.mp3';
        $full_path = $audio_dir . $filename;

        // Save file using WordPress filesystem, with direct write fallback for AJAX contexts
        global $wp_filesystem;
        if (!$wp_filesystem || !is_object($wp_filesystem)) {
            if (!function_exists('WP_Filesystem')) {
                require_once ABSPATH . 'wp-admin/includes/file.php';
            }
            WP_Filesystem();
        }
        if ($wp_filesystem && is_object($wp_filesystem)) {
            if (!$wp_filesystem->put_contents($full_path, $audio_data, FS_CHMOD_FILE)) {
                $this->log_message('Failed to save audio file using WP Filesystem: ' . esc_html($full_path), 'error');
                return false;
            }
        } else {
            // Fallback for AJAX/cron contexts where WP_Filesystem may not initialize
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem unavailable in this context.
            if (file_put_contents($full_path, $audio_data) === false) {
                $this->log_message('Failed to save audio file: ' . esc_html($full_path), 'error');
                return false;
            }
            chmod($full_path, 0644);
        }

        // Add to Media Library if enabled (must happen before S3 offload may delete local file)
        if (get_option('mementor_tts_media_library', 'no') === 'yes' && $post_id > 0) {
            // Check if S3 offload will delete the local file — skip media library if so
            $offload_enabled = get_option('mementor_tts_offload_enabled', 'no') === 'yes';
            $offload_provider = get_option('mementor_tts_offload_provider', 'local');
            $will_delete_local = $offload_enabled && $offload_provider === 's3' && apply_filters('mementor_tts_delete_local_after_s3', true);

            if (!$will_delete_local) {
                $this->add_to_media_library($full_path, $post_id);
            } else {
                $this->log_message('Media Library: Skipped — S3 offload will delete local file');
            }
        }

        // Return URL
        $audio_url = $audio_url_base . $filename;

        // Check if S3 offload is enabled (PRO feature)
        $offload_enabled = get_option('mementor_tts_offload_enabled', 'no') === 'yes';
        $offload_provider = get_option('mementor_tts_offload_provider', 'local');

        error_log('TTS S3 Debug: Offload Enabled = ' . ($offload_enabled ? 'YES' : 'NO'));
        error_log('TTS S3 Debug: Offload Provider = ' . $offload_provider);
        error_log('TTS S3 Debug: Checking condition: $offload_enabled && $offload_provider === "s3"');

        if ($offload_enabled && $offload_provider === 's3') {
            error_log('TTS S3 Debug: Condition met! Starting S3 upload for: ' . $filename);
            // Upload to S3
            $s3_result = $this->upload_to_s3($full_path, $filename);

            error_log('TTS S3 Debug: S3 upload result: ' . print_r($s3_result, true));

            if (!is_wp_error($s3_result) && isset($s3_result['url'])) {
                // Use S3 URL
                $audio_url = $s3_result['url'];
                error_log('TTS S3 Debug: SUCCESS! S3 URL = ' . $audio_url);

                // Optionally delete local file to save space
                if (apply_filters('mementor_tts_delete_local_after_s3', true)) {
                    if ($wp_filesystem && is_object($wp_filesystem)) {
                        $wp_filesystem->delete($full_path);
                    } else {
                        wp_delete_file($full_path);
                    }
                    error_log('TTS S3 Debug: Local file deleted: ' . $full_path);
                }
            } else {
                $error_msg = is_wp_error($s3_result) ? $s3_result->get_error_message() : 'Unknown error';
                error_log('TTS S3 Debug: S3 upload FAILED! Error: ' . $error_msg);
            }
        } else {
            error_log('TTS S3 Debug: S3 upload skipped (offload not enabled or provider not S3)');
        }

        // Allow PRO plugin to intercept and possibly change the audio URL (e.g., for Dropbox storage)
        error_log('TTS Base: About to call mementor_tts_after_save_audio filter with path: ' . $full_path);
        $filtered_result = apply_filters('mementor_tts_after_save_audio', $audio_url, $full_path, $post_id, $element_uid, $language_code);
        error_log('TTS Base: After filter call - result type: ' . gettype($filtered_result));

        // If the filter returns a string, use it as the URL, otherwise use the original URL
        if (is_string($filtered_result) && !empty($filtered_result)) {
            // Check if the result looks like a file path instead of a URL
            if (strpos($filtered_result, '/home/') === 0 || strpos($filtered_result, '/var/') === 0) {
                error_log('TTS Base: Filter returned a file path instead of URL, using original URL');
                return $audio_url;
            }
            return $filtered_result;
        }

        return $audio_url;
    }

    /**
     * Get WordPress filesystem instance
     */
    private function get_filesystem() {
        global $wp_filesystem;

        // Initialize the WordPress Filesystem API
        if (!function_exists('WP_Filesystem')) {
            // Use add_action to ensure this hook is fired when WordPress is fully loaded
            add_action('admin_init', function() {
                // WP_Filesystem will be available here
            });
            
            // For immediate use, we need to use WordPress functions that are available
            if (function_exists('request_filesystem_credentials') && function_exists('WP_Filesystem')) {
                $creds = request_filesystem_credentials(site_url());
                WP_Filesystem($creds);
            } else {
                $this->log_message('WordPress filesystem functions not available', 'error');
                return false;
            }
        } else {
            // If filesystem is not initialized, try to initialize it
            if (!$wp_filesystem) {
                $creds = request_filesystem_credentials(site_url());
                if (!WP_Filesystem($creds)) {
                    $this->log_message('Failed to initialize WordPress filesystem', 'error');
                    return false;
                }
            }
        }

        return $wp_filesystem;
    }

    /**
     * Generate filename based on pattern
     */
    private function generate_filename($pattern, $post_id, $element_uid) {
        // If no pattern is provided or it's empty, just use the element UID
        if (empty($pattern)) {
            return $element_uid;
        }

        $post = get_post($post_id);
        if (!$post) {
            return $element_uid;
        }

        $replacements = array(
            '[id]' => $post_id,
            '[name]' => sanitize_title($post->post_name),
            '[title]' => sanitize_title($post->post_title),
            '[author]' => sanitize_title(get_the_author_meta('display_name', $post->post_author)),
            '[type]' => $post->post_type
        );

        $filename = str_replace(array_keys($replacements), array_values($replacements), $pattern);

        // Sanitize filename
        $filename_pattern = '/[^a-zA-Z0-9s()_-]/';
        $filename = preg_replace($filename_pattern, '', $filename);
        $filename = trim($filename);

        return $filename ?: $element_uid;
    }

    /**
     * Add file to Media Library
     * Registers an audio file as a WordPress attachment attached to the parent post.
     *
     * @param string $file_path Absolute path to the MP3 file on disk.
     * @param int    $post_id   The parent post ID to attach to.
     * @return int|false Attachment ID on success, false on failure.
     */
    public function add_to_media_library($file_path, $post_id = 0) {
        $post_id = absint($post_id);

        // Verify parent post exists and is not trashed (if a post ID was provided)
        if ($post_id > 0) {
            $parent = get_post($post_id);
            if (!$parent || $parent->post_status === 'trash') {
                return false;
            }
        }

        // Verify file exists on disk
        if (!file_exists($file_path)) {
            $this->log_message('Media Library: File not found: ' . esc_html($file_path), 'warning');
            return false;
        }

        // Clean up existing attachment if one was previously created (handles regeneration)
        if ($post_id > 0) {
            $existing_id = get_post_meta($post_id, '_mementor_tts_attachment_id', true);
            if ($existing_id) {
                $existing_id = absint($existing_id);
                $old_attached_file = get_attached_file($existing_id);

                if ($old_attached_file && $old_attached_file !== $file_path && file_exists($old_attached_file)) {
                    // Different file (e.g., after rename) — safe to delete both record and old file
                    wp_delete_attachment($existing_id, true);
                } else {
                    // Same file path — cannot use wp_delete_attachment or wp_delete_post
                    // as both delete the physical file for attachments. Remove the DB
                    // record directly to avoid deleting the newly saved audio file.
                    global $wpdb;
                    $wpdb->delete($wpdb->posts, array('ID' => $existing_id), array('%d'));
                    $wpdb->delete($wpdb->postmeta, array('post_id' => $existing_id), array('%d'));
                    clean_post_cache($existing_id);
                }
                delete_post_meta($post_id, '_mementor_tts_attachment_id');
            }
        }

        // Ensure required WordPress files are loaded
        if (!function_exists('wp_generate_attachment_metadata')) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
        }
        if (!function_exists('wp_read_audio_metadata')) {
            require_once ABSPATH . 'wp-admin/includes/media.php';
        }
        if (!function_exists('wp_handle_sideload')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        // Build attachment data
        $filename = basename($file_path, '.mp3');
        $attachment = array(
            'post_mime_type' => 'audio/mpeg',
            'post_title'     => $post_id > 0 ? get_the_title($post_id) . ' (TTS Audio)' : $filename . ' (TTS Audio)',
            'post_status'    => 'inherit',
        );
        if ($post_id > 0) {
            $attachment['post_parent'] = $post_id;
        }

        // Insert the attachment
        $attachment_id = wp_insert_attachment($attachment, $file_path, $post_id > 0 ? $post_id : 0);

        // Check for failure
        if (is_wp_error($attachment_id) || $attachment_id === 0) {
            $error_msg = is_wp_error($attachment_id) ? $attachment_id->get_error_message() : 'Unknown error';
            $this->log_message('Media Library: Failed to insert attachment: ' . esc_html($error_msg), 'error');
            return false;
        }

        // Generate and save attachment metadata
        $metadata = wp_generate_attachment_metadata($attachment_id, $file_path);
        wp_update_attachment_metadata($attachment_id, $metadata);

        // Store attachment ID on the parent post for future cleanup
        if ($post_id > 0) {
            update_post_meta($post_id, '_mementor_tts_attachment_id', $attachment_id);
        }

        $this->log_message('Media Library: Attachment #' . $attachment_id . ' created' . ($post_id > 0 ? ' for post #' . $post_id : ' (unattached)'));

        return $attachment_id;
    }

    /**
     * Cleanup audio files directory
     *
     * This should be called via a WordPress cron job or admin action
     */
    public function cleanup_audio_files($days = 30) {
        // Get the upload directory
        $upload_dir = wp_upload_dir();
        $dir = $upload_dir['basedir'] . '/text-to-speech-tts/';
        
        // If directory doesn't exist, no cleanup needed
        if (!is_dir($dir)) {
            return false;
        }
        
        // Set cutoff date
        $cutoff = time() - (86400 * $days);
        
        // Call recursive cleanup function
        return $this->cleanup_directory($dir, $cutoff);
    }
    
    /**
     * Cleanup a directory recursively
     */
    private function cleanup_directory($dir, $cutoff) {
        // Don't continue if WP_Filesystem is not available
        if (!function_exists('WP_Filesystem')) {
            return false;
        }
        
        // Initialize the WordPress Filesystem API
        global $wp_filesystem;
        
        // Load WP_Filesystem if not already loaded
        // Using WordPress hooks instead of direct includes
        if (!$wp_filesystem) {
            $creds = request_filesystem_credentials(site_url());
            if (!WP_Filesystem($creds)) {
                return false;
            }
        }

        // Get list of files
        $files = glob($dir . '*');
        
        // Process each file/directory
        foreach ($files as $file) {
            // Skip current and parent directories
            if ($file !== "." && $file !== "..") {
                $file_path = $dir . '/' . $file;
                if (is_dir($file_path)) {
                    // Recursively clean up directories
                    $this->cleanup_directory($file_path, $cutoff);
                    
                    // Try to remove directory if empty
                    if (count(scandir($file_path)) <= 2) { // Only . and .. remain
                        $this->log_message('Attempting to remove empty directory: ' . $file_path);
                        
                        // Initialize the WP filesystem if needed
                        if (!$wp_filesystem) {
                            require_once ABSPATH . '/wp-admin/includes/file.php';
                            WP_Filesystem();
                        }
                        
                        if ($wp_filesystem->rmdir($file_path, false)) {
                            $this->log_message('Successfully removed directory: ' . $file_path);
                        } else {
                            $this->log_message('Failed to remove directory: ' . $file_path, 'error');
                        }
                    } elseif (is_file($file_path)) {
                        // If file older than cutoff, delete it
                        if (filemtime($file_path) < $cutoff) {
                            $this->delete_file($file_path);
                        }
                    }
                }
            }
        }
        
        return true;
    }

    /**
     * Initialize the API
     */
    private function initialize_api() {
        if (!$this->api_client) {
            $this->api_client = Mementor_TTS_Api_Client::get_instance();
        }
    }

    /**
     * Check if a post needs audio generation
     * 
     * @param int $post_id The post ID
     * @param bool $force Whether to force regeneration
     * @return bool Whether the post needs audio generation
     */
    private function needs_audio_generation($post_id, $force = false) {
        // If forcing regeneration, always return true
        if ($force) {
            $this->log_message('Force regeneration requested');
            return true;
        }

        // Check if post has audio file
        $has_audio = get_post_meta($post_id, '_mementor_tts_has_audio', true);
        if ($has_audio) {
            // Check if file exists
            $audio_url = get_post_meta($post_id, '_mementor_tts_audio_url', true);
            if ($audio_url) {
                $upload_dir = wp_upload_dir();
                $file_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $audio_url);
                
                // Check if file exists and is readable
                if (file_exists($file_path) && is_readable($file_path)) {
                    // Check if file is not empty and is a valid MP3
                    $file_size = filesize($file_path);
                    if ($file_size > 1000) { // Minimum size for a valid MP3
                        $this->log_message('Valid audio file exists at: ' . esc_url($audio_url)); // Escaped URL for log
                        return false;
                    } else {
                        $this->log_message('Audio file exists but appears to be invalid or corrupted: ' . esc_url($audio_url), 'warning'); // Escaped URL for log
                        // Delete the invalid file
                        wp_delete_file($file_path);
                        delete_post_meta($post_id, '_mementor_tts_has_audio');
                        delete_post_meta($post_id, '_mementor_tts_audio_url');
                        return true;
                    }
                } else {
                    $this->log_message('Audio file not found or not readable: ' . esc_url($audio_url), 'warning'); // Escaped URL for log
                    // Clean up invalid metadata
                    delete_post_meta($post_id, '_mementor_tts_has_audio');
                    delete_post_meta($post_id, '_mementor_tts_audio_url');
                    return true;
                }
            }
        }

        $this->log_message('No existing audio found, generation needed');
        return true;
    }

    /**
     * Update post audio metadata
     *
     * @param int $post_id The post ID
     * @param string $audio_url The audio file URL
     * @param string $language_code The language code for this audio (optional)
     */
    private function update_post_audio_meta($post_id, $audio_url, $language_code = '') {
        update_post_meta($post_id, '_mementor_tts_has_audio', true);
        update_post_meta($post_id, '_mementor_tts_last_generated', current_time('timestamp'));

        // Store URL in language-specific meta key if language is provided
        if (!empty($language_code)) {
            update_post_meta($post_id, '_mementor_tts_audio_url_' . sanitize_key($language_code), esc_url_raw($audio_url));
            $this->log_message('Saved audio URL to language-specific meta key: _mementor_tts_audio_url_' . $language_code);
        }

        // Also store in generic meta for backwards compatibility
        update_post_meta($post_id, '_mementor_tts_audio_url', esc_url_raw($audio_url));
    }

    /**
     * Get voice settings from options
     * 
     * @return array Voice settings array
     */
    private function get_voice_settings() {
        // Get stability setting from options
        $stability = floatval(get_option('mementor_tts_stability_preset', 50)) / 100;
        
        // Get similarity boost setting from options
        $similarity_boost = floatval(get_option('mementor_tts_similarity_preset', 75)) / 100;
        
        // Get speaker boost setting from options
        $speaker_boost_raw = get_option('mementor_tts_speaker_boost', true);
        
        // Convert to boolean - handle both string and boolean values
        if (is_string($speaker_boost_raw)) {
            $speaker_boost = ($speaker_boost_raw === '1' || $speaker_boost_raw === 'true' || $speaker_boost_raw === 'on');
        } else {
            $speaker_boost = (bool) $speaker_boost_raw;
        }
        
        
        // Get speed setting from options
        $speed = get_option('mementor_tts_speed_preset', '1.0');
        
        $log_message = sprintf(
            'Voice settings - Stability: %s, Similarity: %s, Speaker Boost: %s, Speed: %s',
            floatval($stability),
            floatval($similarity_boost),
            ($speaker_boost ? 'Yes' : 'No'),
            esc_html($speed) // Escaped speed
        );
        $this->log_message($log_message);
        
        // Get MP3 output quality setting
        $mp3_quality_raw = get_option('mementor_tts_quality', '128');
        // Convert the quality value to the API format
        if ($mp3_quality_raw === '128') {
            $mp3_quality = 'mp3_44100_128';
        } elseif ($mp3_quality_raw === 'mp3_44100_192') {
            $mp3_quality = 'mp3_44100_192';
        } else {
            // Default to 128kbps for any other value
            $mp3_quality = 'mp3_44100_128';
        }
        
        // Get model ID setting
        $model_id = get_option('mementor_tts_model', 'eleven_flash_v2_5');
        
        $this->log_message('MP3 Quality setting: ' . $mp3_quality);
        $this->log_message('Model ID setting: ' . $model_id);
        
        return array(
            'stability' => $stability,
            'similarity_boost' => $similarity_boost,
            'style' => 0, // Style is not used in free version
            'use_speaker_boost' => $speaker_boost,
            'speed' => $speed,
            'mp3_quality' => $mp3_quality,
            'model_id' => $model_id
        );
    }

    /**
     * Generate audio for a post
     *
     * @param string $text Text to convert to speech
     * @param int    $post_id Post ID
     * @param string $voice_id Voice ID to use (optional)
     * @param array  $options Additional options for generation (optional)
     * @return array Result array with success status and message
     */
    public function generate_audio($text, $post_id, $voice_id = '', $options = array()) {
        $this->log_message('====== Starting Audio Generation Process ======');
        $this->log_message('Text Length: ' . strlen($text));
        $post_id = intval($post_id); // Ensure post_id is an integer
        $this->log_message('Post ID: ' . $post_id);

        $ttswp_proc_text_len   = strlen( $text );
        $ttswp_proc_raw_len    = 0;
        $ttswp_proc_title      = null;
        $ttswp_proc_edit_url   = null;
        $ttswp_proc_permalink  = null;
        $ttswp_proc_post_type  = null;
        if ( $post_id > 0 ) {
            $ttswp_proc_post = get_post( $post_id );
            if ( $ttswp_proc_post ) {
                $ttswp_proc_raw_len   = strlen( (string) $ttswp_proc_post->post_content );
                $ttswp_proc_title     = (string) $ttswp_proc_post->post_title;
                $ttswp_proc_edit_url  = get_edit_post_link( $post_id, 'raw' );
                $ttswp_proc_permalink = get_permalink( $post_id );
                $ttswp_proc_post_type = (string) $ttswp_proc_post->post_type;
            }
        }
        $ttswp_proc_ctx = array(
            'text_len'        => $ttswp_proc_text_len,
            'raw_post_len'    => $ttswp_proc_raw_len,
            'post_title'      => $ttswp_proc_title,
            'post_type'       => $ttswp_proc_post_type,
            'edit_url'        => $ttswp_proc_edit_url,
            'permalink'       => $ttswp_proc_permalink,
            'voice_id_param'  => $voice_id !== '' ? $voice_id : null,
            'target_language' => isset( $options['target_language'] ) ? $options['target_language'] : null,
        );
        if ( get_option( 'mementor_tts_remote_debug_include_content', '0' ) === '1' ) {
            $ttswp_proc_ctx['text_sample'] = mb_substr( $text, 0, 2000 );
            if ( isset( $ttswp_proc_post ) && $ttswp_proc_post ) {
                $ttswp_proc_ctx['raw_sample'] = mb_substr( (string) $ttswp_proc_post->post_content, 0, 2000 );
            }
        }
        // Stash for speech-builder's log_generation to pick up. One enriched
        // event per generation instead of two.
        self::$last_gen_ctx[ $post_id ] = $ttswp_proc_ctx;
        
        // --- Lock Mechanism Start ---
        // Get language code for this post (supports Weglot, WPML, Polylang)
        $language_code = 'en'; // Default to English
        if (class_exists('Mementor_TTS_I18n_Helper') && $post_id > 0) {
            $language_code = Mementor_TTS_I18n_Helper::get_post_language($post_id);
        } elseif (class_exists('Mementor_TTS_I18n_Helper')) {
            $language_code = Mementor_TTS_I18n_Helper::get_current_language();
        } elseif (defined('ICL_LANGUAGE_CODE')) {
            $language_code = ICL_LANGUAGE_CODE;
        } else {
            $language_code = get_option('mementor_tts_language', 'en');
        }

        // Caller override for Weglot per-language generation: when the post-list
        // Weglot column asks for a specific destination language, the processor
        // must translate, name, and store under that code instead of the
        // auto-detected one.
        if (!empty($options['target_language'])) {
            $language_code = sanitize_key($options['target_language']);
            $this->log_message('Target language override from options: ' . esc_html($language_code));
        }

        $this->log_message('Language code for audio generation: ' . esc_html($language_code));

        // Translate content if on a translated page (Weglot integration)
        if (class_exists('Mementor_TTS_I18n_Helper') && Mementor_TTS_I18n_Helper::is_weglot_active()) {
            $original_language = Mementor_TTS_I18n_Helper::get_original_language();
            if ($language_code !== $original_language) {
                $this->log_message('Translating content from ' . $original_language . ' to ' . $language_code . ' using Weglot API');
                $translated_text = Mementor_TTS_I18n_Helper::translate_content($text, $language_code, $original_language);
                if ($translated_text !== $text) {
                    $text = $translated_text;
                    $this->log_message('Content translated successfully. New length: ' . strlen($text));
                } else {
                    $this->log_message('Translation returned same content (may have failed or was not needed)');
                }
            }
        }

        $lock_key = sprintf('mementor_tts_lock_%d_%s', $post_id, $language_code);

        if (get_transient($lock_key)) {
            $this->log_message('Audio generation for post ' . $post_id . ' (' . $language_code . ') is already in progress. Skipping.', 'warning');
            // Return existing URL if available, otherwise false
            $existing_url = get_post_meta($post_id, '_mementor_tts_audio_url', true);
            return $existing_url ? esc_url_raw($existing_url) : false;
        }
        // Set the lock with a 60-second expiry
        set_transient($lock_key, true, 60);
        // --- Lock Mechanism End ---

        try {
            // If no voice ID provided, check for custom voice ID first
            if (empty($voice_id)) {
                // Check if paid plan user has a custom voice ID
                if (ttswp_has_paid_plan()) {
                    $custom_voice_id = get_option('mementor_tts_custom_voice_id', '');
                    if (!empty($custom_voice_id)) {
                        $voice_id = $custom_voice_id;
                        $this->log_message('Using custom voice ID: ' . esc_html($voice_id));
                    }
                }
                
                // Check language-specific voice based on post language
                if (empty($voice_id) && class_exists('Mementor_TTS_I18n_Helper')) {
                    $lang_voice_id = ($post_id > 0) ? Mementor_TTS_I18n_Helper::get_voice_for_post($post_id) : Mementor_TTS_I18n_Helper::get_voice_for_language();
                    if (!empty($lang_voice_id)) {
                        $voice_id = $lang_voice_id;
                        $this->log_message('Using language-specific voice: ' . esc_html($voice_id));
                    }
                }

                // If still no voice ID, use the selected voice
                if (empty($voice_id)) {
                    $voice_id = get_option('mementor_tts_voice', 'EXAVITQu4vr4xnSDxMaL'); // Default to Nicole's voice
                    $this->log_message('No voice ID provided, using default: ' . esc_html($voice_id));
                }

            }
            $this->log_message('Voice ID: ' . esc_html($voice_id));
    
            // Initialize API client if needed
            $this->log_message('Initializing API...');
            if (!$this->api_client) {
                $this->initialize_api();
                if (!$this->api_client) {
                    $this->log_message('Failed to initialize API client.', 'error');
                    delete_transient($lock_key);
                    return false;
                }
            }
            $this->log_message('API client initialized successfully');
    
            // Get voice settings
            $voice_settings = $this->get_voice_settings();
            
            // Add language code to voice settings
            $voice_settings['language_code'] = $language_code;
            
            // Add privacy mode setting if enabled
            $privacy_mode = get_option('mementor_tts_privacy_mode', false);
            if ($privacy_mode) {
                $voice_settings['privacy_mode'] = true;
                $options['privacy_mode'] = true;
                $this->log_message('Privacy mode enabled for this generation');
            }
            
            // Merge voice settings into options to ensure language code is included
            $options = array_merge($voice_settings, $options);
            
            $this->log_message('Voice settings: ' . wp_json_encode($voice_settings));
            $this->log_message('Final options being sent to API: ' . wp_json_encode($options));
    
            // Clean up the text before processing
            $this->log_message('Text before cleanup: ' . substr($text, 0, 200) . '...');
            $text = preg_replace('#</?speak>#i', '', $text); // Remove any existing outer speak tags only
            $this->log_message('Removed any existing speak tags from text');
            
            // Replace NIAB with --Important Note--
            $text = str_replace('NIAB', '--Important Note--', $text);
            $this->log_message('After NIAB replacement: ' . substr($text, 0, 200) . '...');
            
            // FIRST: Normalize double (or more) dashes for pauses
            $text = preg_replace('/\s*--+\s*/', ' -- ', $text); // Two or more dashes become " -- "
            $this->log_message('After normalizing dashes: ' . substr($text, 0, 200) . '...');
            
            // THEN: Replace single hyphens between words with spaces
            $text = preg_replace('/(\w)-(\w)/', '$1 $2', $text); // word-word becomes word word
            $this->log_message('After replacing hyphens: ' . substr($text, 0, 200) . '...');
            
            // Finally: Clean up spaces
            $text = preg_replace('/\s+/', ' ', $text);
            $text = trim($text);
            $this->log_message('After final cleanup: ' . substr($text, 0, 200) . '...');
            
            // Get custom introduction and outro text from options
            $custom_intro = get_option('mementor_tts_text_before', '');
            $custom_outro = get_option('mementor_tts_text_after', '');
            
            // Add custom introduction if it exists
            if (!empty($custom_intro)) {
                $custom_intro = trim($custom_intro);
                $this->log_message('Adding custom introduction: "' . substr($custom_intro, 0, 50) . (strlen($custom_intro) > 50 ? '...' : '') . '"');
                $text = $custom_intro . ' -- ' . $text;
            }
            
            // Add custom outro if it exists
            if (!empty($custom_outro)) {
                $custom_outro = trim($custom_outro);
                $this->log_message('Adding custom outro: "' . substr($custom_outro, 0, 50) . (strlen($custom_outro) > 50 ? '...' : '') . '"');
                $text = $text . ' -- ' . $custom_outro;
            }
            
            $this->log_message('Text before splitting: ' . substr($text, 0, 200) . '...');
            $parts = explode(' -- ', $text); // Split by double dash and rejoin
            $this->log_message('Number of parts after split: ' . count($parts));
            $this->log_message('First part: ' . (isset($parts[0]) ? substr($parts[0], 0, 100) : 'NO FIRST PART'));
            $parts = array_map('trim', $parts);
            $parts = array_filter($parts); // Remove empty parts
            $this->log_message('Number of parts after filter: ' . count($parts));
            // REMOVED array_unique() - it was removing duplicate paragraphs in Fusion Builder posts
            
            // Apply word replacements before custom pauses
            $text = implode(' -- ', $parts);
            $text = $this->apply_word_replacements($text);
            
            // Apply custom pauses instead of generic " -- "
            $text = $this->apply_custom_pauses($text);
            $this->log_message('Text after applying custom pauses: ' . substr($text, 0, 200) . '...');
            
            $text = '<speak><break time="0.65s" />' . $text . '</speak>'; // Wrap text in SSML tags with initial pause
            $this->log_message('Text wrapped in SSML tags');
    
            // Log the actual text being sent (preview)
            $text_preview = substr($text, 0, 200) . (strlen($text) > 200 ? '...' : '');
            $this->log_message('Text being sent to API (preview): ' . esc_html($text_preview));
    
            // Build filename: {post-slug}-{post_id}-{lang}.mp3
            $post_slug = sanitize_title( get_the_title( $post_id ) );
            $post_slug = $post_slug ?: 'audio';
            $options['filename'] = $post_slug . '-' . $post_id . '-' . $language_code . '.mp3';
            $this->log_message('Audio filename: ' . $options['filename']);

            // Call SaaS API for synthesis (credits are checked server-side)
            $this->log_message('Calling SaaS API synthesize...');
            $response = $this->api_client->synthesize($text, $voice_id, $options);
    
            // Check for errors in the response
            if (is_wp_error($response)) {
                $error_message = $response->get_error_message();
                $this->log_message('API Error: ' . esc_html($error_message), 'error');

                // Release lock before returning
                delete_transient($lock_key);

                // Check for not connected
                if ($response->get_error_code() === 'not_connected') {
                    return array(
                        'success' => false,
                        'error' => 'not_connected',
                        'message' => $error_message,
                    );
                }

                // Check for credit exhaustion
                if ($response->get_error_code() === 'synthesis_failed') {
                    $data = $response->get_error_data();
                    if (isset($data['status']) && $data['status'] === 402) {
                        $saas_body = isset($data['data']) && is_array($data['data']) ? $data['data'] : array();
                        return array(
                            'success' => false,
                            'error'   => 'credits_exhausted',
                            'message' => $error_message,
                            'credits_info' => array(
                                'remaining' => isset($saas_body['credits_remaining']) ? (int) $saas_body['credits_remaining'] : 0,
                                'required'  => isset($saas_body['credits_required'])  ? (int) $saas_body['credits_required']  : 0,
                            ),
                        );
                    }
                }

                return array(
                    'success' => false,
                    'error' => $error_message
                );
            }

            $this->log_message('SaaS API call completed successfully');

            // Save audio via API client (handles paid vs free plan URL storage)
            $file_url = $this->api_client->save_audio($post_id, $response, $language_code);

            if (is_wp_error($file_url)) {
                $this->log_message('ERROR: Failed to save audio: ' . $file_url->get_error_message(), 'error');
                $this->log_message('====== Audio Generation Process Complete ======');
                delete_transient($lock_key);
                return false;
            }

            $this->log_message('Audio saved successfully to: ' . $file_url);
            $this->log_message('====== Audio Generation Process Complete ======');

            // Credits charged by SaaS
            $character_cost = isset($response['credits_charged']) ? intval($response['credits_charged']) : 0;
    
            // Release lock before returning success
            delete_transient($lock_key);

            // Credits are deducted server-side by the SaaS API
            $actual_credits = $character_cost > 0 ? $character_cost : strlen($text);

            // Store character cost in transient for AJAX handler to read
            if ($character_cost > 0) {
                set_transient('mementor_tts_char_cost_' . $post_id, $character_cost, 60);
            }

            // Fire custom action hook after successful audio generation
            do_action('mementor_tts_after_generate_audio', $actual_credits, $post_id, $voice_id);

            return $file_url;

        } catch (Exception $e) {
            $this->log_message('Exception during audio generation: ' . esc_html($e->getMessage()), 'error');
            // Ensure lock is released on exception
            delete_transient($lock_key);
            return false;
        }
    }

    /**
     * Generate audio for a shortcode
     * 
     * @param string $text The text to convert to speech
     * @param string $element_uid Unique identifier for the element
     * @param string $voice_id Optional voice ID to use
     * @param array $options Optional settings to override defaults
     * @return array Result with success status and file URL
     */
    public function generate_shortcode_audio($text, $element_uid, $voice_id = '', $options = array()) {
        // Add debug information header
        $this->log_message('========== SHORTCODE AUDIO GENERATION START ==========');
        $this->log_message('Element UID: ' . $element_uid);
        $this->log_message('Text length: ' . strlen($text) . ' characters');
        $this->log_message('Voice ID: ' . ($voice_id ?: 'not provided, will use default'));

        // Use explicit language from options (set by AJAX handler from plugin settings),
        // falling back to the plugin's TTS language setting.
        // Do NOT use ICL_LANGUAGE_CODE here — it reflects admin UI language, not TTS language.
        $language_code = !empty($options['language_code'])
            ? $options['language_code']
            : get_option('mementor_tts_language', 'en');
        $this->log_message('Language code for shortcode audio generation: ' . esc_html($language_code));

        // Translate content if on a translated page (Weglot integration)
        if (class_exists('Mementor_TTS_I18n_Helper') && Mementor_TTS_I18n_Helper::is_weglot_active()) {
            $original_language = Mementor_TTS_I18n_Helper::get_original_language();
            if ($language_code !== $original_language) {
                $this->log_message('Translating shortcode content from ' . $original_language . ' to ' . $language_code . ' using Weglot API');
                $translated_text = Mementor_TTS_I18n_Helper::translate_content($text, $language_code, $original_language);
                if ($translated_text !== $text) {
                    $text = $translated_text;
                    $this->log_message('Shortcode content translated successfully. New length: ' . strlen($text));
                } else {
                    $this->log_message('Translation returned same content (may have failed or was not needed)');
                }
            }
        }

        // Initialize the API if not already done
        $this->initialize_api();
        
        // Check if debug mode is enabled
        if ($this->debug_mode) {
            $this->log_message('Debug mode is enabled, generating detailed logs');
            // Log first 100 characters of text for debugging
            $this->log_message('Text preview: "' . substr($text, 0, 100) . (strlen($text) > 100 ? '...' : '') . '"');
        }
        
        // Get SaaS API client instance
        $api = $this->api_client;
        if (!$api) {
            $this->log_message('ERROR: Failed to initialize SaaS API client', 'error');
            return array(
                'success' => false,
                'message' => 'Failed to initialize API'
            );
        }
        $this->log_message('SaaS API client initialized successfully');
        
        // Check if audio file already exists
        $upload_dir = wp_upload_dir();
        $file_path = $upload_dir['basedir'] . '/text-to-speech-tts/' . $element_uid . '-' . $language_code . '.mp3';
        $file_url = $upload_dir['baseurl'] . '/text-to-speech-tts/' . $element_uid . '-' . $language_code . '.mp3';
        
        if (file_exists($file_path)) {
            $file_size = filesize($file_path);
            if ($file_size > 1000) { // Minimum size for valid MP3
                $this->log_message('Audio file already exists at: ' . $file_url . ' (size: ' . $file_size . ' bytes)');
                $this->log_message('========== SHORTCODE AUDIO GENERATION END - USING EXISTING FILE ==========');
                return array(
                    'success' => true,
                    'file_url' => $file_url,
                    'message' => 'Using existing audio file'
                );
            } else {
                $this->log_message('WARNING: Existing audio file is too small (' . $file_size . ' bytes), regenerating', 'warning');
                // Delete the invalid file
                wp_delete_file($file_path);
            }
        } else {
            $this->log_message('No existing audio file found, will generate new audio');
        }
        
        // Get voice settings
        $voice_settings = $this->get_voice_settings();
        
        // Add language code and element_uid to voice settings
        $voice_settings['language_code'] = $language_code;
        $voice_settings['element_uid'] = $element_uid;
        
        // If no voice ID provided, check for custom voice ID first
        if (empty($voice_id)) {
            // Check if paid plan user has a custom voice ID
            if (ttswp_has_paid_plan()) {
                $custom_voice_id = get_option('mementor_tts_custom_voice_id', '');
                if (!empty($custom_voice_id)) {
                    $voice_id = $custom_voice_id;
                    $this->log_message('Using custom voice ID: ' . $voice_id);
                }
            }
            
            // If still no voice ID, use the selected voice
            if (empty($voice_id)) {
                $default_voice = get_option('mementor_tts_voice', self::DEFAULT_VOICE_ID);
                $this->log_message('Using default voice ID: ' . $default_voice);
                $voice_id = $default_voice;
            }
            
            // Apply filter to allow custom voice ID override
            $voice_id = apply_filters('mementor_tts_voice_id', $voice_id, 0);
            $this->log_message('Final voice ID after filter: ' . $voice_id);
        } else {
            $this->log_message('Using provided voice ID: ' . $voice_id);
        }
        
        // Ensure text is properly formatted
        // Create a working copy of the text
        $processed_text = $text;
        
        // First clean the text by removing any existing outer speak tags only, preserve inner SSML
        $processed_text = preg_replace('#</?speak>#i', '', $processed_text);
        
        // Replace NIAB with --Important Note--
        $processed_text = str_replace('NIAB', '--Important Note--', $processed_text);
        
        // FIRST: Process line breaks and normalize double dashes
        // This preserves intentional pauses before we process single hyphens
        $processed_text = str_replace("\n", ' -- ', $processed_text);
        $processed_text = str_replace(array("\r\n", "\r", "\n"), ' -- ', $processed_text);
        $processed_text = preg_replace('/\s*--+\s*/', ' -- ', $processed_text); // Normalize dashes with spaces
        
        // THEN: Replace single hyphens between words with spaces for more natural speech
        // This prevents ElevenLabs from treating word-connecting hyphens as pauses
        // Pattern: word-word becomes word word, but -- (already processed) are preserved
        $processed_text = preg_replace('/(\w)-(\w)/', '$1 $2', $processed_text);
        
        // Finally: Clean up any duplicate spaces
        $processed_text = preg_replace('/\s+/', ' ', $processed_text); // Remove duplicate spaces
        $processed_text = trim($processed_text);
        
        // Get custom introduction and outro text from options
        $custom_intro = get_option('mementor_tts_text_before', '');
        $custom_outro = get_option('mementor_tts_text_after', '');

        // Check if intro/outro should be included (default to true for backward compatibility)
        $include_intro = array_key_exists('include_intro', $options) ? (bool) $options['include_intro'] : true;
        $include_outro = array_key_exists('include_outro', $options) ? (bool) $options['include_outro'] : true;

        // Add custom introduction if it exists and should be included
        if (!empty($custom_intro) && $include_intro) {
            $custom_intro = trim($custom_intro);
            $processed_text = $custom_intro . ' -- ' . $processed_text;
        }

        // Add custom outro if it exists and should be included
        if (!empty($custom_outro) && $include_outro) {
            $custom_outro = trim($custom_outro);
            $processed_text = $processed_text . ' -- ' . $custom_outro;
        }

        // Debug the processed text
        $this->log_message('Processed text length: ' . strlen($processed_text) . ' characters');
        if ($this->debug_mode) {
            $this->log_message('Processed text preview: "' . substr($processed_text, 0, 100) . (strlen($processed_text) > 100 ? '...' : '') . '"');
        }
        
        // Check if the text is empty after processing
        if (empty($processed_text)) {
            $this->log_message('ERROR: Text is empty after processing', 'error');
            $this->log_message('========== SHORTCODE AUDIO GENERATION END - ERROR: EMPTY TEXT ==========');
            return array(
                'success' => false,
                'message' => 'Empty text after processing'
            );
        }
        
        // Number normalization is handled server-side by TextNormalizationService
        // (locale-aware: converts European formats like 8,52% to Dutch/German/etc. words)

        // Apply word replacements before custom pauses
        $processed_text = $this->apply_word_replacements($processed_text);

        // Apply custom pauses to the processed text
        $processed_text = $this->apply_custom_pauses($processed_text);
        
        // Wrap text in SSML tags - only add initial break if text doesn't already start with one
        if (preg_match('/^\s*<break\s+time="[0-9.]+s"\s*\/?>/', $processed_text)) {
            $processed_text = '<speak>' . $processed_text . '</speak>';
        } else {
            $processed_text = '<speak><break time="0.65s" />' . $processed_text . '</speak>';
        }
        $this->log_message('Text wrapped in SSML tags');
        
        // If language is specified in options, use it (override the default)
        if (!empty($options['language_code'])) {
            $voice_settings['language_code'] = $options['language_code'];
            $this->log_message('Using language code from options: ' . $options['language_code']);
        }
        
        // Convert voice settings to JSON for logging
        $settings_json = function_exists('wp_json_encode') ? 
            wp_json_encode($voice_settings) : 
            json_encode($voice_settings);
            
        $this->log_message('Voice settings: ' . $settings_json);
        $this->log_message('Voice ID before API call: ' . $voice_id);
        $this->log_message('Calling SaaS API synthesize for shortcode...');

        // Generate audio using the SaaS API client
        $final_options = array_merge($voice_settings, $options);
        // Build filename for shortcode audio: shortcode-<uid>-<lang>.mp3
        $sc_lang = $final_options['language_code'] ?? 'en';
        $final_options['filename'] = $element_uid . '-' . $sc_lang . '.mp3';
        $this->log_message('Final options being sent to API: ' . wp_json_encode($final_options));
        $response = $api->synthesize($processed_text, $voice_id, $final_options);

        // Check for errors
        if (is_wp_error($response)) {
            $error_message = $response->get_error_message();
            $this->log_message('ERROR: API returned error: ' . $error_message, 'error');
            $this->log_message('========== SHORTCODE AUDIO GENERATION END - API ERROR ==========');

            return array(
                'success' => false,
                'message' => 'API call failed: ' . $error_message
            );
        }

        $this->log_message('SaaS API call completed successfully');

        // Save audio via API client (handles paid vs free plan URL storage)
        // For shortcodes we pass post_id=0
        $file_url = $api->save_audio(0, $response, $language_code);

        if (is_wp_error($file_url)) {
            $this->log_message('ERROR: Failed to save audio: ' . $file_url->get_error_message(), 'error');
            $this->log_message('========== SHORTCODE AUDIO GENERATION END - FILE SAVE ERROR ==========');
            return array(
                'success' => false,
                'message' => 'Failed to save audio file'
            );
        }

        $this->log_message('Audio file saved successfully to: ' . $file_url);

        // Store filename → URL mapping for shortcode resolver
        $sc_filename = $element_uid . '-' . $language_code . '.mp3';
        $audio_map = get_option('mementor_tts_shortcode_audio_map', array());
        $audio_map[$sc_filename] = $file_url;
        update_option('mementor_tts_shortcode_audio_map', $audio_map, false);
        $this->log_message('Stored shortcode audio mapping: ' . $sc_filename . ' => ' . $file_url);

        $this->log_message('========== SHORTCODE AUDIO GENERATION END - SUCCESS ==========');

        // Always return a consistent array structure
        return array(
            'success' => true,
            'file_url' => $file_url,
            'message' => 'Audio file generated successfully'
        );
    }


    /**
     * Get content from HTML using CSS selectors
     */
    public function get_content_from_html($html) {
        // TEST MARKER: File deployed 2025-08-21 - Version 4 - Fixed FAQ duplication in Fusion Builder
        error_log('[TTS] get_content_from_html called - TEST MARKER V4');

        // Apply "Truncate After" via the shared helper so the Weglot path and
        // this processor path behave identically.
        if (function_exists('ttswp_apply_truncate_after')) {
            $html = ttswp_apply_truncate_after($html);
        }

        // If title is being included separately, remove h1 elements
        if (get_option('mementor_tts_include_title', true) && !empty($html)) {
            // Remove all h1 elements to prevent title duplication
            $html = preg_replace('#<h1[^>]*>.*?</h1>#is', '', $html);
        }
        
        // Fusion Builder compatibility - Extract ALL text from fusion elements
        if (strpos($html, 'fusion-text') !== false || strpos($html, 'fusion-fullwidth') !== false) {
            error_log('[TTS] Fusion Builder content detected - using comprehensive extraction');
            
            // Strip the audio player first to avoid extracting its text
            $html = preg_replace('/<div[^>]*class="[^"]*mementor-tts-player[^"]*"[^>]*>.*?<\/div>/is', '', $html);
            
            // Remove script and style tags
            $html = preg_replace('/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/is', '', $html);
            $html = preg_replace('/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/is', '', $html);
            
            // If title is being included separately, remove common title elements to avoid duplication
            if (get_option('mementor_tts_include_title', true)) {
                // Remove h1 tags with common title classes
                $html = preg_replace('/<h1[^>]*class="[^"]*(?:entry-title|post-title|page-title)[^"]*"[^>]*>.*?<\/h1>/is', '', $html);
                // Remove any h1 inside entry-header
                $html = preg_replace('/<div[^>]*class="[^"]*entry-header[^"]*"[^>]*>.*?<h1[^>]*>.*?<\/h1>.*?<\/div>/is', '', $html);
                // Remove Elementor heading widgets with h1 titles
                $html = preg_replace('/<div[^>]*class="[^"]*elementor-widget-heading[^"]*"[^>]*>.*?<h1[^>]*class="[^"]*elementor-heading-title[^"]*"[^>]*>.*?<\/h1>.*?<\/div>/is', '', $html);
                // Remove Elementor theme post title widgets
                $html = preg_replace('/<div[^>]*class="[^"]*elementor-widget-theme-post-title[^"]*"[^>]*>.*?<\/div>/is', '', $html);
            }
            
            // Load HTML into DOMDocument with proper UTF-8 handling
            // IMPORTANT: Use full HTML structure to avoid LIBXML_HTML_NOIMPLIED issues with nested divs
            $dom = new DOMDocument('1.0', 'UTF-8');
            $dom->preserveWhiteSpace = false;
            $html_wrapped = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>';
            @$dom->loadHTML($html_wrapped, LIBXML_NOERROR | LIBXML_NOWARNING);

            // Create XPath object
            $xpath = new DOMXPath($dom);

            // Find all fusion-text elements first
            $fusion_text_elements = $xpath->query("//div[contains(@class, 'fusion-text')]");
            error_log('[TTS] Found ' . $fusion_text_elements->length . ' fusion-text containers');
            
            $fusion_content = '';
            $paragraph_count = 0;
            
            // Process each fusion-text container separately to maintain order
            foreach ($fusion_text_elements as $fusion_container) {
                // Get all paragraph, heading, and list elements within this container
                // Note: We don't include inline elements like span/b/i as they're already captured within their parent elements
                // Build XPath query based on settings
                $xpath_elements = array('.//p', './/h2', './/h3', './/h4', './/h5', './/h6', './/li');

                // If title is NOT included separately, include h1 elements
                if (!get_option('mementor_tts_include_title', true)) {
                    $xpath_elements[] = './/h1';
                }

                // If image captions are enabled, include figcaption elements
                if (get_option('mementor_tts_include_image_captions', 0)) {
                    $xpath_elements[] = './/figcaption';
                }

                $content_elements = $xpath->query(implode(' | ', $xpath_elements), $fusion_container);
                
                error_log('[TTS] Processing fusion-text container with ' . $content_elements->length . ' child elements');
                
                $container_texts = array();
                
                foreach ($content_elements as $element) {
                    // Skip if this element contains other block-level content elements (to avoid duplication)
                    // We only check for block-level elements, not inline elements like span/b/i
                    $has_child_blocks = $xpath->query(".//p | .//h1 | .//h2 | .//h3 | .//h4 | .//h5 | .//h6 | .//li", $element)->length > 0;
                    
                    // Skip if this is a container element that has child block elements
                    if ($has_child_blocks && !in_array($element->nodeName, ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li'])) {
                        continue;
                    }
                    
                    $text = trim($element->textContent);
                    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                    // Replace non-breaking spaces with regular spaces
                    $text = str_replace("\xC2\xA0", ' ', $text);
                    $text = str_replace("\u{00A0}", ' ', $text);
                    $text = trim(preg_replace('/\s+/', ' ', $text));
                    
                    // Only add non-empty, substantial text that we haven't seen yet in this container
                    if (!empty($text) && strlen($text) > 5) {
                        // Check if this text is already contained in existing texts (or vice versa)
                        $should_add = true;
                        $replace_index = -1;
                        
                        foreach ($container_texts as $index => $existing_text) {
                            // If new text is contained in existing text, skip it
                            if (strpos($existing_text, $text) !== false) {
                                $should_add = false;
                                break;
                            }
                            // If existing text is contained in new text, mark for replacement
                            if (strpos($text, $existing_text) !== false) {
                                $replace_index = $index;
                            }
                        }
                        
                        if ($should_add) {
                            if ($replace_index >= 0) {
                                // Replace the shorter text with the longer one
                                $container_texts[$replace_index] = $text;
                            } else if (!in_array($text, $container_texts)) {
                                // Add as new text
                                $container_texts[] = $text;
                            }
                            
                            if ($element->nodeName === 'p') {
                                $paragraph_count++;
                            }
                        }
                    }
                }
                
                // Fallback: classic-editor Fusion posts sometimes wrap body text
                // in <b>/<strong> (or leave it as loose text) inside a
                // .fusion-text container — no <p>/<h2-h6>/<li>. In that case
                // the structured sweep above finds nothing and the whole
                // container would be dropped. Use the container's textContent
                // so the body survives.
                if (empty($container_texts)) {
                    $text = trim($fusion_container->textContent);
                    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                    $text = str_replace("\xC2\xA0", ' ', $text);
                    $text = str_replace("\u{00A0}", ' ', $text);
                    $text = trim(preg_replace('/\s+/', ' ', $text));
                    if (strlen($text) > 5) {
                        $container_texts[] = $text;
                    }
                }

                // Add all texts from this container with separators
                if (!empty($container_texts)) {
                    $fusion_content .= implode(' -- ', $container_texts) . ' -- ';
                }
            }
            
            // Remove trailing separator
            $fusion_content = rtrim($fusion_content, ' -- ');
            $fusion_content = trim($fusion_content);
            
            error_log('[TTS] Total Fusion content extracted: ' . strlen($fusion_content) . ' chars from ' . $paragraph_count . ' paragraphs');
            
            // If we got content, return it
            if (!empty($fusion_content)) {
                return apply_filters('mementor_tts_extract_content_from_html', $fusion_content, $html);
            }
            
            // Fallback: If no content found with the structured approach, try a more aggressive extraction
            error_log('[TTS] No content found with structured approach, trying aggressive extraction');
            
            // Get ALL text nodes within fusion elements
            $all_text_nodes = $xpath->query("//div[contains(@class, 'fusion-text')]//text()[normalize-space(.) != '']");
            
            $fallback_content = '';
            $seen_texts = array();
            
            foreach ($all_text_nodes as $node) {
                $text = trim($node->textContent);
                $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                // Replace non-breaking spaces with regular spaces
                $text = str_replace("\xC2\xA0", ' ', $text);
                $text = str_replace("\u{00A0}", ' ', $text);
                $text = trim(preg_replace('/\s+/', ' ', $text));
                
                if (!empty($text) && strlen($text) > 5 && !in_array($text, $seen_texts)) {
                    $fallback_content .= $text . ' ';
                    $seen_texts[] = $text;
                }
            }
            
            $fallback_content = trim($fallback_content);
            error_log('[TTS] Fallback extraction got: ' . strlen($fallback_content) . ' chars');
            
            return apply_filters('mementor_tts_extract_content_from_html', $fallback_content, $html);
        }
        
        // Skip normal processing entirely for Fusion Builder
        // and return early with our extracted content
        
        // Load HTML into DOMDocument with proper UTF-8 handling
        // IMPORTANT: Use full HTML structure to avoid LIBXML_HTML_NOIMPLIED issues with nested divs
        $dom = new DOMDocument('1.0', 'UTF-8');
        $dom->preserveWhiteSpace = false;
        $html_wrapped = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>';
        @$dom->loadHTML($html_wrapped, LIBXML_NOERROR | LIBXML_NOWARNING);

        // Debug: Check if .no-audio elements exist in the HTML
        if (strpos($html, 'no-audio') !== false) {
            error_log('[TTS] HTML contains "no-audio" class in source');
        }
        
        // Create new DOMXPath object
        $xpath = new DOMXPath($dom);
        
        // Get content selectors from settings
        $content_selectors_setting = get_option('mementor_tts_include_selectors', '.post-content, .wp-block-post-content, .page-content');
        $content_selectors = array_map('trim', explode(',', $content_selectors_setting));
        
        // Check if this is Elementor content and adjust selectors accordingly
        if (strpos($html, 'elementor') !== false && strpos($html, 'data-elementor-type') !== false) {
            error_log('[TTS] Detected Elementor content, using broader selectors');
            // For Elementor, use the main content area selector
            $content_selectors = array('.elementor', '[data-elementor-type]', '.elementor-section-wrap');
        }
        
        error_log('[TTS] Content selectors: ' . implode(', ', $content_selectors));
        
        // Get blocked selectors from settings
        $blocked_selectors_setting = get_option('mementor_tts_exclude_selectors', '#ad-wrapper, .sidebar, .comments, .advertisement, .mementor-tts-player-container, .mementor-tts-player, .mementor-tts-player-wrapper');
        $blocked_selectors = array_map('trim', explode(',', $blocked_selectors_setting));

        // If image captions are disabled, exclude all image-related elements (img, figure, figcaption)
        if (!get_option('mementor_tts_include_image_captions', 0)) {
            $blocked_selectors[] = 'img';
            $blocked_selectors[] = 'figure';
            $blocked_selectors[] = 'figcaption';
            $blocked_selectors[] = '.wp-block-image';
            $blocked_selectors[] = '.wp-caption';
        }
        
        // IMPORTANT: Also check for ID-based selectors as fallback for class selectors
        // If .no-audio is in the list, also add #no-audio
        if (in_array('.no-audio', $blocked_selectors) && !in_array('#no-audio', $blocked_selectors)) {
            $blocked_selectors[] = '#no-audio';
            error_log('[TTS] Added #no-audio ID selector as fallback for .no-audio class');
        }
        
        error_log('[TTS] Blocked selectors: ' . implode(', ', $blocked_selectors));
        
        // If title is already being included separately, exclude common title selectors from content extraction
        if (get_option('mementor_tts_include_title', true)) {
            $title_selectors = array(
                'h1.entry-title', 
                '.entry-title', 
                'h1.post-title', 
                '.post-title', 
                '.entry-header h1', 
                '.page-title', 
                'h1.page-title',
                // Elementor specific title selectors
                '.elementor-widget-heading h1',
                'h1.elementor-heading-title',
                '.elementor-widget-theme-post-title',
                '.elementor-widget-container h1.elementor-heading-title'
            );
            $blocked_selectors = array_merge($blocked_selectors, $title_selectors);
        }
        
        // Build XPath query for content selectors
        $xpath_query_include = implode(' | ', array_map(function($selector) {
            $selector = trim($selector);
            // Handle class selectors (.classname)
            if (substr($selector, 0, 1) === '.') {
                return "//*[contains(concat(' ', normalize-space(@class), ' '), ' " . str_replace('.', '', $selector) . " ')]";
            }
            // Handle attribute selectors [attribute] or [attribute=value]
            else if (substr($selector, 0, 1) === '[' && substr($selector, -1) === ']') {
                $attr_content = substr($selector, 1, -1);
                if (strpos($attr_content, '=') !== false) {
                    list($attr, $value) = explode('=', $attr_content, 2);
                    $attr = trim($attr);
                    $value = trim($value);
                    $value = trim($value, "'" . chr(34)); // unngår bokstavelig " i kildekoden
                    return sprintf("//*[@%s=%s]", $attr, $this->xpathLiteral($value));                    
                } else {
                    return "//*[@" . $attr_content . "]";
                }
            }
            // Handle element selectors
            else {
                return "//" . $selector;
            }
        }, $content_selectors));
        
        // Find all content nodes
        error_log('[TTS] XPath query for content: ' . $xpath_query_include);
        $content_nodes = $xpath->query($xpath_query_include);
        error_log('[TTS] Found ' . $content_nodes->length . ' content nodes');
        
        // Create a DOMNodeList of nodes to exclude
        $nodes_to_exclude = array();
        
        // Build XPath query for each blocked selector and collect nodes to exclude
        foreach ($blocked_selectors as $selector) {
            $selector = trim($selector);
            if (!empty($selector)) {
                $xpath_query_exclude = '';
                
                // Handle element selectors, class selectors (.classname), and ID selectors (#idname)
                if (substr($selector, 0, 1) === '.') {
                    // Use proper XPath for class matching with multiple classes
                    $class_name = str_replace('.', '', $selector);
                    $xpath_query_exclude = "//*[contains(concat(' ', normalize-space(@class), ' '), ' " . $class_name . " ')]";
                    error_log('[TTS] Blocked selector XPath for ' . $selector . ': ' . $xpath_query_exclude);
                } elseif (substr($selector, 0, 1) === '#') {
                    $id_name = str_replace('#', '', $selector);
                    $xpath_query_exclude = "//*[@id='" . $id_name . "']";
                    error_log('[TTS] Blocked selector XPath for ' . $selector . ': ' . $xpath_query_exclude);
                } else {
                    $xpath_query_exclude = "//" . $selector;
                }
                
                $excluded_nodes = $xpath->query($xpath_query_exclude);
                error_log('[TTS] Found ' . $excluded_nodes->length . ' nodes to exclude for selector: ' . $selector);
                
                // Additional debug for .no-audio
                if ($selector === '.no-audio' && $excluded_nodes->length === 0) {
                    // Try a simpler query to debug
                    $simple_query = $xpath->query("//*[@class]");
                    error_log('[TTS] Total elements with class attribute: ' . $simple_query->length);
                    foreach ($simple_query as $elem) {
                        $classes = $elem->getAttribute('class');
                        if (strpos($classes, 'no-audio') !== false) {
                            error_log('[TTS] Found element with no-audio in classes: ' . $classes);
                        }
                    }
                }
                
                foreach ($excluded_nodes as $node) {
                    $nodes_to_exclude[] = $node;
                    error_log('[TTS] Excluding node with classes: ' . $node->getAttribute('class'));
                }
            }
        }
        
        $content = '';
        $processed_nodes = array();
        
        foreach ($content_nodes as $node) {
            // Skip this node if it's in the exclude list or is a child of an excluded node
            $skip = false;
            foreach ($nodes_to_exclude as $excluded_node) {
                if ($node === $excluded_node || $this->is_descendant($node, $excluded_node)) {
                    $skip = true;
                    error_log('[TTS] Skipping node because it is or is descendant of excluded node');
                    break;
                }
            }
            
            // Skip if this node is a descendant of an already processed node
            foreach ($processed_nodes as $processed_node) {
                if ($this->is_descendant($node, $processed_node)) {
                    $skip = true;
                    break;
                }
            }
            
            // Skip if this node has a parent that's also in the content_nodes list
            foreach ($content_nodes as $other_node) {
                if ($other_node !== $node && $this->is_descendant($node, $other_node)) {
                    $skip = true;
                    break;
                }
            }
            
            if (!$skip) {
                // Extract text more carefully to respect excluded descendants
                $extracted_text = $this->extract_text_respecting_exclusions($node, $nodes_to_exclude);
                if (!empty($extracted_text)) {
                    $content .= $extracted_text . ' ';
                    $processed_nodes[] = $node;
                }
            }
        }
        
        $content = trim($content);

        // Check if we found a main container (entry-content, post-content, etc.) or only specific elements
        $main_container_selectors = array('.main-content', '.post-content', '.wp-block-post-content', '.page-content', '.entry-content');
        $found_main_container = false;
        foreach ($processed_nodes as $pnode) {
            if ($pnode->hasAttribute('class')) {
                $classes = ' ' . $pnode->getAttribute('class') . ' ';
                foreach ($main_container_selectors as $selector) {
                    $class_name = str_replace('.', '', $selector);
                    if (strpos($classes, ' ' . $class_name . ' ') !== false) {
                        $found_main_container = true;
                        break 2;
                    }
                }
            }
        }

        // Fallback: If no content found OR if only specific elements matched (not main container),
        // also extract paragraphs/headings to capture sibling content
        $run_fallback = empty($content) || !$found_main_container;

        if ($run_fallback) {
            error_log('[TTS] Running fallback extraction - empty content: ' . (empty($content) ? 'YES' : 'NO') . ', found main container: ' . ($found_main_container ? 'YES' : 'NO'));
            error_log('[TTS] HTML length for fallback: ' . strlen($html));
            
            // Log more of the HTML to see what we're working with
            error_log('[TTS] HTML preview (first 1500 chars): ' . substr($html, 0, 1500) . '...');
            
            // Check if we have the full structure
            if (strpos($html, '<html') === false && strpos($html, '<body') === false) {
                error_log('[TTS] WARNING: HTML appears to be a fragment, not a full document');
            }
            
            // Check if the HTML contains any no-audio elements
            if (strpos($html, 'no-audio') !== false) {
                error_log('[TTS] WARNING: HTML contains "no-audio" class but no nodes were found by XPath!');
                // Try to extract the no-audio content with regex to debug
                preg_match_all('/<[^>]+class="[^"]*no-audio[^"]*"[^>]*>/', $html, $matches);
                if (!empty($matches[0])) {
                    error_log('[TTS] Found ' . count($matches[0]) . ' elements with no-audio class via regex');
                    foreach ($matches[0] as $match) {
                        error_log('[TTS] no-audio element: ' . $match);
                    }
                }
            }
            
            // Extract all paragraphs, headings, and list items that aren't inside excluded elements
            $fallback_elements = array('//p', '//h1', '//h2', '//h3', '//h4', '//h5', '//h6', '//li');

            // If image captions are enabled, include figcaption elements
            if (get_option('mementor_tts_include_image_captions', 0)) {
                $fallback_elements[] = '//figcaption';
            }

            $fallback_query = implode(' | ', $fallback_elements);
            $fallback_nodes = $xpath->query($fallback_query);
            
            $fallback_content = '';
            $seen_fallback_texts = array(); // Track seen texts to avoid duplicates

            // If we already have some content (e.g., from su-note), add it to seen texts to avoid duplication
            if (!empty($content)) {
                $seen_fallback_texts[] = preg_replace('/\s+/', ' ', trim($content));
            }

            error_log('[TTS] Fallback found ' . $fallback_nodes->length . ' nodes to process');
            
            foreach ($fallback_nodes as $node) {
                // Debug: Log what node we're processing
                error_log('[TTS] Processing fallback node: ' . $node->nodeName . ' with text preview: ' . substr(trim($node->textContent), 0, 50));

                // Skip if this node is inside an excluded element
                $skip = false;
                foreach ($nodes_to_exclude as $excluded_node) {
                    if ($node === $excluded_node || $this->is_descendant($node, $excluded_node)) {
                        $skip = true;
                        break;
                    }
                }
                
                // CRITICAL: Also check if this node or any parent has excluded classes, IDs, or tag names
                if (!$skip) {
                    $checkNode = $node;
                    while ($checkNode && $checkNode->nodeType === XML_ELEMENT_NODE) {
                        // Check for excluded element tag names
                        $tagName = strtolower($checkNode->nodeName);
                        foreach ($blocked_selectors as $selector) {
                            // If selector doesn't start with . or #, it's a tag name
                            if (strpos($selector, '.') !== 0 && strpos($selector, '#') !== 0) {
                                if ($tagName === strtolower(trim($selector))) {
                                    error_log('[TTS] Fallback: Skipping node because it is or has ancestor: ' . $selector);
                                    $skip = true;
                                    break 2;
                                }
                            }
                        }

                        // Check for excluded classes
                        if ($checkNode->hasAttribute('class')) {
                            $classes = $checkNode->getAttribute('class');
                            // Check each blocked selector
                            foreach ($blocked_selectors as $selector) {
                                if (strpos($selector, '.') === 0) {
                                    $class_name = substr($selector, 1);
                                    if (strpos(' ' . $classes . ' ', ' ' . $class_name . ' ') !== false) {
                                        error_log('[TTS] Fallback: Skipping node because ancestor has class: ' . $class_name);
                                        $skip = true;
                                        break 2;
                                    }
                                }
                            }
                        }

                        // Check for excluded IDs
                        if ($checkNode->hasAttribute('id')) {
                            $id = $checkNode->getAttribute('id');
                            foreach ($blocked_selectors as $selector) {
                                if (strpos($selector, '#') === 0) {
                                    $id_name = substr($selector, 1);
                                    if ($id === $id_name) {
                                        error_log('[TTS] Fallback: Skipping node because ancestor has ID: ' . $id_name);
                                        $skip = true;
                                        break 2;
                                    }
                                }
                            }
                        }

                        $checkNode = $checkNode->parentNode;
                    }
                }
                
                if (!$skip) {
                    // Extract text but exclude text from any excluded descendant elements
                    $text = $this->extract_text_respecting_exclusions($node, $nodes_to_exclude);
                    $text = trim($text);
                    
                    // Normalize the text for comparison
                    $normalized_text = preg_replace('/\s+/', ' ', $text);
                    $normalized_text = trim($normalized_text);
                    
                    // Check if we've seen this text (or very similar) before
                    $is_duplicate = false;
                    foreach ($seen_fallback_texts as $seen_text) {
                        // Check for exact match or if one contains the other
                        if ($seen_text === $normalized_text || 
                            strpos($seen_text, $normalized_text) !== false || 
                            strpos($normalized_text, $seen_text) !== false) {
                            $is_duplicate = true;
                            break;
                        }
                    }
                    
                    // Only add non-empty text that we haven't seen yet
                    if (!empty($text) && !$is_duplicate) {
                        $fallback_content .= $text . ' -- ';
                        $seen_fallback_texts[] = $normalized_text; // Remember normalized text
                        error_log('[TTS] Added text (' . strlen($text) . ' chars): ' . substr($text, 0, 50) . '...');
                    } else if ($is_duplicate) {
                        error_log('[TTS] Skipped duplicate text: ' . substr($text, 0, 50) . '...');
                    }
                }
            }
            
            $fallback_content = rtrim($fallback_content, ' -- ');
            error_log('[TTS] Fallback extraction found: ' . strlen($fallback_content) . ' chars');

            // Merge fallback content with any existing content (e.g., from su-note)
            if (!empty($content) && !empty($fallback_content)) {
                // Add existing content to seen texts to avoid duplication in merge
                $existing_normalized = preg_replace('/\s+/', ' ', trim($content));

                // Only add fallback if it's not already contained in existing content
                if (strpos($existing_normalized, preg_replace('/\s+/', ' ', $fallback_content)) === false) {
                    // Prepend existing content (su-note) before fallback content (rest of article)
                    $content = $content . ' -- ' . $fallback_content;
                    error_log('[TTS] Merged existing content with fallback content');
                }
            } else if (!empty($fallback_content)) {
                $content = $fallback_content;
            }
        }
        
        // Apply filter to allow custom content extraction (e.g., for Fusion Builder)
        $content = apply_filters('mementor_tts_extract_content_from_html', $content, $html);
        
        return $content;
    }

    /**
     * Generate a valid XPath literal
     */
    private function xpathLiteral($value) {
        if (strpos($value, "'") === false) {
            // Hvis ingen apostrof, bruk enkeltfnutter
            return "'" . $value . "'";
        }
        if (strpos($value, '"') === false) {
            // Hvis ingen dobbeltfnutter, bruk dem
            return '"' . $value . '"';
        }
        // Hvis begge finnes, bygg concat()
        $parts = explode("'", $value);
        $escaped = array();
        foreach ($parts as $i => $part) {
            if ($i > 0) {
                $escaped[] = '"' . "'" . '"';
            }
            if ($part !== '') {
                $escaped[] = "'" . $part . "'";
            }
        }
        return 'concat(' . implode(',', $escaped) . ')';
    }    

    /**
     * Check if a node is a descendant of another node
     */
    private function is_descendant($node, $ancestor) {
        $parent = $node->parentNode;
        while ($parent !== null) {
            if ($parent === $ancestor) {
                return true;
            }
            $parent = $parent->parentNode;
        }
        return false;
    }
    
    /**
     * Extract text from a node while respecting excluded nodes
     */
    private function extract_text_respecting_exclusions($node, $nodes_to_exclude) {
        $text = '';
        
        // If the node itself is excluded, return empty
        foreach ($nodes_to_exclude as $excluded_node) {
            if ($node === $excluded_node) {
                return '';
            }
        }
        
        // Process child nodes
        if ($node->hasChildNodes()) {
            foreach ($node->childNodes as $child) {
                // Check if this child or any of its ancestors in excluded list
                $skip_child = false;
                foreach ($nodes_to_exclude as $excluded_node) {
                    if ($child === $excluded_node || $this->is_descendant($child, $excluded_node)) {
                        $skip_child = true;
                        error_log('[TTS] Skipping child node in extract_text because it or its descendant is excluded');
                        break;
                    }
                }
                
                if (!$skip_child) {
                    if ($child->nodeType === XML_TEXT_NODE) {
                        // Get text content and decode HTML entities
                        $node_text = $child->textContent;
                        // Decode HTML entities including &nbsp;
                        $node_text = html_entity_decode($node_text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                        // Replace non-breaking spaces with regular spaces
                        $node_text = str_replace("\xC2\xA0", ' ', $node_text);
                        $node_text = str_replace("\u{00A0}", ' ', $node_text);
                        $text .= $node_text . ' ';
                    } else if ($child->nodeType === XML_ELEMENT_NODE) {
                        // Recursively extract from element nodes
                        $child_text = $this->extract_text_respecting_exclusions($child, $nodes_to_exclude);
                        if (!empty($child_text)) {
                            $text .= $child_text . ' ';
                        }
                    }
                }
            }
        } else {
            // Leaf node - just get its text
            $text = $node->textContent;
            // Decode HTML entities including &nbsp;
            $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
            // Replace non-breaking spaces with regular spaces
            $text = str_replace("\xC2\xA0", ' ', $text);
            $text = str_replace("\u{00A0}", ' ', $text);
        }
        
        return trim($text);
    }

    /**
     * Upload file to Amazon S3
     *
     * @param string $file_path Local file path
     * @param string $filename Filename to use in S3
     * @return array|WP_Error Result array with 'url' and 'key' on success, WP_Error on failure
     */
    private function upload_to_s3($file_path, $filename) {
        // Include S3 handler class
        if (!class_exists('Mementor_TTS_S3_Handler')) {
            require_once plugin_dir_path(__FILE__) . 'class-mementor-tts-s3-handler.php';
        }

        // Create S3 handler instance (loads config from options)
        $s3_handler = new Mementor_TTS_S3_Handler();

        // Check if S3 is configured
        if (!$s3_handler->is_configured()) {
            return new WP_Error(
                's3_not_configured',
                __('S3 is not properly configured. Please check your settings.', 'text-to-speech-tts')
            );
        }

        // Upload file
        $result = $s3_handler->upload_file($file_path, $filename, 'audio/mpeg');

        return $result;
    }
} // End class