# metasync/2.6.18/otto/Otto_html_class.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version 2.6.18. 3,383 lines.

- Page: https://pluginprobe.com/plugins/metasync/2.6.18/code/otto/Otto_html_class.php
- Raw: https://pluginprobe.com/plugins/metasync/2.6.18/raw/otto/Otto_html_class.php
- Modified: 2026-07-21T00:29:48+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/metasync/2.6.18/code/otto/Otto_html_class.php#L10-L20`.

```php
<?php
// If this file is called directly, abort.
if (!defined('ABSPATH')) {
	exit;
}


# uses the simple html dom library
use simplehtmldom\HtmlWeb;
use simplehtmldom\HtmlDocument;

/**
 * This Class Handles  
 */

Class Metasync_otto_html{

    # html dom
    private $dom;

    # the file path to save to
    private $html_file;

    # uuid of site
    private $site_uuid;

    # the otto endpoint url
    private $otto_end_point;

    # PERFORMANCE OPTIMIZATION: Defer DOM reload until final save
    # Reduces 6-10 serialize/deserialize cycles to just 1
    private $deferred_reload = false;

    # PERFORMANCE OPTIMIZATION: Cache commonly accessed DOM elements
    # Eliminates 8-10 full DOM traversals per page
    private $cached_elements = [];

    /**
     * Escape bare < characters in text content that SimpleHtmlDom misinterprets as HTML tags.
     * Example: "<4 microns" gets parsed as a tag, corrupting the DOM and breaking page layout.
     * Only targets < followed by digits (never valid HTML tag starts).
     * Protects <script> and <style> blocks where < appears in code.
     */
    private function sanitize_text_less_than($html) {
        $protected = [];
        $html = preg_replace_callback(
            '/<(script|style)([\s>])(.*?)<\/\1>/si',
            function($match) use (&$protected) {
                $placeholder = '<!--METASYNC_PROTECTED_' . count($protected) . '-->';
                $protected[$placeholder] = $match[0];
                return $placeholder;
            },
            $html
        );

        $html = preg_replace('/<(?=\d)/', '&lt;', $html);

        if (!empty($protected)) {
            $html = str_replace(array_keys($protected), array_values($protected), $html);
        }

        return $html;
    }

    /**
     * Fix malformed self-closing non-void HTML tags before DOM parsing.
     *
     * Some themes (e.g. Bootstrap Component Blox) output malformed tags like:
     *   <ul/ class="sub-menu dropdown-menu" />
     * SimpleHtmlDom interprets <ul/ as a void element, strips all attributes,
     * and re-serializes as bare <ul> — breaking Bootstrap dropdown menus.
     *
     * This normalizes such tags into proper opening tags:
     *   <ul class="sub-menu dropdown-menu">
     *
     * @param string $html Raw HTML before DOM parsing.
     * @return string HTML with malformed self-closing non-void tags fixed.
     */
    private function fix_malformed_self_closing_tags($html) {
        # Non-void elements that must never be self-closing
        $tags = 'ul|ol|li|div|span|a|p|h[1-6]|section|header|footer|nav|main|article|aside'
              . '|table|thead|tbody|tfoot|tr|td|th|caption|colgroup'
              . '|form|fieldset|legend|label|select|option|optgroup|textarea|button'
              . '|figure|figcaption|details|summary|blockquote|pre|code|dl|dt|dd'
              . '|video|audio|canvas|iframe|object|embed|map|picture|source';

        # Pattern 1: <tag/ attr...> or <tag/ attr... /> — slash after tag name
        # e.g. <ul/ class="sub-menu dropdown-menu" />
        $html = preg_replace(
            '#<(' . $tags . ')/(\s[^>]*?)\s*/>#si',
            '<$1$2>',
            $html
        );

        # Pattern 2: <tag/ attr...> without trailing slash
        # e.g. <ul/ class="sub-menu">
        $html = preg_replace(
            '#<(' . $tags . ')/(\s)#si',
            '<$1$2',
            $html
        );

        # Pattern 3: <tag attr... /> — proper self-closing syntax on non-void element
        # e.g. <ul class="sub-menu" />
        $html = preg_replace(
            '#<(' . $tags . ')(\s[^>]*?)\s*/>#si',
            '<$1$2>',
            $html
        );

        return $html;
    }

    /**
     * Restore case-sensitive SVG and HTML5 attributes that SimpleHtmlDom lowercases.
     * The DOM parser's $lowercase=true flag (required for find() queries) lowercases
     * all attribute names, breaking case-sensitive attributes like viewBox.
     * Applied on final HTML output after all DOM processing is complete.
     */
    private function restore_case_sensitive_attributes($html) {
        static $map = [
            ' viewbox='             => ' viewBox=',
            ' preserveaspectratio=' => ' preserveAspectRatio=',
            ' controlslist='       => ' controlsList=',
        ];
        return str_ireplace(array_keys($map), array_values($map), $html);
    }

    /**
     * / Capture ALL <style> blocks before DOM processing.
     *
     * SimpleHtmlDom can lose or corrupt <style> blocks during parse/serialize,
     * especially those without an id attribute (common in custom themes).
     * This captures every <style> block so we can re-inject any that are lost.
     *
     * @param string $html Raw HTML before DOM parsing.
     * @return array Keyed by id (or content hash for anonymous styles) → full <style> tag.
     */
    private function capture_style_blocks($html) {
        $blocks = [];
        if (preg_match_all('/<style[^>]*>.*?<\/style>/si', $html, $matches)) {
            foreach ($matches[0] as $style_tag) {
                if (preg_match('/id=["\']([^"\']+)["\']/', $style_tag, $id_match)) {
                    $key = $id_match[1];
                } else {
                    $key = 'anon_' . md5($style_tag);
                }
                $blocks[$key] = $style_tag;
            }
        }
        return $blocks;
    }

    /**
     * / Re-inject any <style> blocks lost during DOM processing.
     *
     * Compares the captured blocks against the final HTML and re-injects any
     * that disappeared. Named blocks (with id) are checked by id; anonymous
     * blocks are checked by exact content match.
     *
     * @param string $html  Processed HTML after DOM modifications.
     * @param array  $original_blocks Blocks returned by capture_style_blocks().
     * @return string HTML with lost style blocks restored.
     */
    private function restore_lost_style_blocks($html, $original_blocks) {
        if (empty($original_blocks)) {
            return $html;
        }

        foreach ($original_blocks as $key => $style_tag) {
            if (strpos($key, 'anon_') === 0) {
                # Anonymous block — check if exact content is still present
                if (strpos($html, $style_tag) === false) {
                    $html = str_replace('</head>', $style_tag . "\n" . '</head>', $html);
                }
            } else {
                # Named block — check if its id is still in the document
                if (strpos($html, 'id="' . $key . '"') === false
                    && strpos($html, "id='" . $key . "'") === false) {
                    $html = str_replace('</head>', $style_tag . "\n" . '</head>', $html);
                }
            }
        }

        return $html;
    }

    /**
     * Protect entity-encoded `srcdoc` attribute values before DOM parsing.
     *
     * Lazy-loaded YouTube/video facades embed a full HTML document inside an
     * iframe's `srcdoc` attribute, entity-encoded:
     *   <iframe srcdoc="&lt;style&gt;*{overflow:hidden}...&lt;/style&gt;&lt;a&gt;..."></iframe>
     * SimpleHtmlDom decodes that value and re-emits the inner <style>/<a>/<img>
     * as REAL nodes hoisted into the document <head>. The facade's global CSS
     * (`*{overflow:hidden}`, `img,span{position:absolute;width:100%;...}`) then
     * nukes the entire page layout — the page renders blank below the header even
     * though all body content is still present (so the element-loss guard can't
     * see it). We swap each srcdoc value for an opaque token before parsing and
     * restore it verbatim afterward, so SimpleHtmlDom never touches it.
     *
     * @param string $html        Raw HTML before DOM parsing.
     * @param array  $store        (out) token => original encoded value.
     * @return string HTML with srcdoc values tokenized.
     */
    private function protect_srcdoc_attributes($html, &$store) {
        $store = array();
        $index = 0;
        return preg_replace_callback(
            '/\bsrcdoc\s*=\s*(["\'])(.*?)\1/is',
            function ($m) use (&$store, &$index) {
                $token = '__METASYNC_SRCDOC_' . ($index++) . '__';
                $store[$token] = $m[2];
                return 'srcdoc="' . $token . '"';
            },
            $html
        );
    }

    /**
     * Restore srcdoc values tokenized by protect_srcdoc_attributes().
     * Tokens are unique and never altered by SimpleHtmlDom, so a plain
     * str_replace on the final output is safe.
     *
     * @param string $html  Processed HTML.
     * @param array  $store Token map from protect_srcdoc_attributes().
     * @return string
     */
    private function restore_srcdoc_attributes($html, $store) {
        if (empty($store)) {
            return $html;
        }
        return str_replace(array_keys($store), array_values($store), $html);
    }

    /**
     * Safety net — strip any lazy-video FACADE <style> that leaked into the
     * document as a real stylesheet.
     *
     * Lazy YouTube/video facades carry this exact CSS reset inside their iframe
     * srcdoc:  *{...overflow:hidden}  html,body{height:100%}  img,span{position:absolute;width:100%;...}
     * If anything (SimpleHtmlDom attribute-decode, capture/restore_lost_style_blocks)
     * hoists it out of the srcdoc into a real <style>, it absolutely-positions every
     * image and span on the page and clips all overflow — rendering the page blank
     * below the header. No legitimate global stylesheet ever does this to img,span,
     * so removing such a block is safe.
     *
     * Must run AFTER srcdoc values are tokenized (protect_srcdoc_attributes) so the
     * encoded copy still living inside the iframe attribute is never matched.
     *
     * @param string $html
     * @return string
     */
    /**
     * Crash-free guard for full-document string transforms.
     *
     * PCRE operations (preg_replace / preg_replace_callback) return NULL when they
     * hit a limit — most notably "JIT stack limit exhausted" on Divi/page-builder
     * pages whose large inline <style> blocks defeat tempered-greedy patterns.
     * Historically that NULL propagated through the rest of the pipeline, so
     * process_html_directly() returned empty HTML, the render strategy's 50%-size
     * sanity check rejected it, and the ORIGINAL un-optimised page was served —
     * OTTO silently did nothing.
     *
     * This guard keeps the last-good HTML whenever a transform yields NULL (or an
     * unexpectedly empty string), so a single failing regex can no longer discard
     * every OTTO optimisation. It is defence-in-depth: the specific pattern that
     * triggered (strip_hoisted_facade_styles) has been rewritten to be
     * non-backtracking, but this guard still protects the rest of the pipeline.
     *
     * @param string|null $new   Result of the transform.
     * @param string      $prev  HTML before the transform (fallback value).
     * @param string      $where Transform name, for the log line.
     * @return string
     */
    private function otto_guard_html($new, $prev, $where = '') {
        if ($new === null || (is_string($new) && $new === '' && $prev !== '')) {
            # A full-document transform ($where) returned NULL (e.g. a PCRE
            # limit such as "JIT stack limit exhausted"). Keep the last-good
            # HTML so one failed step can't discard every OTTO optimisation.
            # Intentionally silent — not logged, to keep customer logs clean.
            return $prev;
        }
        return $new;
    }

    private function strip_hoisted_facade_styles($html) {
        # Non-backtracking implementation.
        #
        # The previous single-regex approach used two tempered-greedy segments
        # ((?:(?!</style>).)*?) to keep the match inside one <style> block. That
        # runs a negative lookahead for every character, and on Divi/page-builder
        # pages whose inline <style> blocks are tens of KB it overflows the PCRE
        # JIT stack ("JIT stack limit exhausted"). preg_replace() then returns
        # NULL, which discarded the entire OTTO-modified document.
        #
        # Instead, isolate each <style>…</style> block with a single lazy .*?
        # (cheap, no per-char lookahead) and test only that bounded block for the
        # facade signature. The signature check runs on one small block at a time,
        # so neither pattern can strain the JIT stack.
        if (!is_string($html) || stripos($html, '<style') === false) {
            return $html;
        }

        $out = preg_replace_callback(
            '#<style\b[^>]*>.*?</style>#is',
            static function ($m) {
                # Facade reset: "img,span{ … position:absolute … }" inside this block.
                if (preg_match('#\bimg\s*,\s*span\s*\{[^}]*position\s*:\s*absolute[^}]*\}#is', $m[0])) {
                    return '';
                }
                return $m[0];
            },
            $html
        );

        # Belt-and-suspenders: if PCRE still fails for any reason, keep the input.
        return $out === null ? $html : $out;
    }

    /**
     * Apply OTTO's canonical via string replacement (reliable fallback).
     *
     * Why the DOM path fails: do_header_replacements() sets the canonical with a
     * DOM attribute edit ($link->href = …). That edit works in isolation, but
     * insert_header_html() runs *earlier* in the same pipeline and reassigns
     * $head->outertext to a raw string (to inject OTTO's schema). In SimpleHtmlDom,
     * assigning ->outertext freezes that node — <head> now serializes as that
     * literal string and no longer re-renders from its child node tree. The
     * canonical <link> is captured inside that frozen string with its OLD href, so
     * the later $link->href edit is never reflected in the output and the SEO
     * plugin's (Yoast/Rank Math/AIOSEO) canonical always wins. This is the same
     * head-freeze that forced title/meta onto string-replacement fallbacks;
     * canonical was the one case that never got one.
     *
     * This pass runs on the final serialized HTML (after the freeze), so it is
     * immune to the ordering problem. It guarantees exactly one canonical —
     * OTTO's: it removes every existing <link rel="canonical"> and inserts OTTO's
     * recommended value (marked data-otto="true") right after <head>. Manual
     * canonicals (_metasync_canonical_url / meta_canonical) still take priority,
     * matching the DOM path's protection.
     *
     * @param string $html             Serialized HTML.
     * @param array  $replacement_data OTTO suggestions.
     * @return string
     */
    private function apply_canonical_via_string($html, $replacement_data) {
        if (!is_string($html) || empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])) {
            return $html;
        }

        # Find OTTO's canonical recommendation.
        $canonical = '';
        foreach ($replacement_data['header_replacements'] as $item) {
            if (($item['type'] ?? '') === 'link' && ($item['rel'] ?? '') === 'canonical') {
                $canonical = $item['recommended_value'] ?? $item['value'] ?? '';
                break;
            }
        }
        if (empty($canonical) || !is_string($canonical)) {
            return $html;
        }

        # Validate OTTO's own suggestion too: if the platform payload
        # carries a corrupted/non-URL canonical, leave the document untouched.
        $canonical = Metasync_Canonical_Sanitizer::sanitize($canonical);
        if ($canonical === '') {
            return $html;
        }

        # Respect a manually-set canonical (same protection as the DOM path).
        # Validated: a legacy row corrupted to "Array" must not count
        # as a manual canonical — that would suppress OTTO's correct value.
        if (function_exists('is_singular') && is_singular()) {
            $post_id = function_exists('get_the_ID') ? get_the_ID() : 0;
            if ($post_id) {
                $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true));
                if ($custom === '') {
                    $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true));
                }
                if ($custom !== '') {
                    return $html; # manual canonical wins — leave the document untouched
                }
            }
        }

        # Only proceed if there is a <head> to place the tag in (never end up with zero canonical).
        if (!preg_match('#<head\b[^>]*>#i', $html)) {
            return $html;
        }

        $tag = '<link rel="canonical" href="' . htmlspecialchars($canonical, ENT_QUOTES, 'UTF-8') . '" data-otto="true" />';

        # Remove every existing canonical link (bounded per-tag pattern; null-safe).
        $stripped = preg_replace('#<link\b[^>]*\brel=(["\'])canonical\1[^>]*>\s*#i', '', $html);
        if (is_string($stripped)) {
            $html = $stripped;
        }

        # Insert OTTO's canonical right after <head> (callback avoids $/\ interpolation from the URL).
        $inserted = preg_replace_callback('#(<head\b[^>]*>)#i', function ($m) use ($tag) {
            return $m[1] . "\n" . $tag;
        }, $html, 1);
        if (is_string($inserted)) {
            $html = $inserted;
        }

        return $html;
    }

    /**
     * Keep the charset declaration within the first 1024 bytes of <head>.
     *
     * OTTO injects meta tags + JSON-LD schema at the top of <head>, which can push
     * the theme's <meta charset="utf-8"> past the 1024-byte limit that browsers
     * enforce for in-document charset detection (HTML spec). When that happens —
     * and a cached/CDN response is served without an HTTP charset header — the
     * document falls back to the locale encoding (Windows-1252). External
     * stylesheets that declare no @charset of their own (e.g. a theme rule like
     * `content:"\2713"` written as a raw UTF-8 ✓) then inherit that wrong encoding
     * and render as mojibake ("âœ"" instead of "✓").
     *
     * We guarantee a <meta charset="UTF-8"> as the first child of <head> whenever
     * one isn't already present within the first 1024 bytes. Harmless when a valid
     * early charset already exists (we skip), and a duplicate later declaration is
     * ignored by the browser (first one wins).
     *
     * @param string $html
     * @return string
     */
    private function ensure_early_charset_meta($html) {
        if (!preg_match('/<head\b[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) {
            return $html;
        }
        $inner_start = $m[0][1] + strlen($m[0][0]);

        # Already declared early enough? Leave it alone (also covers AMP, which
        # requires charset as the first child).
        $window = substr($html, $inner_start, 1024);
        if (preg_match('/<meta[^>]*charset/i', $window)) {
            return $html;
        }

        return substr($html, 0, $inner_start)
            . '<meta charset="UTF-8">'
            . substr($html, $inner_start);
    }

    #
    function __construct($otto_uuid){

        # set the site uuid using the provided string
        $this->site_uuid = $otto_uuid;

        # Use endpoint manager if available, otherwise fallback to production
        if (class_exists('Metasync_Endpoint_Manager')) {
            $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
        } else {
            $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details';
        }

        # laod the simple html dom parser with UTF-8 charset to handle special characters
        $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
    }

    /**
     * Check Route Method
     * @param route : The route to check
     * @param path : The path of the html file to save
     */
    function process_route($route, $file_path){

        # Construct the full endpoint URL with query parameters
        $url_with_params = add_query_arg(
            [
                'url'  => $route,
                'uuid' => $this->site_uuid,
            ],
            $this->otto_end_point
        );

        # PERFORMANCE FIX: Add timeout to prevent blocking
        $args = array(
            'timeout' => 5, // 5 second max timeout (allow time for redirects)
            'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301)
            'user-agent' => 'MetaSync-OTTO-SSR/2.0',
            'sslverify' => true
        );

        # Perform the GET request with timeout
        $response = wp_remote_get($url_with_params, $args);

        # Check for errors
        if (is_wp_error($response)) {
            error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
            return false;
        }

        # get the response body
        $body = wp_remote_retrieve_body($response);

        # Get the response code
        $response_code = wp_remote_retrieve_response_code($response);

        # if no change data skip
        if (empty($body) || $response_code !== 200){
            error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
            return false;
        }

        # set the html file path
        $this->html_file = $file_path;

        # load change data
        $change_data = json_decode($body, true);
        
        # Process with the fetched data
        return $this->process_route_with_data($route, $change_data, $file_path);
    }
    
    /**
     * Process route with pre-fetched suggestions data
     * OPTION 1: Used when data comes from transient cache
     * @param route : The route to check
     * @param change_data : Pre-fetched OTTO suggestions data
     * @param path : The path of the html file to save
     */
    function process_route_with_data($route, $change_data, $file_path){

        if (empty($change_data) || !is_array($change_data)) {
            return false;
        }

        # set the html file path
        $this->html_file = $file_path;
        
        # Analyze what Otto is providing and store for conditional SEO blocking
        $has_otto_title = false;
        $otto_description_tags = []; // Track specific description tags Otto provides
        
        if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) {
            foreach ($change_data['header_replacements'] as $item) {
                if (!empty($item['type'])) {
                    # Check if Otto has title
                    if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
                        $has_otto_title = true;
                    }
                    # Check if Otto has description - track specific tag types
                    if ($item['type'] == 'meta') {
                        # Check for meta[name=description]
                        if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
                            $otto_description_tags[] = 'meta[name=description]';
                        }
                        # Check for meta[property=og:description]
                        if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
                            $otto_description_tags[] = 'meta[property=og:description]';
                        }
                        # Check for meta[name=twitter:description]
                        if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
                            $otto_description_tags[] = 'meta[name=twitter:description]';
                        }
                    }
                }
            }
        }
        
        # Check header_html_insertion for description - must have non-empty content value
        if (!empty($change_data['header_html_insertion'])) {
            if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) {
                $otto_description_tags[] = 'meta[name=description]';
            }
        }

        # Remove duplicates
        $otto_description_tags = array_unique($otto_description_tags);
        
        # Store blocking flags to pass to handle_route_html
        # This will be added to the internal fetch URL as parameters
        $change_data['_otto_blocking'] = array(
            'block_title' => $has_otto_title,
            'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove
        );
        
        # Process the route with the suggestions data
        return $this->handle_route_html($route, $change_data);
        
    }

    # function to get tag attributes
    function get_tag_attributes($tag){

        # Extract existing attributes of the <body> tag
        $attributes = [];


        # set the tag attributes
        $tag_attributes = [];
        

        # check that the tag attributes 
        if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){
            return '';
        }

        # get the tag attributes
        $tag_attributes = $tag->getAllAttributes();  

        # loop all attributes
        foreach ($tag_attributes as $key => $value) {

            if ($value == 1) {

                # Handle boolean attributes
                $attributes[] = htmlspecialchars($key, ENT_QUOTES);
            } else {

                # Handle attributes with values
                $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            }
        }

        # Convert attributes array to a string
        $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';

        # return the attributes string
        return $attributes_string;
    }

    function handle_route_html($route, $replacement_data){

        # Detect if current page uses Brizy and disable SG Cache if so
        # Using global function defined in otto_pixel.php
        if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
            metasync_otto_disable_sg_cache_for_brizy();
        }

        # lablel the Otto Route
        # label otto requests to avoid loops
        // $request_body = add_query_arg(
        //     [
        //         'is_otto_page_fetch' => 1
        //     ],
        //     $route
        // );
        # Add blocking flags as URL parameters (no database writes!)
        $url_params = ['is_otto_page_fetch' => 1];
        
        # Add blocking flags if available
        if (!empty($replacement_data['_otto_blocking'])) {
            $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0';
            # For HTTP fetch path, check if any description tags need blocking
            $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
            $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0';        
            }
        
        $request_body = add_query_arg($url_params, $route);
        
        # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.)
        # and loopback requests fail, try using localhost instead
        $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route);
		# set cookie header var
		$cookie_header = '';
		
		# loop cookies to set header
		foreach ($_COOKIE as $name => $value) {
			
            # handle array values by converting to string
			$cookie_value = is_array($value) ? serialize($value) : $value;

			# add cookie to header
			# $cookie_header .= $name . '=' . $value . '; ';
            $cookie_header .= $name . '=' . $cookie_value . '; ';
		}
		
		# trim the string
		$cookie_header = rtrim($cookie_header, '; ');

		# Allow timeout customization for slow tunnel environments
		$fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5);
		
		$args = array(
			'sslverify' => false, // Disabled for localhost/tunnel environments
			'timeout' => $fetch_timeout, // Configurable timeout for tunnels
			'redirection' => 5,
			'httpversion' => '1.1',
			'headers' => array(
				'Cookie' => $cookie_header,
				'Cache-Control' => 'no-cache, no-store, must-revalidate',
				'Pragma' => 'no-cache',
				'X-OTTO-Internal-Fetch' => '1',
				'User-Agent' => 'MetaSync-OTTO-SSR/3.0',
				'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels
			)
		);

        # get the associateed route html
        $route_html = wp_remote_get($request_body, $args);

		# Check for timeout or connection errors
		if (is_wp_error($route_html)) {
			$error_msg = $route_html->get_error_message();
			error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
			return false;
		}

        # get body
        $html_body = wp_remote_retrieve_body($route_html);

        # Get the response code
        $response_code = wp_remote_retrieve_response_code($route_html);


        # check not empty
        if(empty($html_body) || $response_code !== 200){
			error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
            return false;
        }

        # Skip DOM processing on oversized documents to avoid fatal OOM.
        if (class_exists('Metasync_Otto_Render_Strategy')
            && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html_body))
        ) {
            Metasync_Otto_Render_Strategy::log_oversized_skip('handle_route_html', strlen($html_body));
            return false;
        }

        # Remove XML declaration
        $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);

        # / Save ALL original <style> blocks before DOM processing.
        # SimpleHtmlDom can lose or corrupt style blocks during parse/serialize —
        # not only Divi's id-tagged ones but also anonymous <style> blocks used by
        # custom themes for nav collapse CSS, responsive breakpoints, etc.
        $original_style_blocks = $this->capture_style_blocks($html_body);

        # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
        # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
        $srcdoc_store = array();
        $html_body = $this->protect_srcdoc_attributes($html_body, $srcdoc_store);

        # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
        $html_body = $this->sanitize_text_less_than($html_body);

        # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
        # before DOM parsing — SimpleHtmlDom strips attributes from these.
        $html_body = $this->fix_malformed_self_closing_tags($html_body);

        # now that the html is not empty
        # load it into the simple html dom
        $this->dom->load($html_body, true, false);

        # Force UTF-8 charset to preserve emojis and special characters
        # This overrides any charset detection from HTML meta tags
        $this->dom->_charset = 'UTF-8';
        $this->dom->_target_charset = 'UTF-8';

        # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
        # This eliminates 8-10 full DOM traversals per page
        $this->cache_elements();

        # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
        # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
        $this->deferred_reload = true;

        # now lets do the magic

        # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
        # Some API versions return 'value' instead of 'recommended_value'
        if (!empty($replacement_data['header_replacements'])) {
            foreach ($replacement_data['header_replacements'] as &$item) {
                if (isset($item['value']) && !isset($item['recommended_value'])) {
                    $item['recommended_value'] = $item['value'];
                }
            }
            unset($item); // Break reference
        }

        # start the header html insertion
        $this->insert_header_html($replacement_data);

        # now we do the header replacements
        $this->do_header_replacements($replacement_data);

        # now do the body replacements
        $this->do_body_replacements($replacement_data);

        # now do the footer insertions
        $this->do_footer_html_insertion($replacement_data);

        # final cleanup: ensure metasync_optimized attribute is removed from AMP pages
        $this->cleanup_amp_metasync_attribute();

        # CRITICAL FIX: SimpleHtmlDom save() doesn't persist outertext/innertext changes
        # Use the same manual string replacement approach as process_html_directly
        $this->deferred_reload = false;

        # Get the HTML as string
        $result_html = $this->dom->save();

        # Apply manual replacements (same logic as process_html_directly)

        # Apply header replacements manually
        if (!empty($replacement_data['header_replacements'])) {
            foreach ($replacement_data['header_replacements'] as $item) {
                $type = $item['type'] ?? '';
                $value = $item['recommended_value'] ?? $item['value'] ?? '';

                if (empty($value)) continue;

                if ($type === 'title') {
                    $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
                    $title_tag = '<title>' . $new_value . '</title>';
                    $result_html = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
                        return $title_tag;
                    }, $result_html, 1);
                } elseif ($type === 'meta') {
                    $name = $item['name'] ?? '';
                    $property = $item['property'] ?? '';
                    $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');

                    if (!empty($name)) {
                        $pattern = '/<meta\s+(?:name\s*=\s*["\']' . preg_quote($name, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']|content\s*=\s*["\'][^"\']*["\']\s+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'])\s*\/?>/i';
                        $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
                        $count = 0;
                        $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
                            return $replacement;
                        }, $result_html, -1, $count);

                        if ($count === 0) {
                            $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
                                return $m[1] . "\n" . $replacement;
                            }, $result_html, 1);
                        }
                    } elseif (!empty($property)) {
                        $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
                        $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
                        $count = 0;
                        $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
                            return $replacement;
                        }, $result_html, -1, $count);

                        if ($count === 0) {
                            $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
                                return $m[1] . "\n" . $replacement;
                            }, $result_html, 1);
                        }
                    }
                } elseif ($type === 'h1' || $type === 'heading') {
                    $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
                    $result_html = preg_replace_callback(
                        '/<h1([^>]*)>.*?<\/h1>/is',
                        function ($m) use ($new_value) {
                            return '<h1' . $m[1] . '>' . $new_value . '</h1>';
                        },
                        $result_html,
                        1
                    );
                }
            }
        }

        # String-based heading fallback for body_substitutions
        # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
        if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
            foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
                if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
                    continue;
                }

                $heading_type = preg_quote($heading['type'], '/');
                $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
                $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');

                $result_html = preg_replace_callback(
                    '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
                    function ($m) use ($current_value, $recommended_value) {
                        $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
                        if ($inner_text === $current_value) {
                            return $m[1] . $recommended_value . $m[3];
                        }
                        return $m[0];
                    },
                    $result_html,
                    -1
                );
            }
        }

        # CRITICAL FIX: Apply image alt text manually via string replacement
        # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path
        $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');

        # Apply insertions — only if DOM insertion didn't already apply it
        if (!empty($replacement_data['header_html_insertion'])) {
            $header_html_check = trim($replacement_data['header_html_insertion']);
            if (strpos($result_html, $header_html_check) === false) {
                $header_html_insertion = preg_replace(
                    '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
                    '<script$1type=$2application/ld+json$2 data-otto="true"',
                    $replacement_data['header_html_insertion']
                );
                $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
                $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
            }
        }
        if (!empty($replacement_data['body_top_html_insertion'])) {
            $body_top_check = trim($replacement_data['body_top_html_insertion']);
            if (strpos($result_html, $body_top_check) === false) {
                $safe_body_top = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
                $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $safe_body_top, $result_html, 1);
            }
        }
        if (!empty($replacement_data['body_bottom_html_insertion'])) {
            $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
            if (strpos($result_html, $body_bottom_check) === false) {
                $safe_body_bottom = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
                $result_html = preg_replace('/(<\/body>)/i', $safe_body_bottom . "\n" . '$1', $result_html, 1);
            }
        }
        if (!empty($replacement_data['footer_html_insertion'])) {
            $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
            $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
        }

        # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
        $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);

        # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
        $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
        $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
        $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
        $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
        $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
        $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');

        # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
        if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
            $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
        }

        $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');

        # / Re-inject any <style> blocks lost during processing.
        $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');

        # Fix Divi 5 shortcode framework class renumbering (HTTP path)
        $result_html = $this->otto_guard_html($this->fix_divi_class_renumbering($result_html), $result_html, 'fix_divi_class_renumbering');

        # Clean OTTO internal fetch params from HTML output.
        # The HTTP render uses wp_remote_get with ?is_otto_page_fetch=1&otto_block_title=1&otto_block_desc=1
        # These leak into form action URLs, canonical links, etc. in the rendered HTML.
        $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');

        # Remove any lazy-video facade <style> hoisted into the page
        # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
        $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');

        # Restore the original encoded srcdoc values (undo tokenization).
        $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');

        # Keep <meta charset> within the first 1024 bytes so external CSS
        # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
        $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');

        # undo double-encoded numeric/hex character references produced by the
        # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
        $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');

        # Remove any lazy-video facade <style> hoisted into the page
        # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
        $result_html = $this->strip_hoisted_facade_styles($result_html);

        # Restore the original encoded srcdoc values (undo tokenization).
        $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);

        # Keep <meta charset> within the first 1024 bytes so external CSS
        # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
        $result_html = $this->ensure_early_charset_meta($result_html);

        # undo double-encoded numeric/hex character references produced by the
        # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
        $result_html = $this->repair_double_encoded_entities($result_html);

        return $result_html;
    }

    /**
     * Repair double-encoded character references in serialized HTML.
     *
     * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
     * $this->dom->save() and $root->outertext) runs every attribute value through
     * htmlentities(), which escapes the leading '&' of numeric/hex character
     * references. Theme icon attributes such as data-x-icon-s="&#xf3c5"
     * (Themeco X/Cornerstone) therefore become "&amp;#xf3c5", so the browser
     * prints the literal text instead of drawing the icon glyph.
     *
     * Restores only NUMERIC and HEX character references (&#NNN; / &#xHHHH;).
     * Bare ampersands in query strings (?a=1&b=2 -> ?a=1&amp;b=2) and named
     * entities are intentionally left escaped.
     *
     * @param string $html Serialized HTML from save()/outertext.
     * @return string
     */
    private function repair_double_encoded_entities($html) {
        # strpos guard: skip the regex entirely when there is nothing to repair
        if (!is_string($html) || strpos($html, '&amp;#') === false) {
            return $html;
        }
        return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
    }

    # do the footer html insertion
    function do_footer_html_insertion($replacement_data){

        # check that we have footer html
        if(empty($replacement_data['footer_html_insertion'])){
            return;
        }

        # OPTIMIZED: Use cached element instead of DOM traversal
        $footer = $this->get_cached_element('footer');

        # check that footer is object
        if(!is_object($footer) || !isset($footer->innertext, $footer->outertext)){
            return;
        }

        # get the tag attributes
        $attributes_string = $this->get_tag_attributes($footer);

        # now do the actual html replacements
        $footer->outertext = '<footer' . $attributes_string . '>' . $footer->innertext . $replacement_data['footer_html_insertion'].'</footer>';

        # save the document
        $this->save_reload();
    }

    # do body replacements
    function do_body_replacements($replacement_data){

        # start body top html replacements
        $this->do_body_top_html($replacement_data);
    
        # start the body bottom html replacements
        $this->do_body_bottom_html($replacement_data);

        # do the body substitutions
        $this->do_body_substitutions($replacement_data);

        # Check if the feature is enabled in general settings (Post/Page Editor Settings)
        $general_settings = get_option('metasync_options')['general'] ?? [];
        if (!empty($general_settings['open_external_links']) && $general_settings['open_external_links'] == '1') {
            $this->add_target_blank_to_external_links();
        }

        # Check if the feature is enabled in seo_controls settings (Indexation Control)
        $seo_controls = get_option('metasync_options')['seo_controls'] ?? [];
        if (!empty($seo_controls['add_nofollow_to_external_links']) && $seo_controls['add_nofollow_to_external_links'] === 'true') {
            $this->add_nofollow_to_external_links();
        }
    }

    # body substitutions data
    function do_body_substitutions($replacement_data){

        # check that we have an array of substitutions
        if(empty($replacement_data['body_substitutions']) || !is_array($replacement_data['body_substitutions'])){
            return;
        }

        # now work on different substitution keys
        foreach ($replacement_data['body_substitutions'] as $key => $value) {

            # check key categories
            if($key == 'images'){
                # do image replacements
                $this->handle_images($value);
            }
            elseif($key == 'headings'){
                # do heading repalcements
                $this->do_heading_body_substitutions($value);
            }
            elseif($key == 'links'){
                # do link replacements
                $this->do_link_body_substitutions($value);
            }

        }

        # save the document
        $this->save_reload();

    }

    /**
     * START BODY SUBSTITUTION FUNCTIONS
     * @see do_body_substitutions();
     */

    # image substitions
    function handle_images($image_data){

        if (empty($image_data) || !is_array($image_data)) {
            return;
        }

        # OPTIMIZED: Use cached images instead of DOM traversal
        $images = $this->get_cached_element('imgs', []);

        if (empty($images)) {
            return;
        }

        # index suggestions by URL path as well, so images authored with
        # relative srcs still match OTTO's absolute-URL keys (and vice versa).
        $alt_lookup = $this->build_image_alt_lookup($image_data);

        # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
        # Single pass with hash map lookup instead of nested loop
        foreach($images AS $key => $image){
            # Get image src
            $image_src = $image->src;

            if (empty($image_src)) {
                continue;
            }

            # Hash map lookup O(1) instead of loop O(n)
            $alt_text = $this->lookup_image_alt($alt_lookup, $image_src);
            if ($alt_text !== null) {
                # Set alt text - Note: This may not persist in all cases
                # Manual string replacement in process_html_directly() ensures it's applied
                $new_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');

                # Get current img tag HTML and update alt attribute
                $current_html = $image->outertext;

                # Remove existing alt attribute (if any)
                $updated_html = preg_replace('/\s+alt=(["\'])[^"\']*\1/', '', $current_html);

                # Insert new alt attribute after the opening <img
                $updated_html = preg_replace('/^<img\s/', '<img alt="' . str_replace('$', '\\$', $new_alt) . '" ', $updated_html);

                # Update the element
                $image->outertext = $updated_html;

                $multi_view_attr = $image->getAttribute('data-et-multi-view');
                if (!empty($multi_view_attr)) {
                    $this->update_divi_multi_view_alt($image, $alt_text);
                }
            }
        }
    }

    # memoized site host — home_url() runs its filter chain on every
    # call and these helpers run per suggestion and per img.
    private ?string $site_host_cache = null;

    /**
     * Get this site's host (lowercase) for same-host URL comparisons.
     *
     * @return string
     */
    private function get_site_host() {
        if ($this->site_host_cache === null) {
            $host = wp_parse_url(home_url(), PHP_URL_HOST);
            $this->site_host_cache = is_string($host) ? strtolower($host) : '';
        }
        return $this->site_host_cache;
    }

    /**
     * Candidate lookup keys for an image URL — the URL itself plus,
     * for relative URLs or absolute/protocol-relative URLs on this site's
     * host, the bare URL path. URLs on a different host only ever match
     * exactly, so a same path on a foreign host cannot produce a false match.
     *
     * @param string $url Image URL (absolute, protocol-relative, or relative)
     * @return array Candidate keys, original URL first
     */
    private function get_image_url_variants($url) {
        $variants = [$url];

        if (!is_string($url) || $url === '') {
            return $variants;
        }

        $host = wp_parse_url($url, PHP_URL_HOST);
        if (!empty($host) && strtolower($host) !== $this->get_site_host()) {
            # Foreign host: exact match only
            return $variants;
        }

        $path = wp_parse_url($url, PHP_URL_PATH);
        if (is_string($path) && $path !== '' && $path !== $url) {
            $variants[] = $path;
        }

        return $variants;
    }

    /**
     * Build the image alt lookup keyed by every URL variant of each
     * OTTO suggestion, so relative img srcs match absolute suggestion URLs.
     *
     * @param array $image_data OTTO body_substitutions.images map (url => alt)
     * @return array Expanded lookup (url-or-path => alt)
     */
    private function build_image_alt_lookup($image_data) {
        $lookup = [];

        foreach ($image_data as $image_url => $alt_text) {
            foreach ($this->get_image_url_variants($image_url) as $variant) {
                # First suggestion wins when two keys collapse to the same
                # path (e.g. a relative and an absolute key for one image);
                # exact-src matches still take priority in lookup_image_alt().
                if (!isset($lookup[$variant])) {
                    $lookup[$variant] = $alt_text;
                }
            }
        }

        return $lookup;
    }

    /**
     * Resolve the OTTO alt text for an img src, trying the exact src
     * first and then its same-host path variant.
     *
     * @param array  $lookup Expanded lookup from build_image_alt_lookup()
     * @param string $image_src The img element's src attribute
     * @return string|null Alt text, or null when no suggestion matches
     */
    private function lookup_image_alt($lookup, $image_src) {
        foreach ($this->get_image_url_variants($image_src) as $variant) {
            if (isset($lookup[$variant])) {
                return $lookup[$variant];
            }
        }

        return null;
    }

    /**
     * Update Divi's multi-view data attribute with alt text
     * Divi stores image attributes in a JSON structure within data-et-multi-view
     * 
     * @param object $image The image DOM element
     * @param string $alt_text The alt text to set
     */
    private function update_divi_multi_view_alt($image, $alt_text) {
        $multi_view_attr = $image->getAttribute('data-et-multi-view');
        
        if (!empty($multi_view_attr)) {
            try {
                # Decode the JSON
                $multi_view_data = json_decode($multi_view_attr, true);
                
                if ($multi_view_data && isset($multi_view_data['schema']['attrs'])) {
                    # Update alt in desktop view
                    if (isset($multi_view_data['schema']['attrs']['desktop'])) {
                        $multi_view_data['schema']['attrs']['desktop']['alt'] = $alt_text;
                    }
                    
                    # Update alt in other views if they exist (phone, tablet, etc.)
                    foreach ($multi_view_data['schema']['attrs'] as $view => $attrs) {
                        if (isset($attrs['alt'])) {
                            $multi_view_data['schema']['attrs'][$view]['alt'] = $alt_text;
                        }
                    }
                    
                    # Encode back to JSON and update the attribute
                    $updated_json = json_encode($multi_view_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                    $image->setAttribute('data-et-multi-view', $updated_json);
                    
                   # error_log('MetaSync OTTO DEBUG: Updated Divi multi-view alt text');
                }
            } catch (Exception $e) {
                # If JSON decode fails, just log and continue
                # error_log('MetaSync OTTO DEBUG: Failed to update Divi multi-view - ' . $e->getMessage());
            }
        }
    }

    /**
     * Apply image alt text via string replacement on raw HTML.
     * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites).
     *
     * Known limitation (pre-existing): the `<img[^>]*src=` pattern can match a
     * `data-src`/`data-lazy-src` attribute that ends with the target URL before
     * the real `src`, mis-attributing the alt on lazy-loaded markup. Tracked as
     * a follow-up to.
     *
     * @param string $html The HTML to process
     * @param array  $replacement_data The replacement data containing body_substitutions.images
     * @return string Modified HTML with alt text applied
     */
    private function apply_image_alt_text_via_string($html, $replacement_data) {
        if (empty($replacement_data['body_substitutions']['images']) || !is_array($replacement_data['body_substitutions']['images'])) {
            return $html;
        }

        foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
            # prefilter and match on the same-host path variant too, so
            # relative img srcs match OTTO's absolute suggestion URLs.
            $src_pattern = $this->get_image_src_pattern($image_url);

            if (empty($alt_text) || $src_pattern === null || strpos($html, $src_pattern['needle']) === false) {
                continue;
            }

            $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
            $img_pattern = '/<img[^>]*src=["\']' . $src_pattern['pattern'] . '["\'][^>]*>/i';

            if (preg_match_all($img_pattern, $html, $img_matches)) {
                foreach ($img_matches[0] as $original_img) {
                    if (strpos($original_img, $escaped_alt) !== false) {
                        continue;
                    }

                    # Remove ALL existing alt attributes
                    $new_img = preg_replace('/\s+alt\s*=\s*(["\'])[^"\']*\1/i', '', $original_img);
                    $new_img = preg_replace('/<img\s+alt\s*=\s*(["\'])[^"\']*\1\s*/i', '<img ', $new_img);

                    # Add single alt attribute after <img
                    $new_img = preg_replace('/^<img\s*/i', '<img alt="' . str_replace('$', '\\$', $escaped_alt) . '" ', $new_img);

                    # Update data-et-multi-view JSON if present (Divi theme)
                    if (strpos($new_img, 'data-et-multi-view') !== false) {
                        $new_img = preg_replace_callback(
                            '/data-et-multi-view="([^"]+)"/i',
                            function($mv_matches) use ($alt_text) {
                                $json_str = html_entity_decode($mv_matches[1], ENT_QUOTES, 'UTF-8');
                                $json_data = json_decode($json_str, true);

                                if ($json_data && isset($json_data['schema']['attrs'])) {
                                    foreach ($json_data['schema']['attrs'] as &$attrs) {
                                        if (array_key_exists('alt', $attrs)) {
                                            $attrs['alt'] = $alt_text;
                                        }
                                    }
                                    unset($attrs);
                                    $new_json = json_encode($json_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                                    return 'data-et-multi-view="' . str_replace('"', '&quot;', $new_json) . '"';
                                }
                                return $mv_matches[0];
                            },
                            $new_img
                        );
                    }

                    $html = str_replace($original_img, $new_img, $html);
                }
            }
        }

        return $html;
    }

    /**
     * Build the src match for an OTTO image suggestion URL.
     *
     * For relative URLs or absolute/protocol-relative URLs on this site's
     * host, the regex matches the URL path with an optional scheme+host
     * prefix — so a relative img src matches an absolute suggestion URL and
     * vice versa. URLs on a foreign host keep the original exact match, so a
     * same path on a different host cannot produce a false positive.
     *
     * @param string|int $image_url OTTO suggestion URL (may arrive as an int
     *                   when it originates from an array key that PHP coerced)
     * @return array|null ['needle' => strpos prefilter string,
     *                     'pattern' => regex fragment ('/' delimiter)] or null
     */
    private function get_image_src_pattern($image_url) {
        if (!is_string($image_url) || $image_url === '') {
            return null;
        }

        $host = wp_parse_url($image_url, PHP_URL_HOST);
        $site_host = $this->get_site_host();

        # Foreign host: exact match only
        if (!empty($host) && strtolower($host) !== $site_host) {
            return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')];
        }

        $path = wp_parse_url($image_url, PHP_URL_PATH);
        if (!is_string($path) || $path === '') {
            return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')];
        }

        $prefix = $site_host !== '' ? '(?:(?:https?:)?\/\/' . preg_quote($site_host, '/') . ')?' : '';

        # Allow an optional query/fragment after the path (e.g. WordPress
        # `?ver=` cache-busters) so src="/up/a.jpg?ver=2" still matches.
        return ['needle' => $path, 'pattern' => $prefix . preg_quote($path, '/') . '(?:[?#][^"\']*)?'];
    }

    # heading substitutions
    function do_heading_body_substitutions($heading_data){

        # loop all data
        foreach($heading_data AS $okey => $heading){

            # find all occurences of the heading type
            $occurences = $this->dom->find($heading['type']);

            # loop all occurences
            foreach ($occurences as $ikey => $heading_old) {
                
                # get the header text
                $text = $heading_old->text();

                # check matching text
                if(trim($heading['current_value'] ?? '') == trim($text ?? '')){

                    # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
                    $outer = $heading_old->outertext;
                    $open_end = strpos($outer, '>');
                    if ($open_end !== false) {
                        $open_tag = substr($outer, 0, $open_end + 1);
                        $close_tag = '</' . $heading['type'] . '>';
                        $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
                    }

                }
            }

        }

        # save and reload the dom
        $this->save_reload();
    }

    # link replacements
    function do_link_body_substitutions($swap_data){

        # find all links in the body
        $links = $this->dom->find('a');

        # loop all links check if we have them in swap data 
        foreach ($links as $key => $link) {
            
            # check if link matches
            if(!empty($swap_data[$link->href])){
                
                # replace the link
                $link->href = $swap_data[$link->href];
            }

        }
    }
    
    /**
     * Add rel="nofollow" attribute to external links
     * Works for both buffer and HTTP rendering methods
     * Only processes links that don't already have rel="nofollow"
     */
    function add_nofollow_to_external_links(){
        
        # find all links in the body
        $links = $this->dom->find('a');
        
        # get home URL for comparison
        $home_url = rtrim(home_url(), '/');
        $home_url_lower = strtolower($home_url);
        
        # loop through all links
        foreach ($links as $key => $link) {
            
            # get the href attribute
            $href = $link->href ?? '';
            
            # skip if href is empty
            if (empty($href)) {
                continue;
            }
            
            # check if link is external
            if ($this->is_external_link($href, $home_url, $home_url_lower)) {
                # get existing rel attribute
                $existing_rel = $link->rel ?? '';
                
                # check if nofollow already exists
                if (empty($existing_rel)) {
                    # no rel attribute, add nofollow
                    $link->rel = 'nofollow';
                } elseif (strpos($existing_rel, 'nofollow') === false) {
                    # rel exists but nofollow is not present, add it
                    $link->rel = trim($existing_rel . ' nofollow');
                }
                # if nofollow already exists, do nothing
            }
        }
        
        # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
        # This avoids redundant DOM save/reload operations and improves performance
    }

    /**
     * Check if a URL is external
     * 
     * @param string $url The URL to check
     * @param string $home_url The home URL without trailing slash
     * @param string $home_url_lower Lowercase version of home URL
     * @return bool True if external, false if internal
     */
    private function is_external_link($url, $home_url, $home_url_lower) {
        # Empty or anchor-only links are internal
        if (empty($url) || $url === '#' || strpos($url, '#') === 0) {
            return false;
        }

        # Relative URLs (starting with /) are internal
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            return false;
        }

        # Check if URL starts with home URL (case-insensitive)
        $url_lower = strtolower($url);
        if (strpos($url_lower, $home_url_lower) === 0) {
            return false;
        }

        # If it's a protocol-relative URL (//example.com), check if it matches our domain
        if (strpos($url, '//') === 0) {
            $parsed_home = parse_url($home_url);
            $parsed_link = parse_url($url);
            
            if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
                if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
                    return false;
                }
            }
        }

        # All other URLs are considered external
        return true;
    }

     /**
     * Add target="_blank" attribute to external links
     * Works for both buffer and HTTP rendering methods
     * Only processes links that don't already have a target attribute
     */
    function add_target_blank_to_external_links(){
        
        # find all links in the body
        $links = $this->dom->find('a');
        
        # get home URL for comparison
        $home_url = rtrim(home_url(), '/');
        $home_url_lower = strtolower($home_url);
        
        # loop through all links
        foreach ($links as $key => $link) {
            
            # get the href attribute
            $href = $link->href ?? '';
            
            # skip if href is empty or already has target attribute
            if (empty($href) || !empty($link->target)) {
                continue;
            }
            
            # check if link is external
            if ($this->is_external_link($href, $home_url, $home_url_lower)) {
                # add target="_blank" attribute
                $link->target = '_blank';
                
                # add rel="noopener noreferrer" for security
                $existing_rel = $link->rel ?? '';
                if (empty($existing_rel)) {
                    $link->rel = 'noopener noreferrer';
                } elseif (strpos($existing_rel, 'noopener') === false) {
                    $link->rel = trim($existing_rel . ' noopener noreferrer');
                }
            }
        }
        
        # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
        # This avoids redundant DOM save/reload operations and improves performance
    }

    # body bottom html replacement code
    function do_body_bottom_html($insert_data){

        # check that data is availbale
        if(empty($insert_data['body_bottom_html_insertion'])){
            return;
        }

        # OPTIMIZED: Use cached body element
        $body = $this->get_cached_element('body');

        # set the link property if not empty
        if(empty($body->outertext)){
            return;
        }


        # get the tag attributes
        $attributes_string = $this->get_tag_attributes($body);

        # now do the actual html replacements
        $body->outertext = '<body' . $attributes_string . '>' . $body->innertext . $insert_data['body_bottom_html_insertion'].'</body>';

        # save the document
        $this->save_reload();
    }

    # body top html replacement
    function do_body_top_html($insert_data){

        # check that data is availbale
        if(empty($insert_data['body_top_html_insertion'])){
            return;
        }

        # OPTIMIZED: Use cached body element
        $body = $this->get_cached_element('body');

        # get the tag attributes
        $attributes_string = $this->get_tag_attributes($body);

        # now do the actual html replacements
        $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
    }

    # this function does the header replacements
    function do_header_replacements($replacement_data){

        # check that we have header replacements
        if(empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])){
            return;
        }

        # Check for custom SEO values from MetaSync SEO Sidebar
        # Custom values take absolute priority over OTTO suggestions
        $custom_seo_title = '';
        $custom_seo_description = '';

        if (function_exists('is_singular') && is_singular()) {
            $post_id = get_the_ID();
            if ($post_id) {
                $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
                $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
            }
        }

        # now lets do the replacement work
        foreach($replacement_data['header_replacements'] AS $key => $data){

            # skip cases where type is not specified
            if(empty($data['type'])){
                continue;
            }

            # handle title - skip if custom SEO title exists or no value
            if($data['type'] == 'title'){
                if (!empty($custom_seo_title)) {
                    # Skip title replacement - custom SEO title takes priority
                    continue;
                }

                # Skip if OTTO has no title value
                if (empty(trim($data['recommended_value'] ?? $data['value'] ?? ''))) {
                    continue;
                }

                # handle the title logic
                $this->replace_title($data);

                #
                continue;
            }

            # handle canonical links
            if($data['type'] == 'link' && $data['rel'] === 'canonical'){

                # Protect manually-set canonical from OTTO override.
                # Validated: a legacy row corrupted to "Array" must not
                # count as a manual canonical and block OTTO's correct value.
                if (function_exists('is_singular') && is_singular()) {
                    $post_id = get_the_ID();
                    if ($post_id) {
                        $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true));
                        if ($custom_canonical === '') {
                            $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true));
                        }
                        if ($custom_canonical !== '') {
                            # Manual canonical takes priority — skip OTTO override
                            continue;
                        }
                    }
                }

                # find the cannonical dom element
                $link = $this->dom->find('link[rel="canonical"]', 0);

                # set the link property if not empty — only to a validated URL
                # never inject a corrupted/non-URL suggestion.
                if(!empty($link->href)){
                    $otto_canonical = Metasync_Canonical_Sanitizer::sanitize($data['recommended_value'] ?? '');
                    if ($otto_canonical !== '') {
                        $link->href = $otto_canonical;
                    }
                }

                #
                continue;
            }

            # work on other elemenets not titlte
            $this->handle_meta_element($data);
        }
    }

    # function to handle meta elements other than title
    function handle_meta_element($data){

        # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
        # with empty content when OTTO has no recommendation for this meta field
        $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
        if (is_object($recommended_value) || is_array($recommended_value)) {
            return;
        }
        if (!is_string($recommended_value)) {
            $recommended_value = (string) $recommended_value;
        }
        if (empty(trim($recommended_value))) {
            return;
        }

        # Check if this is a description meta tag and if custom description exists
        # Custom values take absolute priority over OTTO suggestions
        $name = $data['name'] ?? false;
        $property = $data['property'] ?? false;

        # Protect manually-set robots meta from OTTO override
        # If the user set noindex via Common Robots Meta or meta_robots, honour it
        if (!empty($name) && $name === 'robots') {
            if (function_exists('is_singular') && is_singular()) {
                $post_id = get_the_ID();
                if ($post_id) {
                    $manual_robots = get_post_meta($post_id, 'meta_robots', true);
                    if (!empty($manual_robots) && stripos($manual_robots, 'noindex') !== false) {
                        # Manual noindex takes priority — skip OTTO override
                        return;
                    }
                    $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
                    if (is_array($common_robots) && !empty($common_robots['noindex'])) {
                        # Common Robots Meta has noindex checked — skip OTTO override
                        return;
                    }
                }
            }
        }

        # Check for custom SEO description
        if (!empty($name) && $name === 'description') {
            if (function_exists('is_singular') && is_singular()) {
                $post_id = get_the_ID();
                if ($post_id) {
                    $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
                    if (!empty($custom_seo_description)) {
                        # Custom description exists, skip OTTO's suggestion
                        return;
                    }
                }
            }
        }

        # extract property value
        $property = $data['property'] ?? false;

        # extract name value
        $name = $data['name'] ?? false;

        # set the selector
        $meta_selector = '';

        # extend selector
        if(!empty($name)){
            $meta_selector .= 'meta[name="' . trim($name) . '"]';
        }

        # extent if property is defined
        if(!empty($property)){
            if (!empty($meta_selector)) {
                $meta_selector .= ',';
            }
            $meta_selector .= 'meta[property="' . trim($property) . '"]';
        }

        # find the meta gat in the dom
        $meta_tag = $this->dom->find($meta_selector, 0);

        # if tag not exists add it
        if(empty($meta_tag)){
            if($data['type'] == 'meta'){

                # get the attribute
                $attribute = $property ? 'property' : 'name';

                # call the create metatag function
                $result = $this->create_metatag($attribute, $data);
            }

            # return after creation
            return;
        }

        # CRITICAL FIX: Clear cache and get fresh reference
        # Preserve 'imgs' key for later use by handle_images()
        $preserved_imgs = $this->cached_elements['imgs'] ?? null;
        $this->cached_elements = [];
        if ($preserved_imgs !== null) {
            $this->cached_elements['imgs'] = $preserved_imgs;
        }

        # Get fresh meta tag reference using same selector
        $meta_tag_fresh = $this->dom->find($meta_selector, 0);

        if ($meta_tag_fresh) {
            # Use outertext for replacement
            $new_value = htmlspecialchars($recommended_value, ENT_QUOTES, 'UTF-8');

            # Determine attribute name
            $attr_name = !empty($data['name']) ? 'name' : 'property';
            $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');

            # Build new meta tag
            $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
        }


    }

    # function to handle the page title
    function replace_title($title_data){

        # find the title
        $title = $this->dom->find('title', 0) ?? false;

        # if none
        if($title === false){
            return $this->create_title($title_data);
        }

        # CRITICAL FIX: Clear element cache and get fresh reference
        # Preserve 'imgs' key for later use by handle_images()
        $preserved_imgs = $this->cached_elements['imgs'] ?? null;
        $this->cached_elements = [];
        if ($preserved_imgs !== null) {
            $this->cached_elements['imgs'] = $preserved_imgs;
        }

        # Get fresh title element reference
        $title_fresh = $this->dom->find('title', 0);

        if ($title_fresh) {
            # Use outertext for replacement
            $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
            $title_fresh->outertext = '<title>' . $new_value . '</title>';
        }

        # Don't save_reload here - will happen at the end
        # $this->save_reload();
    }

    # Function to create a title when it's missing
    function create_title($title_data) {

        # Find the <head> tag
        $head = $this->dom->find('head', 0);

        # Construct the <title> tag HTML
        $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';

        if (empty($head)) {
            return false;
        }

        # Extract existing attributes of the <head> tag
        $attributes = [];

        # get the tag attributes
        $tag_attributes = $head->getAllAttributes();

        # loop all attributes
        foreach ($tag_attributes as $key => $value) {

            if ($value == 1) {

                # Handle boolean attributes
                $attributes[] = htmlspecialchars($key, ENT_QUOTES);
            } else {

                # Handle attributes with values
                $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            }
        }

        # Convert attributes array to a string
        $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';

        # Rebuild the <head> tag, inserting the <title> at the beginning
        $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';

        # save and reload DOM
        $this->save_reload();
    }

    # function to create meta tag if none existss
    function create_metatag($attribute, $data){

        # Find the <head> tag
        $head = $this->dom->find('head', 0);

        # Construct the meta tag HTML (no spaces around = for standard HTML and regex compatibility)
        $meta_tag = '<meta '.$attribute.'="'.htmlspecialchars($data[$attribute], ENT_QUOTES, 'UTF-8').'" content="'.htmlspecialchars($data['recommended_value'], ENT_QUOTES, 'UTF-8').'">';

        if (empty($head)) {
            return false;
        }

        # Extract existing attributes of the <head> tag
        $attributes = [];

        # get the tag attributes
        $tag_attributes = $head->getAllAttributes();

        # loop all attributes
        foreach ($tag_attributes as $key => $value) {

            if ($value == 1) {

                # Handle boolean attributes
                $attributes[] = htmlspecialchars($key, ENT_QUOTES);
            } else {

                # Handle attributes with values
                $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            }
        }

        # Convert attributes array to a string
        $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';

        # Rebuild the <head> tag, inserting the <title> at the beginning
        $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';

        # save and reload DOM
        $this->save_reload();
    }

    /**
     * Remove duplicate <title> tags from HTML, keeping the first (OTTO's) value.
     *
     * OTTO's title replacement is always applied first (limit=1 or DOM manipulation),
     * so the first <title> tag holds the authoritative value. If SEO plugin conflicts
     * produce additional <title> tags, this strips all and re-inserts one.
     *
     * @param  string $html Full HTML document.
     * @return string HTML with at most one <title> tag.
     */
    private function deduplicate_title_tags($html) {
        $title_count = preg_match_all('/<title[^>]*>.*?<\/title>/is', $html, $title_matches);
        if ($title_count <= 1) {
            return $html;
        }

        # Capture the first title's inner text (OTTO's replacement)
        preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $first_title);
        $authoritative_title = isset($first_title[1]) ? $first_title[1] : '';

        # Strip all <title> tags
        $html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html);

        # Re-insert a single <title> after <head> using preg_replace_callback to prevent
        # backreference injection when title contains $ followed by digits (e.g. "$50 off")
        $title_tag = '<title>' . $authoritative_title . '</title>';
        $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
            return $m[1] . $title_tag;
        }, $html, 1);

        return $html;
    }

    /**
     * Remove duplicate OG and Twitter meta tags from HTML after OTTO processing.
     *
     * OTTO injects its tags with `data-otto-pixel` or `data-otto` attributes.
     * Legacy MetaSync output and third-party SEO plugins may also emit the same
     * OG/Twitter properties. This method runs at the buffer level — after all
     * sources have written their tags — and keeps only the OTTO version when
     * duplicates exist.
     *
     * Strategy per property (e.g. og:description):
     *   - If OTTO tag exists (has data-otto marker) → remove all non-OTTO duplicates
     *   - If no OTTO tag exists → keep the first occurrence, remove the rest
     *
     * @param  string $html Full HTML document.
     * @return string HTML with at most one tag per OG/Twitter property.
     */
    private function deduplicate_og_twitter_tags($html) {
        # OG properties to deduplicate
        $og_properties = [
            'og:title', 'og:description', 'og:url', 'og:type',
            'og:locale', 'og:site_name', 'og:image',
        ];

        foreach ($og_properties as $prop) {
            $html = $this->deduplicate_meta_by_attr($html, 'property', $prop);
        }

        # Twitter names to deduplicate
        $twitter_names = [
            'twitter:title', 'twitter:description', 'twitter:card',
            'twitter:image', 'twitter:site',
        ];

        foreach ($twitter_names as $name) {
            $html = $this->deduplicate_meta_by_attr($html, 'name', $name);
        }

        return $html;
    }

    /**
     * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
     *
     * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
     * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
     * default fills the gap. Mirrors how the custom SEO sidebar values take priority
     * over OTTO (see do_header_replacements()).
     *
     * Runs *after* deduplicate_og_twitter_tags(), and only within <head>. For a field the
     * user explicitly set it removes any existing tag (OTTO's) and re-adds the user's
     * value; for the rest it only adds a tag when absent — so OTTO stays authoritative
     * where the user left a field blank and no duplicate is produced. OTTO never emits an
     * og:image, so this also surfaces a user-set social image on an OTTO-managed page.
     *
     * Only applies to singular posts/pages: the meta box values are per-post, and on
     * archives/home get_queried_object_id() can return a term or user id.
     *
     * @param  string $html Full HTML document (post-dedup).
     * @return string
     */
    private function apply_metabox_og_precedence($html) {
        if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) {
            return $html;
        }
        # Per-post OG only applies to singular posts/pages. On archives/home the queried
        # object id can be a term or user id, which must not be read as post meta.
        if (!is_singular()) {
            return $html;
        }
        $post_id = get_queried_object_id();
        if (!$post_id) {
            return $html;
        }

        # Respect the meta box opt-out (only an explicit '0' disables it)
        if (get_post_meta($post_id, '_metasync_og_enabled', true) === '0') {
            return $html;
        }

        # Explicit (user-typed) meta box values. A non-empty value means the user set
        # this field themselves and it must take priority over OTTO.
        $og_title_set     = get_post_meta($post_id, '_metasync_og_title', true);
        $og_desc_set      = get_post_meta($post_id, '_metasync_og_description', true);
        $og_image_set     = get_post_meta($post_id, '_metasync_og_image', true);
        $og_type_set      = get_post_meta($post_id, '_metasync_og_type', true);
        $tw_card_set      = get_post_meta($post_id, '_metasync_twitter_card', true);
        $tw_site_set      = get_post_meta($post_id, '_metasync_twitter_site', true);
        $tw_title_set     = get_post_meta($post_id, '_metasync_twitter_title', true);
        $tw_desc_set      = get_post_meta($post_id, '_metasync_twitter_description', true);
        $tw_image_set     = get_post_meta($post_id, '_metasync_twitter_image', true);
        $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);

        # Default OG values the meta box pre-fills (post title / generated excerpt /
        # featured image). The meta box PERSISTS these defaults on save, so a non-empty
        # _metasync_og_* value alone does not prove the user customized it — a field is a
        # genuine override only when its stored value differs from this default. Reuse the
        # emitter's own resolver so the defaults match exactly (falling back to empty
        # defaults, i.e. treat nothing as customized, if the instance is unavailable).
        $defaults = ['title' => '', 'description' => '', 'image' => ''];
        if (class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::get_instance()) {
            $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
        }

        $title_is_custom = ($og_title_set !== '' && $og_title_set !== $defaults['title']);
        $desc_is_custom  = ($og_desc_set  !== '' && $og_desc_set  !== $defaults['description']);
        $image_is_custom = ($og_image_set !== '' && $og_image_set !== $defaults['image']);

        # Resolved values (explicit value first, then the meta box default).
        $og_title = $og_title_set !== '' ? $og_title_set : $defaults['title'];
        $og_desc  = $og_desc_set  !== '' ? $og_desc_set  : $defaults['description'];
        $og_image = $og_image_set !== '' ? $og_image_set : $defaults['image'];
        $og_type  = $og_type_set  ?: 'article';
        $site_name = get_bloginfo('name');

        # Twitter inherits the OG value when its own field is blank; a user-customized
        # og:image therefore also overrides OTTO's twitter:image (the meta box treats them
        # as one). A twitter field is custom if its own value differs from the default OR
        # the OG field it inherits was customized.
        $tw_card  = $tw_card_set ?: 'summary_large_image';
        $tw_title = $tw_title_set !== '' ? $tw_title_set : $og_title;
        $tw_desc  = $tw_desc_set  !== '' ? $tw_desc_set  : $og_desc;
        $tw_image = $tw_image_set !== '' ? $tw_image_set : $og_image;

        $tw_title_is_custom = ($tw_title_set !== '' && $tw_title_set !== $defaults['title'])       || $title_is_custom;
        $tw_desc_is_custom  = ($tw_desc_set  !== '' && $tw_desc_set  !== $defaults['description']) || $desc_is_custom;
        $tw_image_is_custom = ($tw_image_set !== '' && $tw_image_set !== $defaults['image'])       || $image_is_custom;

        # twitter:site: per-post value, else the site-wide handle from Social Meta settings.
        # twitter:site / twitter:image:alt are not auto-defaulted by the meta box, so a
        # non-empty per-post value is itself the override signal.
        $tw_site = $tw_site_set;
        if ($tw_site === '' && class_exists('Metasync')) {
            $social = Metasync::get_option('social_meta');
            if (is_array($social) && !empty($social['twitter_username'])) {
                $tw_site = '@' . ltrim($social['twitter_username'], '@');
            }
        }

        # og:type / twitter:card are dropdowns that always carry a value, so they override
        # OTTO only when the user picked a non-default option. og:url is auto-populated with
        # the permalink on save, so it is fill-only (never an override) to avoid clobbering
        # OTTO's canonical URL with a stale/auto value. og:site_name is a site default.
        $og_type_override = ($og_type_set !== '' && $og_type_set !== 'article');
        $tw_card_override = ($tw_card_set !== '' && $tw_card_set !== 'summary_large_image');

        # [attr, property/name, value, is_url, is_override]
        $tags = [
            ['property', 'og:title',       $og_title,  false, $title_is_custom],
            ['property', 'og:description', $og_desc,   false, $desc_is_custom],
            ['property', 'og:image',       $og_image,  true,  $image_is_custom],
            ['property', 'og:url',         get_permalink($post_id), true, false],
            ['property', 'og:type',        $og_type,   false, $og_type_override],
            ['property', 'og:site_name',   $site_name, false, false],
            ['name',     'twitter:card',        $tw_card,      false, $tw_card_override],
            ['name',     'twitter:site',        $tw_site,      false, $tw_site_set !== ''],
            ['name',     'twitter:title',       $tw_title,     false, $tw_title_is_custom],
            ['name',     'twitter:description', $tw_desc,      false, $tw_desc_is_custom],
            ['name',     'twitter:image',       $tw_image,     true,  $tw_image_is_custom],
            ['name',     'twitter:image:alt',   $tw_image_alt_set, false, $tw_image_alt_set !== ''],
        ];

        # Operate only within <head> so meta tags in the body are never touched.
        if (!preg_match('/(<head\b[^>]*>)(.*?)(<\/head>)/is', $html, $hm)) {
            return $html;
        }
        $head = $hm[2];

        # 1) Remove existing head tags for user-overridden properties so the user's value wins.
        foreach ($tags as $t) {
            list($attr, $key, $val, $is_url, $is_override) = $t;
            if ($is_override && $val !== '' && $val !== null) {
                $stripped = preg_replace(
                    '/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1[^>]*>\s*/i',
                    '',
                    $head
                );
                if ($stripped !== null) {
                    $head = $stripped;
                }
            }
        }

        # 2) Build additions: overrides (removed above) + gap-fills for anything still absent.
        $additions = '';
        foreach ($tags as $t) {
            list($attr, $key, $val, $is_url, $is_override) = $t;
            if ($val === '' || $val === null) {
                continue;
            }
            if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) {
                continue; # OTTO (or another source) already provides it and the user did not override
            }
            $content = $is_url ? esc_url($val) : esc_attr($val);
            if ($content === '') {
                continue;
            }
            $marker = $is_override ? 'override' : 'fill';
            $additions .= '<meta ' . $attr . '="' . $key . '" content="' . $content . '" data-metasync-og="' . $marker . '">' . "\n";
        }

        # Reassemble the head block (str_replace avoids backreference issues from $ in values).
        if ($head === $hm[2] && $additions === '') {
            return $html; # nothing removed, nothing added
        }
        $new_head_block = $hm[1] . $head . $additions . $hm[3];
        return str_replace($hm[0], $new_head_block, $html);
    }

    /**
     * Deduplicate meta tags by a specific attribute (property= or name=).
     *
     * When duplicates exist and one carries a data-otto marker, keep only
     * the OTTO version. Otherwise keep the first occurrence.
     *
     * @param  string $html      Full HTML.
     * @param  string $attr_name Attribute name: 'property' or 'name'.
     * @param  string $attr_val  Attribute value: e.g. 'og:title' or 'twitter:description'.
     * @return string
     */
    private function deduplicate_meta_by_attr($html, $attr_name, $attr_val) {
        $escaped = preg_quote($attr_val, '/');
        # Match all <meta> tags with this attribute value (both attr orderings)
        $pattern = '/<meta\s[^>]*' . preg_quote($attr_name, '/') . '\s*=\s*["\']' . $escaped . '["\'][^>]*\/?>/i';

        if (preg_match_all($pattern, $html, $matches) <= 1) {
            return $html; # 0 or 1 — nothing to deduplicate
        }

        $all_tags = $matches[0];

        # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
        $otto_tag = null;
        foreach ($all_tags as $tag) {
            if (stripos($tag, 'data-otto') !== false) {
                $otto_tag = $tag;
                break;
            }
        }

        # Determine the keeper: OTTO tag if present, otherwise the first tag
        $keeper = $otto_tag ?: $all_tags[0];

        # Remove all occurrences, then re-insert the keeper at the first position
        $first_replaced = false;
        $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
            if (!$first_replaced) {
                $first_replaced = true;
                return $keeper;
            }
            return ''; # Remove subsequent duplicates
        }, $html);

        return $html;
    }

    /**
     * Deduplicate <meta name="description"> tags after OTTO processing.
     *
     * Unlike title, og, twitter, and canonical tags, the plain name="description"
     * tag had NO dedup pass, so a page could end up with 2-3 copies when several
     * subsystems each emit one:
     *   - OTTO backend payload (header_html_insertion) → data-otto-pixel="dynamic-seo"
     *     (spliced in additively before </head>, never replacing an existing tag)
     *   - MetaSync's persisted-meta wp_head hook       → data-metasync-otto="true"
     *   - MetaSync SEO sidebar custom value            → data-metasync-seo="custom"
     *
     * This runs at the buffer level — after every source has written its tag —
     * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented
     * "custom always wins over OTTO" intent):
     *   1. custom sidebar (data-metasync-seo)
     *   2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto)
     *   3. first occurrence
     *
     * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches
     * substring "data-otto", which misses "data-metasync-otto"; this method checks
     * both OTTO markers explicitly, so either OTTO source is recognized.
     *
     * @param  string $html Full HTML document.
     * @return string HTML with at most one <meta name="description">.
     */
    private function deduplicate_description_tags($html) {
        if (!is_string($html) || $html === '') {
            return $html;
        }

        # Match <meta ... name="description" ...> in either attribute order.
        # [^>]* is bounded to a single tag; name="twitter:description" is NOT matched
        # because the opening quote must be immediately followed by "description".
        $pattern = '/<meta\s[^>]*name\s*=\s*["\']description["\'][^>]*\/?>/i';

        if (preg_match_all($pattern, $html, $matches) <= 1) {
            return $html; # 0 or 1 — nothing to deduplicate
        }

        $all_tags = $matches[0];

        # Choose the keeper by precedence: custom sidebar → OTTO → first.
        $custom_tag = null;
        $otto_tag   = null;
        foreach ($all_tags as $tag) {
            if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) {
                $custom_tag = $tag;
            }
            if ($otto_tag === null && (
                stripos($tag, 'data-otto-pixel') !== false ||
                stripos($tag, 'data-metasync-otto') !== false ||
                stripos($tag, 'data-otto') !== false
            )) {
                $otto_tag = $tag;
            }
        }
        $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]);

        # Remove all occurrences, re-inserting the keeper at the first position.
        # Callback form avoids backreference injection when the description content
        # contains $ followed by digits (e.g. "$50 off").
        $first_replaced = false;
        $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
            if (!$first_replaced) {
                $first_replaced = true;
                return $keeper;
            }
            return ''; # Remove subsequent duplicates
        }, $html);

        return $html;
    }

    /**
     * Remove duplicate <link rel="canonical"> tags from HTML.
     *
     * When OTTO injects a canonical via header_html_insertion and MetaSync's
     * SEO output (or WordPress core) has already emitted one, keep only the
     * OTTO version (identified by data-otto marker). If no OTTO tag exists,
     * keep the first occurrence.
     *
     * @param  string $html Full HTML document.
     * @return string HTML with at most one canonical tag.
     */
    private function deduplicate_canonical_tags($html) {
        $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';

        if (preg_match_all($pattern, $html, $matches) <= 1) {
            return $html;
        }

        $all_tags = $matches[0];

        $otto_tag = null;
        foreach ($all_tags as $tag) {
            if (stripos($tag, 'data-otto') !== false) {
                $otto_tag = $tag;
                break;
            }
        }

        $keeper = $otto_tag ?: $all_tags[0];

        $first_replaced = false;
        $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
            if (!$first_replaced) {
                $first_replaced = true;
                return $keeper;
            }
            return '';
        }, $html);

        return $html;
    }

    /**
     * Deduplicate JSON-LD schema blocks.
     *
     * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
     * blocks, keep OTTO's version for any @type that appears in both.
     * Third-party blocks whose @type is not covered by OTTO are preserved.
     *
     * @param  string $html Full HTML.
     * @return string
     */
    private function deduplicate_schema_tags($html) {
        // Find all JSON-LD script blocks
        $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
        if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
            return $html;
        }

        $otto_by_type   = []; // @type => decoded JSON object
        $third_by_type  = []; // @type => decoded JSON object
        $otto_graph     = []; // entries from OTTO @graph blocks
        $third_graph    = []; // entries from third-party @graph blocks

        foreach ($matches as $m) {
            $attrs    = $m[1];
            $json_str = $m[3];
            $decoded  = json_decode($json_str, true);
            if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
                continue; // skip unparseable blocks — leave them in place
            }
            $is_otto = stripos($attrs, 'data-otto') !== false;

            if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
                foreach ($decoded['@graph'] as $entry) {
                    if (!isset($entry['@type'])) continue;
                    // JSON-LD allows @type to be a string OR an array of strings.
                    // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
                    // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
                    $type = is_array($entry['@type'])
                        ? (string) reset($entry['@type'])
                        : (string) $entry['@type'];
                    if ($type === '') continue;
                    if ($is_otto) {
                        $otto_graph[$type] = $entry;
                    } else {
                        $third_graph[$type] = $entry;
                    }
                }
            } elseif (isset($decoded['@type'])) {
                $type = is_array($decoded['@type'])
                    ? (string) reset($decoded['@type'])
                    : (string) $decoded['@type'];
                if ($type === '') continue;
                if ($is_otto) {
                    $otto_by_type[$type] = $decoded;
                } else {
                    $third_by_type[$type] = $decoded;
                }
            }
        }

        // If OTTO provided no schema at all, nothing to deduplicate
        if (empty($otto_by_type) && empty($otto_graph)) {
            return $html;
        }

        // Remove all JSON-LD blocks from HTML
        $html = preg_replace($pattern, '', $html);

        // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
        $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
        $rebuilt = '';
        foreach ($kept as $decoded) {
            $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
                        wp_json_encode($decoded) . "</script>\n";
        }

        // Re-insert merged @graph block (if any entries exist)
        $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
        if (!empty($merged_graph)) {
            $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
            $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
                        wp_json_encode($graph_obj) . "</script>\n";
        }

        // Re-inject before </head>
        if (!empty($rebuilt)) {
            $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
                return $rebuilt . $m[1];
            }, $html, 1);
        }

        return $html;
    }

    # function to detect if current page is an AMP page
    function is_amp_page(){
        
        # Check if URL path contains /amp/
        $current_url = $_SERVER['REQUEST_URI'] ?? '';
        if (strpos($current_url, '/amp/') !== false) {
            return true;
        }
        
        # Check if URL ends with /amp
        if (preg_match('/\/amp\/?$/', $current_url)) {
            return true;
        }
        
        # Check if amp=1 query parameter is present
        if (isset($_GET['amp']) && $_GET['amp'] == '1') {
            return true;
        }
        
        # Check for other common AMP query parameters
        if (isset($_GET['amp']) && !empty($_GET['amp'])) {
            return true;
        }
        
        return false;
    }

    # this function insterts header html to the dom
    function insert_header_html($data){

        # check that we have the header html
        if(empty($data['header_html_insertion'])){
            #
            return;
        }

        # append the/ html at the start of the header
        $head = $this->dom->find('head', 0);

        if ($head) {

            # Check if this is an AMP page - if so, don't add metasync_optimized attribute
            $is_amp_page = $this->is_amp_page();

            # Append the new HTML at the start of the <head> tag
            # For AMP pages: use clean <head> tag without metasync_optimized attribute
            # For non-AMP pages: add metasync_optimized attribute to <head> tag
            if ($is_amp_page) {
                $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
            } else {
                $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
            }

        }

        # save and reload DOM
        $this->save_reload();
    }

    # function to forcefully remove metasync_optimized attribute from head on AMP pages
    function cleanup_amp_metasync_attribute(){
        
        # Only proceed if this is an AMP page
        if (!$this->is_amp_page()) {
            return;
        }
        
        # Find the head tag
        $head = $this->dom->find('head', 0);
        
        if (!$head) {
            return;
        }
        
        # Check if head has metasync_optimized attribute
        $head_html = $head->outertext;
        
        # If metasync_optimized attribute is found, remove it
        if (strpos($head_html, 'metasync_optimized') !== false) {
            
            # Remove the metasync_optimized attribute from the head tag
            # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
            $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
            
            # Update the head element
            $head->outertext = $cleaned_head_html;
            
        }
    }

    /**
     * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
     * Reduces 8-10 full DOM traversals to 1 initial traversal
     * Call this once after loading HTML, before processing
     */
    private function cache_elements() {
        if (!$this->dom) {
            return;
        }

        # Cache all commonly accessed elements in one pass
        $this->cached_elements = [
            'html'      => $this->dom->find('html', 0),
            'head'      => $this->dom->find('head', 0),
            'body'      => $this->dom->find('body', 0),
            'footer'    => $this->dom->find('footer', 0),
            'title'     => $this->dom->find('title', 0),
            'imgs'      => $this->dom->find('img'),
            'links'     => $this->dom->find('a'),
            'canonical' => $this->dom->find('link[rel="canonical"]', 0),
        ];
    }

    /**
     * Get cached element by key, with fallback to DOM find
     * @param string $key Element key from cache
     * @param mixed $fallback Fallback value if not cached
     * @return mixed Cached element or fallback
     */
    private function get_cached_element($key, $fallback = null) {
        return $this->cached_elements[$key] ?? $fallback;
    }

    # this function saves are reloads the dom for modifications to avoid conflict
    function save_reload(){

        # PERFORMANCE OPTIMIZATION: Skip reload if deferred
        # This reduces multiple serialize/deserialize cycles to just one final reload
        if ($this->deferred_reload) {
            return;
        }

        # Cleanup metasync_optimized attribute on AMP pages before saving
        $this->cleanup_amp_metasync_attribute();

        # DISABLED: Cache file creation temporarily disabled

        # if(file_put_contents($this->html_file, $this->dom)){

            # load the modified file to the DOM
           # $this->dom = new HtmlDocument($this->html_file );
        # }

        # this code is to be replaced in future
        # reson for adding is to prevent caching logged in user pages
        # why not just skip saving? it broke the DOM Library
        # check user is logged in clear the file

		# if(is_user_logged_in()) {
		#	unlink($this->html_file);
		# }

        # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
        # This prevents DOM breaking while avoiding cache file creation
        if($this->dom){
            # Get current DOM as HTML string
            $current_html = $this->dom->save();

            # Reload DOM from the HTML string to refresh internal state
            # This replaces the file save/reload cycle that SimpleHtmlDOM expects
            $current_html = $this->sanitize_text_less_than($current_html);
            $this->dom->load($current_html, true, false);

            # Force UTF-8 charset after reload to preserve emojis
            $this->dom->_charset = 'UTF-8';
            $this->dom->_target_charset = 'UTF-8';
        }

    }

    /**
     * Explicitly free the SimpleHtmlDom node tree and reclaim memory.
     *
     * SimpleHtmlDom nodes hold circular parent/child references, so simply
     * dropping the document does not free the tree until request shutdown.
     * HtmlNode::clear() breaks those cycles; we then drop the document, force a
     * GC pass, and re-instantiate a fresh empty parser so the instance stays
     * reusable for any subsequent route on the same request.
     */
    function free_dom(){
        if (isset($this->dom)) {
            # Break the node tree's parent/child cycles so GC can reclaim it now.
            if (isset($this->dom->root) && is_object($this->dom->root)
                && method_exists($this->dom->root, 'clear')) {
                $this->dom->root->clear();
            }
            unset($this->dom);
        }

        # Fresh, empty parser — mirrors the constructor so the object is reusable.
        $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);

        if (function_exists('gc_collect_cycles')) {
            gc_collect_cycles();
        }
    }

    /**
     * Process HTML directly without HTTP request (for buffer approach)
     * This is the FAST path - eliminates the internal wp_remote_get call
     * 
     * CRITICAL: This method is called from the output buffer callback.
     * If it fails, we must return false so the original HTML can be used.
     * 
     * @param string $html The raw HTML captured from output buffer
     * @param array $replacement_data OTTO suggestions/replacement data
     * @return HtmlDocument|false Modified DOM or false on failure
     * @since 2.6.0
     */
    function process_html_directly($html, $replacement_data) {
        try {
            # Validate inputs
            if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
                return false;
            }

            # Validate HTML is actual HTML content
            if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
                return false;
            }

            # Skip DOM processing on oversized documents to avoid fatal OOM.
            # Returning false makes the buffer callback serve the original HTML
            # unmodified — the page still renders, just without OTTO changes.
            if (class_exists('Metasync_Otto_Render_Strategy')
                && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html))
            ) {
                Metasync_Otto_Render_Strategy::log_oversized_skip('process_html_directly', strlen($html));
                return false;
            }

            # Remove XML declaration if present
            $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);

            # / Save ALL original <style> blocks before DOM processing.
            $original_style_blocks = $this->capture_style_blocks($html);

            # Shield entity-encoded srcdoc values (lazy YouTube/video facades)
            # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>.
            $srcdoc_store = array();
            $html = $this->protect_srcdoc_attributes($html, $srcdoc_store);

            # Escape bare < in text content before DOM parsing
            $html = $this->sanitize_text_less_than($html);

            # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
            $html = $this->fix_malformed_self_closing_tags($html);

            # Load HTML into DOM
            $this->dom->load($html, true, false);

            # Force UTF-8 charset to preserve emojis and special characters
            $this->dom->_charset = 'UTF-8';
            $this->dom->_target_charset = 'UTF-8';

            # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
            # This eliminates 8-10 full DOM traversals per page
            $this->cache_elements();

            # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
            # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
            $this->deferred_reload = true;

            # Apply blocking flags if available (for SEO plugin coordination)
            if (!empty($replacement_data['_otto_blocking'])) {
                $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
                $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
                
                # Remove SEO plugin meta tags if OTTO is providing them
                if ($block_title || !empty($block_description_tags)) {
                    $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
                }
            }

            # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)

            # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
            # Some API versions return 'value' instead of 'recommended_value'
            if (!empty($replacement_data['header_replacements'])) {
                foreach ($replacement_data['header_replacements'] as &$item) {
                    if (isset($item['value']) && !isset($item['recommended_value'])) {
                        $item['recommended_value'] = $item['value'];
                    }
                }
                unset($item); // Break reference
            }

            # 1. Header HTML insertion
            $this->insert_header_html($replacement_data);

            # 2. Header replacements (title, meta, canonical)
            $this->do_header_replacements($replacement_data);

            # 3. Body replacements (top, bottom, substitutions)
            $this->do_body_replacements($replacement_data);

            # 4. Footer HTML insertion
            $this->do_footer_html_insertion($replacement_data);

            # 5. Final cleanup for AMP pages
            $this->cleanup_amp_metasync_attribute();

            # CRITICAL FIX: Clear cached elements and force DOM to refresh
            $this->deferred_reload = false;
            $this->cached_elements = []; // Clear our custom cache

            # Clear SimpleHtmlDom's internal cache
            if (method_exists($this->dom, 'clear')) {
                $this->dom->clear();
            }


            # Try getting HTML via root element instead of save()
            $root = $this->dom->root;
            if ($root && isset($root->outertext)) {
                $result_html = $root->outertext;
            } else {
                # Fallback to save() method
                $result_html = $this->dom->save();
            }

            # DEBUG: Check if DOM changes persisted
            if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
            }

            # Apply header replacements manually via string replacement
            if (!empty($replacement_data['header_replacements'])) {

                foreach ($replacement_data['header_replacements'] as $idx => $item) {
                    $type = $item['type'] ?? '';
                    $value = $item['recommended_value'] ?? $item['value'] ?? '';


                    if (empty($value)) {
                        continue;
                    }

                    if ($type === 'title') {
                        # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
                        $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
                        $title_tag = '<title>' . $new_value . '</title>';
                        $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
                            return $title_tag;
                        }, $result_html, 1);
                        if ($replaced === $result_html && strpos($result_html, '<title') === false) {
                            # No <title> in document (common when Yoast is blocked) — insert after <head>
                            $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
                                return $m[1] . "\n" . $title_tag;
                            }, $result_html, 1);
                        } else {
                            $result_html = $replaced;
                        }
                    } elseif ($type === 'meta') {
                        $name = $item['name'] ?? '';
                        $property = $item['property'] ?? '';
                        $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');

                        if (!empty($name)) {
                            # MEMORY OPTIMIZED: Count meta tags without storing all matches
                            # preg_match_all requires $matches, so we pass it but unset immediately
                            # Use \s*=\s* to match attributes with or without spaces around =
                            $before_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);

                            # Free memory immediately after use
                            unset($before_matches);

                            # IMPROVED FIX: Remove ALL existing meta tags with this name
                            # This pattern matches ANY meta tag with name="X" regardless of:
                            # - Attribute order (name before/after content)
                            # - Additional attributes (id, class, data-*, etc.)
                            # - Quote style (single or double quotes)
                            # - Whitespace variations
                            # The pattern uses [^>]* to match ANY characters until the closing >
                            $removed_count = preg_replace_callback(
                                '/<meta\s+[^>]*name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
                                function($match) {
                                    return ''; // Remove the tag
                                },
                                $result_html,
                                -1, // Remove all occurrences
                                $total_removed
                            );

                            # Update the HTML with removed tags
                            if ($total_removed > 0) {
                                $result_html = $removed_count;
                            }


                            # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
                            $after_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
                            unset($after_matches);

                            # Now insert ONE new meta tag at the TOP of <head>
                            $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
                            $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
                                return $m[1] . "\n" . $replacement;
                            }, $result_html, 1);
                        } elseif (!empty($property)) {
                            # Replace meta property tag
                            $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
                            $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';

                            if (preg_match($pattern, $result_html)) {
                                $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
                                    return $replacement;
                                }, $result_html, 1);
                            } else {
                                # Meta tag doesn't exist, insert it in head
                                $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
                                    return $m[1] . "\n" . $replacement;
                                }, $result_html, 1);
                            }
                        }
                    } elseif ($type === 'h1' || $type === 'heading') {
                        # Replace first H1 tag, preserving attributes
                        $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
                        $result_html = preg_replace_callback(
                            '/<h1([^>]*)>.*?<\/h1>/is',
                            function ($m) use ($new_value) {
                                return '<h1' . $m[1] . '>' . $new_value . '</h1>';
                            },
                            $result_html,
                            1
                        );
                    }
                }
            }

            # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
            if (!empty($replacement_data['header_html_insertion'])) {
                $header_html_check = trim($replacement_data['header_html_insertion']);
                if (strpos($result_html, $header_html_check) === false) {
                    $header_html_insertion = preg_replace(
                        '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
                        '<script$1type=$2application/ld+json$2 data-otto="true"',
                        $replacement_data['header_html_insertion']
                    );
                    $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
                    # Insert before </head>
                    $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
                }
            }

            # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
            if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
                $result_html = preg_replace(
                    '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
                    '',
                    $result_html
                );
                $first = true;
                $result_html = preg_replace_callback(
                    '/<title[^>]*>.*?<\/title>\s*/is',
                    function ($m) use (&$first) {
                        if ($first) {
                            $first = false;
                            return $m[0];
                        }
                        return '';
                    },
                    $result_html
                );
            }

            # Apply body top HTML insertion — only if DOM insertion didn't already apply it
            if (!empty($replacement_data['body_top_html_insertion'])) {
                $body_top_check = trim($replacement_data['body_top_html_insertion']);
                if (strpos($result_html, $body_top_check) === false) {
                    $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
                    # Insert after <body>
                    $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
                }
            }

            # Apply body bottom HTML insertion — only if DOM insertion didn't already apply it
            if (!empty($replacement_data['body_bottom_html_insertion'])) {
                $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
                if (strpos($result_html, $body_bottom_check) === false) {
                    $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
                    # Insert before </body>
                    $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
                }
            }

            # Apply footer HTML insertion
            if (!empty($replacement_data['footer_html_insertion'])) {
                $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
                # Insert before </html>
                $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
            }

            # CRITICAL FIX: Apply image alt text manually via string replacement
            # DOM changes don't persist, must use string replacement
            $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');

            # String-based heading fallback for body_substitutions
            # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
            if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
                foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
                    if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
                        continue;
                    }

                    $heading_type = preg_quote($heading['type'], '/');
                    $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
                    $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');

                    $result_html = preg_replace_callback(
                        '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
                        function ($m) use ($current_value, $recommended_value) {
                            $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
                            if ($inner_text === $current_value) {
                                return $m[1] . $recommended_value . $m[3];
                            }
                            return $m[0];
                        },
                        $result_html,
                        -1
                    );
                }
            }

            # DEBUG: Check what we're returning
            if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
            }

            # FINAL VERIFICATION: Count meta descriptions in returned HTML
            $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
            if ($final_meta_count > 0) {
                foreach ($final_meta_matches[0] as $idx => $meta) {
                }
            }

            if ($final_meta_count > 1) {
            }

            # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
            # Only runs when OTTO has actually inserted its own description (data-otto marker present)
            # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace

            $removal_count = 0;
            $otto_has_description = (bool) preg_match('/<meta[^>]*name\s*=\s*["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
            if ($otto_has_description) {
                $result_html = preg_replace_callback(
                    '/<meta\s+([^>]*name\s*=\s*["\']description["\'][^>]*)>/i',
                    function($match) use (&$removal_count) {
                        # Keep only if it has data-otto="true"
                        if (stripos($match[1], 'data-otto') !== false) {
                            return $match[0]; // Keep OTTO's meta tag
                        }
                        # Remove any other meta description
                        $removal_count++;
                        return '';
                    },
                    $result_html
                );
            }

            # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string).
            $result_html = $this->apply_canonical_via_string($result_html, $replacement_data);

            # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
            $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
            $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
            $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
            $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
            $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
            $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');

            # MEMORY OPTIMIZED: Free all large objects and arrays before returning
            # This ensures memory is released immediately, especially important for high-traffic sites
            unset($final_meta_matches, $matches);

            # Explicitly free the SimpleHtmlDom node tree now that the
            # result has been serialized to a string. The bundled HtmlDocument has
            # no clear() method, so the previous method_exists() guard was a no-op
            # and the (circular-referenced) node tree lingered until object
            # destruction. free_dom() breaks those references and reclaims memory.
            $this->free_dom();

            # Clear element cache array
            $this->cached_elements = [];

            # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
            if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
                $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
            }

            $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');

            # / Re-inject any <style> blocks lost during processing.
            $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks');

            # Do NOT renumber Divi classes on the buffer/in-place path.
            # fix_divi_class_renumbering() exists to undo the index offset that
            # Divi's shortcode framework introduces ONLY during the internal
            # wp_remote_get fetch (handle_route_html), where the Theme Builder
            # header is counted twice. process_html_directly() processes the
            # page Divi already rendered normally — its module numbering and
            # Divi's own CSS are already in sync. Renumbering here desynced every
            # section/row/column/text/button index (e.g. et_pb_section_10 → _0),
            # colliding page modules with header/global modules of the same
            # number and collapsing layouts (Divi hero/slider) on Divi sites.
            # The renumber stays only on the HTTP-fetch path (handle_route_html).

            # Clean OTTO internal fetch params from HTML output
            $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params');

            # Remove any lazy-video facade <style> hoisted into the page
            # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
            $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles');

            # Restore the original encoded srcdoc values (undo tokenization).
            $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes');

            # Keep <meta charset> within the first 1024 bytes so external CSS
            # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
            $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta');

            # undo double-encoded numeric/hex character references produced by the
            # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
            $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');

            # Remove any lazy-video facade <style> hoisted into the page
            # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
            $result_html = $this->strip_hoisted_facade_styles($result_html);

            # Restore the original encoded srcdoc values (undo tokenization).
            $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store);

            # Keep <meta charset> within the first 1024 bytes so external CSS
            # (e.g. checkmark content:"✓") doesn't mojibake on cached responses.
            $result_html = $this->ensure_early_charset_meta($result_html);

            # undo double-encoded numeric/hex character references produced by the
            # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
            $result_html = $this->repair_double_encoded_entities($result_html);

            return $result_html;

        } catch (Exception $e) {
            error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
            return false;
        } catch (Error $e) {
            error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Remove conflicting SEO plugin meta tags from DOM
     * Called when OTTO is providing its own title/description
     * 
     * @param bool $remove_title Remove title-related tags
     * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
     * @since 2.6.0
     */
    private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
        if (!$this->dom) {
            return;
        }

        # Find head element
        $head = $this->dom->find('head', 0);
        if (!$head) {
            return;
        }

        # Check for custom SEO values from MetaSync SEO Sidebar
        # Custom values take absolute priority over OTTO suggestions
        $has_custom_title = false;
        $has_custom_description = false;

        if (function_exists('is_singular') && is_singular()) {
            $post_id = get_the_ID();
            if ($post_id) {
                $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
                $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
                $has_custom_title = !empty($custom_seo_title);
                $has_custom_description = !empty($custom_seo_description);
            }
        }

        # Handle description tags - only remove specific tags that Otto is providing
        if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
            # Remove only the specific description tags that Otto is providing
            foreach ($remove_description_tags as $selector) {
                $tags = $this->dom->find($selector);
                foreach ($tags as $tag) {
                    # Remove the tag
                    $tag->outertext = '';
                }
            }
        }

        if ($remove_title && !$has_custom_title) {
            # Remove Open Graph and Twitter title tags (keep main <title>)
            $title_selectors = [
                'meta[property=og:title]',
                'meta[name=twitter:title]',
            ];

            foreach ($title_selectors as $selector) {
                $tags = $this->dom->find($selector);
                foreach ($tags as $tag) {
                    $tag->outertext = ''; # Remove the tag
                }
            }
        }

        # Save changes
        $this->save_reload();
    }

    /**
     * Clean OTTO internal fetch query parameters from rendered HTML.
     *
     * @param string $html The rendered HTML
     * @return string HTML with OTTO fetch params removed
     */
    private function clean_otto_fetch_params($html) {
        $patterns = [
            '/[?&]is_otto_page_fetch=1/',
            '/[?&]otto_block_title=1/',
            '/[?&]otto_block_desc=1/',
            '/[?&]amp;is_otto_page_fetch=1/',
            '/[?&]amp;otto_block_title=1/',
            '/[?&]amp;otto_block_desc=1/',
        ];
        $html = preg_replace($patterns, '', $html);

        # Protect <script>/<style> bodies before the URL cleanup below.
        # The href/src/action matcher can otherwise match JS that looks like an
        # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the
        # ?? inside it. Swap each block for a placeholder comment, run the cleanup,
        # then restore byte-for-byte.
        $protected = [];
        $html = preg_replace_callback(
            '/<(script|style)(\b[^>]*)>([\s\S]*?)<\/\1>/i',
            function ($m) use (&$protected) {
                $key = '<!--METASYNC_FETCHCLEAN_' . count($protected) . '-->';
                $protected[$key] = $m[0];
                return $key;
            },
            $html
        );

        # Normalize leftover query-string separators (?? / ?& / ?#) created
        # by the param removal above — but ONLY inside URL attributes (href/src/action).
        # The previous implementation ran str_replace('??','?') over the ENTIRE document,
        # which corrupted inline JavaScript nullish operators (?? / ??=) and any literal
        # "??" in page text — breaking Slider Revolution and other modern inline scripts.
        $html = preg_replace_callback(
            '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is',
            function ($m) {
                $url = str_replace(['??', '?&', '?#'], ['?', '?', '#'], $m[4]);
                return $m[1] . $m[2] . $m[3] . $url . $m[3];
            },
            $html
        );

        # Restore protected <script>/<style> blocks byte-for-byte.
        if (!empty($protected)) {
            $html = str_replace(array_keys($protected), array_values($protected), $html);
        }

        return $html;
    }

    /**
     * Fix Divi 5 shortcode framework class renumbering
     *
     * When Divi 5's shortcode framework loads during the internal HTTP fetch,
     * it uses shared counters across all template parts (header + page + footer),
     * causing page content element classes to be offset (et_pb_section_0 becomes
     * et_pb_section_4, etc.). The cached CSS references 0-based numbering.
     *
     * Detects the offset per element type and remaps back to 0-based.
     *
     * @param string $html The processed HTML string
     * @return string HTML with corrected Divi class numbering
     */
    private function fix_divi_class_renumbering($html) {
        # Only run on Divi sites
        if (!defined('ET_CORE_VERSION')) {
            return $html;
        }

        # Only apply to Divi pages with template builder
        if (strpos($html, 'et-l et-l--post') === false) {
            return $html;
        }

        # Locate the page content area (between et-l--post and et-l--footer)
        $page_start = strpos($html, 'et-l et-l--post');
        if ($page_start === false) {
            return $html;
        }

        $footer_start = strpos($html, 'et-l et-l--footer', $page_start);
        if ($footer_start === false) {
            $footer_start = strlen($html);
        }

        $page_content = substr($html, $page_start, $footer_start - $page_start);


        # Divi element types that get renumbered by the shortcode framework.
        # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render
        # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal
        # wp_remote_get includes the Theme Builder header template in Divi's counter.
        # If the blog module class doesn't match between page 1 (OTTO-processed) and
        # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results.
        $types = [
            'section', 'row', 'column', 'text', 'blurb', 'toggle',
            'button', 'image', 'group_carousel',
            'blog', 'portfolio', 'filterable_portfolio', 'shop',
            'heading', 'divider', 'code', 'icon', 'contact_form_7',
        ];

        $offsets = [];

        # Lookahead excludes two non-instance class shapes so we never rewrite them:
        #   _tb_  → Theme Builder index suffix (et_pb_column_0_tb_header)
        #   _\d   → Divi column WIDTH fractions (et_pb_column_4_4, _1_2, _1_3, …).
        # The width fraction is NOT an instance counter; rewriting it (e.g.
        # et_pb_column_4_4 → et_pb_column_0_4) strips the column's width rule from
        # Divi's static stylesheet (.et_pb_column_4_4{width:100%}) and collapses the
        # layout. Bare instance indices (et_pb_column_16, followed by space/quote)
        # still match and renumber as intended.
        $instance_lookahead = '(?!_(?:tb_|\d))';

        foreach ($types as $type) {
            # Find the FIRST numbered instance in page content (not _tb_ or width-fraction)
            if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)' . $instance_lookahead . '/', $page_content, $m)) {
                $first_num = (int) $m[1];
                if ($first_num > 0) {
                    $offsets[$type] = $first_num;
                }
            }
        }

        if (empty($offsets)) {
            return $html;
        }

        # Remap each element type back to 0-based numbering
        foreach ($offsets as $etype => $offset) {
            $escaped = preg_quote($etype, '/');
            $html = preg_replace_callback(
                '/et_pb_' . $escaped . '_(\d+)' . $instance_lookahead . '/',
                function ($m) use ($etype, $offset) {
                    $num = (int) $m[1];
                    if ($num >= $offset) {
                        return 'et_pb_' . $etype . '_' . ($num - $offset);
                    }
                    return $m[0];
                },
                $html
            );
        }

        # Remove duplicate JS variable declarations from the shortcode framework
        foreach (['et_pb_custom', 'et_frontend_scripts', 'et_builder_utils_params'] as $var_name) {
            $needle = 'var ' . $var_name . ' = ';
            $first = strpos($html, $needle);
            if ($first !== false) {
                $second = strpos($html, $needle, $first + strlen($needle));
                if ($second !== false) {
                    $end = strpos($html, ";\n", $second);
                    if ($end !== false) {
                        $html = substr($html, 0, $second) . substr($html, $end + 2);
                    }
                }
            }
        }

        # Deduplicate animation data (shortcode framework creates duplicate entries)
        if (preg_match('/var diviElementAnimationData = (\[.*?\]);/s', $html, $am)) {
            $data = json_decode($am[1], true);
            if (is_array($data)) {
                $seen = [];
                $unique = [];
                foreach (array_reverse($data) as $e) {
                    $k = $e['class'] ?? '';
                    if ($k !== '' && !isset($seen[$k])) {
                        $seen[$k] = true;
                        array_unshift($unique, $e);
                    }
                }
                if (count($unique) < count($data)) {
                    $html = str_replace(
                        $am[0],
                        'var diviElementAnimationData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
                        $html
                    );
                }
            }
        }

        # Deduplicate multiview data
        if (preg_match('/var diviElementMultiViewData = (\[.*?\]);/s', $html, $mv)) {
            $data = json_decode($mv[1], true);
            if (is_array($data)) {
                $seen = [];
                $unique = [];
                foreach (array_reverse($data) as $e) {
                    $k = ($e['selector'] ?? '') . '|' . ($e['action'] ?? '');
                    if ($k !== '|' && !isset($seen[$k])) {
                        $seen[$k] = true;
                        array_unshift($unique, $e);
                    }
                }
                if (count($unique) < count($data)) {
                    $html = str_replace(
                        $mv[0],
                        'var diviElementMultiViewData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';',
                        $html
                    );
                }
            }
        }

        return $html;
    }


}
```
