# metasync/2.7.2/otto/Otto_html_class.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version 2.7.2. 4,542 lines.

- Page: https://pluginprobe.com/plugins/metasync/2.7.2/code/otto/Otto_html_class.php
- Raw: https://pluginprobe.com/plugins/metasync/2.7.2/raw/otto/Otto_html_class.php
- Modified: 2026-10-07T12:05:24+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.7.2/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{

    # Marker announcing that OTTO processed this <head>. Deliberately a comment
    # rather than an attribute on the tag — see mark_head_optimized().
    const HEAD_OPTIMIZED_TOKEN  = 'metasync_optimized';
    const HEAD_OPTIMIZED_MARKER = '<!--metasync_optimized-->';

    # 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);
    }

    /**
     * Quote unquoted attribute values before DOM parsing.
     *
     * Per the HTML spec an unquoted attribute value ends at whitespace, so
     * browsers read this correctly:
     *
     *   data-autoplay-viewport=80%
     *   data-mousewheel="false"
     *   data-type="carousel"
     *
     * SimpleHtmlDom does not stop at the whitespace. It runs to the next quote
     * character, so the value becomes
     * `80%\n data-mousewheel="false"\n data-type="carousel"...` and EVERY
     * following attribute on the tag is swallowed into it and lost.
     *
     * Supreme Modules' image carousel emits exactly that markup. The attributes
     * lost after it include data-type, data-slider-orientation and
     * data-slideshow-to-show, so Swiper initialises on defaults, stacks all
     * slides and grows the page by one slide-set per autoplay tick until it is
     * tens of thousands of pixels tall.
     *
     * Normalising to `data-autoplay-viewport="80%"` before parsing makes the
     * markup unambiguous, so no attribute is lost. Already-quoted values are
     * left untouched, and <script>/<style> bodies are shielded so their
     * contents are never mistaken for tags.
     *
     * @param string $html Raw HTML before DOM parsing.
     * @return string HTML with every unquoted attribute value quoted.
     */
    private function quote_unquoted_attribute_values($html) {
        # Shield <script>/<style> bodies — their contents can contain '<' and '='
        # and must never be treated as markup.
        $protected = array();
        $html = preg_replace_callback(
            '/<(script|style)\b([^>]*)>(.*?)<\/\1>/is',
            function ($m) use (&$protected) {
                $token = '<!--METASYNC_ATTRQ_' . count($protected) . '-->';
                $protected[$token] = $m[0];
                return $token;
            },
            $html
        );

        # Walk real tags only. The alternation lets a quoted value contain '>'
        # so the tag boundary is found correctly. Possessive quantifiers (*+ / ++)
        # are deliberate: the three branches are mutually exclusive on their first
        # character, so backtracking can never produce a different match, and
        # forbidding it removes the catastrophic-backtracking risk this shape
        # otherwise carries on markup with many quotes and no '>'.
        $result = preg_replace_callback(
            '/<([a-zA-Z][a-zA-Z0-9:-]*+)((?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*+)>/s',
            function ($m) {
                if (strpos($m[2], '=') === false) {
                    return $m[0];
                }

                $attrs = $this->quote_unquoted_values_in_attr_region($m[2]);

                # null means nothing needed changing — return the ORIGINAL tag so
                # a document of well-formed HTML comes back byte-identical rather
                # than merely equivalent.
                return $attrs === null ? $m[0] : '<' . $m[1] . $attrs . '>';
            },
            $html
        );

        if ($result === null) {
            $result = $html;
        }

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

        return $result;
    }

    /**
     * Quote the unquoted values inside one tag's attribute region.
     *
     * Must be a tokeniser, not a regex. A flat pattern over the region cannot
     * tell an attribute from ordinary text inside an already-quoted value, and
     * would wreck common markup — `content="width=device-width,
     * initial-scale=1"` looks like it contains an `initial-scale=1` attribute.
     * Quoted values are therefore copied through verbatim and never inspected.
     *
     * @param string $attrs Attribute region: everything between the tag name and '>'.
     * @return string|null Rewritten region, or null when nothing needed changing.
     */
    private function quote_unquoted_values_in_attr_region($attrs) {
        $len     = strlen($attrs);
        $out     = '';
        $i       = 0;
        $changed = false;

        while ($i < $len) {
            $c = $attrs[$i];

            # Whitespace between attributes — preserve exactly.
            if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $c === "\f" || $c === "\v") {
                $out .= $c;
                $i++;
                continue;
            }

            # Attribute name.
            if (preg_match('/\G[a-zA-Z_:][a-zA-Z0-9_:.\-]*+/', $attrs, $nm, 0, $i)) {
                $out .= $nm[0];
                $i   += strlen($nm[0]);

                # Look past optional whitespace for '='. No '=' means a valueless
                # boolean attribute; leave it alone.
                $j = $i;
                while ($j < $len && strpos(" \t\n\r\f\v", $attrs[$j]) !== false) {
                    $j++;
                }
                if ($j >= $len || $attrs[$j] !== '=') {
                    continue;
                }

                $out .= substr($attrs, $i, $j - $i) . '=';
                $i    = $j + 1;

                # Whitespace after '='.
                while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) !== false) {
                    $out .= $attrs[$i];
                    $i++;
                }

                if ($i >= $len) {
                    continue;
                }

                # Quoted value — copy verbatim through the closing quote so its
                # contents are never treated as attributes.
                if ($attrs[$i] === '"' || $attrs[$i] === "'") {
                    $quote = $attrs[$i];
                    $end   = strpos($attrs, $quote, $i + 1);
                    if ($end === false) {
                        $out .= substr($attrs, $i);
                        $i    = $len;
                    } else {
                        $out .= substr($attrs, $i, $end - $i + 1);
                        $i    = $end + 1;
                    }
                    continue;
                }

                # Unquoted value — runs to the next whitespace. ('>' cannot occur:
                # it would have ended the tag before this region.)
                $start = $i;
                while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) === false) {
                    $i++;
                }
                $value = substr($attrs, $start, $i - $start);

                if ($value === '') {
                    continue;
                }

                # Only rewrite when it actually prevents a loss. SimpleHtmlDom
                # mis-parses an unquoted value by running from it to the NEXT
                # QUOTE CHARACTER, swallowing everything between. With no quote
                # left in this region there is nothing to swallow and the value
                # already parses correctly — so leave that markup byte-identical
                # rather than churn it. (OceanWP's `style=color:#;` as the final
                # attribute is the common benign case.)
                $rest = substr($attrs, $i);
                if (strpos($rest, '"') === false && strpos($rest, "'") === false) {
                    $out .= $value;
                    continue;
                }

                $out    .= '"' . str_replace('"', '&quot;', $value) . '"';
                $changed = true;
                continue;
            }

            # Anything else (stray punctuation, a '/' before '>', malformed input)
            # — copy through untouched.
            $out .= $c;
            $i++;
        }

        return $changed ? $out : null;
    }

    /**
     * / 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;
    }

    /**
     * Build the new inner HTML for a heading whose text OTTO is replacing,
     * preserving the author's inline formatting.
     *
     * Headings are matched by their stripped text, so the recommended value is
     * plain text with no way to know how it maps onto the original markup.
     * Writing it back directly would discard whatever was inside — a coloured
     * <span>, <em>, <br> line breaks — and visibly restyle the page.
     *
     * Two cases are safe:
     *   - the heading is already plain text        -> replace it
     *   - the heading is ONE nested inline wrapper -> keep the wrappers,
     *     chain around a single run of text           swap only the text
     *
     * Anything else is ambiguous: sibling elements, <br>-separated lines, or
     * text mixed with elements have no single place the new sentence belongs.
     * Those return null so the caller leaves the heading alone — an unapplied
     * suggestion is better than silently restyling the customer's content.
     *
     * @param string $inner_html       Current inner HTML of the heading.
     * @param string $replacement_text New text, already escaped by the caller.
     * @return string|null New inner HTML, or null to skip this heading.
     */
    public static function build_heading_inner_html($inner_html, $replacement_text) {
        $inner = trim($inner_html);

        # Plain text — nothing to preserve.
        if (strpos($inner, '<') === false) {
            return $replacement_text;
        }

        # Inline elements whose wrapper carries the styling worth keeping.
        # Deliberately excludes <br>: it encodes line structure, not styling,
        # and its presence means the heading has more than one text run.
        $inline = 'span|em|strong|b|i|u|a|small|mark|abbr|cite|q|s|sub|sup|font';

        $open  = '';
        $close = '';

        # Peel wrappers outermost-in. The greedy (.*) means a heading with
        # sibling elements (e.g. <em>A</em><br><em>B</em>) leaves stray tags in
        # $inner, which the ambiguity check below then rejects.
        while (preg_match('#^<(' . $inline . ')(\s[^>]*)?>(.*)</\1\s*>$#is', $inner, $m)) {
            # Group 2 (the attribute list) is optional but always captured as ''
            # when absent, because group 3 follows it.
            $open .= '<' . $m[1] . $m[2] . '>';
            $close = '</' . $m[1] . '>' . $close;
            $inner = trim($m[3]);

            if (strpos($inner, '<') === false) {
                return $open . $replacement_text . $close;
            }
        }

        # Still markup left after peeling — ambiguous, so don't touch it.
        return null;
    }

    /**
     * / 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;
    }

    /**
     * Mark the <head> as OTTO-processed without altering the <head> tag itself.
     *
     * This marker used to be an attribute — <head metasync_optimized> — which
     * removed the literal string "<head>" from the document. Anything that
     * injects into the head with a literal str_replace('<head>', ...) then found
     * no match on the real tag and silently matched the *next* literal "<head>"
     * further down the page. That next one can sit inside a JS comment, a <pre>
     * block or escaped documentation text; injecting a <script>...</script> there
     * closes the enclosing script early, strands the rest of its code as a text
     * node in <head>, and the browser therefore ends <head> at that point —
     * pushing title, canonical, OG/Twitter and JSON-LD into <body>, where
     * crawlers and social scrapers ignore them.
     *
     * A comment placed immediately after the tag carries the same signal and
     * leaves the tag byte-identical. Note that an HTML minifier may strip
     * comments, so treat the <meta name="otto"> tag as the durable marker; this
     * one is a convenience for support and QA.
     *
     * Only the first <head> is marked. The real tag is always the first one in
     * the document, so this pass cannot wander the way a bare literal match can.
     *
     * @param string $html Serialized HTML.
     * @return string
     */
    private function mark_head_optimized($html)
    {
        # Idempotent, and also a no-op on pages still carrying the legacy
        # attribute form (e.g. HTML already in a page cache).
        if ($html === '' || strpos($html, self::HEAD_OPTIMIZED_TOKEN) !== false) {
            return $html;
        }

        $out = preg_replace(
            '/(<head(?:\s[^>]*)?>)/i',
            '$1' . self::HEAD_OPTIMIZED_MARKER,
            $html,
            1
        );

        # Belt-and-suspenders: if PCRE 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()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
            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); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
            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();
			if (function_exists('metasync_otto_report_render_failure')) {
			    metasync_otto_report_render_failure('HTTP_FETCH_FAILED', 'Internal fetch failed: ' . $error_msg, [], 'warning');
			}
			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);

        // Requests follows redirects and returns the final body with its final
        // status. Do not put that body on the original route when a redirect
        // changed the URL: doing so creates a soft redirect with mismatched
        // content and SEO metadata. The normal WordPress request can handle
        // the redirect, and OTTO will process the destination on its own URL.
        $effective_url = $this->get_effective_fetch_url($route_html);
        if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
            return false;
        }

        # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
        # body below and would otherwise discard this — but it is both the form/session
        # signal for the caching decision AND (same-origin only) the session the visitor
        # needs for their forms to work. $request_body is the URL actually fetched (it may
        # be rewritten to localhost for tunnels), so only allow pass-through when its
        # scheme+host match the visitor's; otherwise we would set a wrong-domain cookie.
        if (class_exists('Metasync_Otto_Render_Strategy')) {
            $fetch_set_cookie = wp_remote_retrieve_header($route_html, 'set-cookie');
            $fetch_parts      = wp_parse_url($request_body);
            $visitor_host     = preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'] ?? '');
            $same_origin      = !empty($fetch_parts['host'])
                && strcasecmp($fetch_parts['host'], $visitor_host) === 0
                && ((($fetch_parts['scheme'] ?? '') === 'https') === (bool) is_ssl());
            Metasync_Otto_Render_Strategy::set_http_fetch_cookie($fetch_set_cookie, $same_origin);

            # Also record the internal response's own `Cache-Control`. When the page's
            # render starts a PHP session, PHP emits its session cache-limiter headers
            # (`no-store, no-cache, must-revalidate`) — and it does so whether the session
            # is new OR resumed, so this catches session pages that set no NEW cookie and
            # would otherwise look cacheable. It is read as a signal only and never
            # forwarded to the visitor: our own no-cache request header is not echoed into
            # the response, so this cannot make ordinary pages uncacheable.
            Metasync_Otto_Render_Strategy::set_http_fetch_cache_control(
                wp_remote_retrieve_header($route_html, 'cache-control')
            );
        }


        # check not empty
        if(empty($html_body) || $response_code !== 200){
			if (function_exists('metasync_otto_report_render_failure')) {
			    metasync_otto_report_render_failure('HTTP_FETCH_EMPTY', 'Internal fetch returned empty body or non-200 status (HTTP ' . intval($response_code) . ')', [], 'warning');
			}
            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);

        # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
        # SimpleHtmlDom swallows every following attribute into such a value.
        $html_body = $this->quote_unquoted_attribute_values($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(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
                        if ($inner_text !== $current_value) {
                            return $m[0];
                        }
                        # Keep the author's inline formatting; skip when it can't
                        # be preserved rather than flattening the heading.
                        $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
                        if ($new_inner === null) {
                            return $m[0];
                        }
                        return $m[1] . $new_inner . $m[3];
                    },
                    $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'])) {
            # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
            # path inserts the same bytes, so the already-applied check must compare
            # against the stamped form or it would insert a second copy.
            $header_html_insertion = $this->stamp_otto_json_ld(
                metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
            );
            $header_html_check = trim($header_html_insertion);
            if (strpos($result_html, $header_html_check) === false) {
                $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_keywords_tags($result_html), $result_html, 'deduplicate_keywords_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->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_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');

        # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
        if (!$this->is_amp_page()) {
            $result_html = $this->mark_head_optimized($result_html);
        }

        $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');

        # restore literal '&' query-string separators the serializer escaped to
        # '&amp;', which silently truncates multi-family Google Fonts URLs.
        $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');

        # 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;
    }

    /**
     * Read the final URL from the WordPress HTTP response when Requests has
     * followed one or more redirects. Older HTTP transports may not expose it;
     * in that case the empty result leaves the existing fail-open behavior.
     *
     * @param mixed $response wp_remote_get() response.
     * @return string Final URL, or an empty string when unavailable.
     */
    private function get_effective_fetch_url($response)
    {
        if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
            return '';
        }

        $http_response = $response['http_response'];
        if (!method_exists($http_response, 'get_response_object')) {
            return '';
        }

        $requests_response = $http_response->get_response_object();
        if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
            return '';
        }

        return $requests_response->url;
    }

    /**
     * Determine whether a followed response still represents the requested
     * route. The internal fetch may use a rewritten host for tunnel support,
     * so both the original and transport hosts are accepted; the path and
     * non-OTTO query parameters must remain identical.
     *
     * @param string $effective_url Final URL reported by Requests.
     * @param string $route         Original route supplied to OTTO.
     * @param string $fetch_url     URL actually passed to wp_remote_get().
     * @return bool True when no meaningful redirect changed the route.
     */
    private function fetch_url_matches_route($effective_url, $route, $fetch_url)
    {
        $effective = wp_parse_url($effective_url);
        $requested = wp_parse_url($route);
        $transport = wp_parse_url($fetch_url);
        if (!is_array($effective) || !is_array($requested)) {
            return true;
        }

        $effective_path = isset($effective['path']) ? $effective['path'] : '/';
        $requested_path = isset($requested['path']) ? $requested['path'] : '/';
        if ($effective_path !== $requested_path) {
            return false;
        }

        $allowed_hosts = [];
        foreach ([$requested, $transport] as $parts) {
            if (!empty($parts['host'])) {
                $host = strtolower($parts['host']);
                if (!empty($parts['port'])) {
                    $host .= ':' . $parts['port'];
                }
                $allowed_hosts[] = $host;
            }
        }
        if (!empty($effective['host'])) {
            $effective_host = strtolower($effective['host']);
            if (!empty($effective['port'])) {
                $effective_host .= ':' . $effective['port'];
            }
            if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
                return false;
            }
        }

        return $this->without_otto_fetch_params($effective['query'] ?? '')
            === $this->without_otto_fetch_params($requested['query'] ?? '');
    }

    /**
     * Remove parameters used only to mark OTTO's internal request.
     *
     * @param string $query Query string without the leading '?'.
     * @return string Query with OTTO markers removed.
     */
    private function without_otto_fetch_params($query)
    {
        $query = (string) $query;
        if ($query === '') {
            return '';
        }

        $parts = [];
        foreach (explode('&', $query) as $part) {
            $name = strstr($part, '=', true);
            $name = $name === false ? $part : $name;
            if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
                continue;
            }
            $parts[] = $part;
        }

        return implode('&', $parts);
    }

    /**
     * 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);
    }

    /**
     * Restore literal '&' query-string separators inside URL attributes.
     *
     * HtmlNode::makeup() serialises every attribute through htmlentities(), so a
     * separator that arrived as a bare '&' - or as '&#038;', which HtmlDocument
     * decodes to '&' when it parses the attribute - is written back as '&amp;'.
     * Both forms decode to '&' in a conforming HTML parser, so the browser is
     * unaffected. A consumer that reads the raw attribute WITHOUT decoding
     * entities sees a different URL, and Cloudflare Fonts is one of those: it
     * refetches fonts.googleapis.com/css2?family=A&amp;family=B, Google treats
     * only the first 'family=' as a real parameter (the rest arrive named
     * 'amp;family'), and every font after the first is dropped from the response.
     * The page keeps its font-family declarations but loses the @font-face rules,
     * so text silently falls back to a system font.
     *
     * Rewrites only a '&amp;' that is immediately followed by a parameter name
     * and '=', and only inside the listed URL attributes. A '&amp;' elsewhere is
     * left escaped, and the mandatory '=' in the lookahead (with ';' excluded
     * from the name class) means no ambiguous ampersand ('&' + alphanumerics +
     * ';') is ever produced.
     *
     * All three delimiter forms the serializer can emit are handled: double
     * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and
     * emits no delimiter for a value with no whitespace, which is common on
     * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted
     * value may legitimately contain an apostrophe.
     *
     * The attribute list stops at attributes whose value is always a URL.
     * 'content' is deliberately excluded because it also carries prose
     * (<meta name="description" content="A &amp; B">), where unescaping would
     * corrupt visible text. Attributes read only via JavaScript getAttribute()
     * do not need it either - the DOM decodes entities for them; only consumers
     * that scan raw HTML are affected.
     *
     * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded
     * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes
     * there would edit a JS string literal, or turn "&amp;copy=2" in a textarea
     * into "(c)=2" once the browser decodes it.
     *
     * Plain negated character classes only - no tempered-greedy lookaheads, which
     * blow the PCRE JIT stack on pages with very large inline blocks.
     *
     * @param string $html Serialized HTML from save()/outertext.
     * @return string
     */
    private function repair_query_string_ampersands($html) {
        # strpos guard: skip the regex entirely when there is nothing to repair
        if (strpos($html, '&amp;') === false) {
            return $html;
        }

        # Shield verbatim / RCDATA regions and comments before touching anything.
        # Masking also shrinks large inline blocks to a token, so the attribute
        # pass never walks them.
        $shielded = array();
        $masked = preg_replace_callback(
            '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is',
            function ($m) use (&$shielded) {
                $token = '<!--METASYNC_QSA_' . count($shielded) . '-->';
                $shielded[$token] = $m[0];
                return $token;
            },
            $html
        );

        # preg_* returns null on a PCRE failure; keep the original document.
        if ($masked === null) {
            return $html;
        }

        $repaired = preg_replace_callback(
            '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i',
            function ($m) {
                # Pick the delimiter branch that matched. An empty value carries
                # no query string, so the ambiguity between an empty quoted value
                # and an unset group cannot change the outcome.
                if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones)
                    $quote = '';
                    $value = $m[4];
                } elseif (isset($m[3]) && $m[3] !== '') {
                    $quote = "'";
                    $value = $m[3];
                } else {
                    $quote = '"';
                    $value = isset($m[2]) ? $m[2] : '';
                }

                # Only a value carrying a query string can hold separators.
                if (strpos($value, '?') === false || strpos($value, '&amp;') === false) {
                    return $m[0];
                }

                $fixed = preg_replace('/&amp;(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value);

                if ($fixed === null) {
                    return $m[0];
                }

                return $m[1] . $quote . $fixed . $quote;
            },
            $masked
        );

        if ($repaired === null) {
            return $html;
        }

        return empty($shielded) ? $repaired : strtr($repaired, $shielded);
    }

    # 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 ?? '')){

                    # Rebuild the inner HTML, keeping the author's inline
                    # formatting. Returns null when the markup can't be preserved
                    # unambiguously — leave that heading alone rather than strip
                    # its <span style>/<em>/<br> and restyle the page.
                    $new_inner = self::build_heading_inner_html(
                        $heading_old->innertext,
                        $heading['recommended_value']
                    );

                    if ($new_inner === null) {
                        continue;
                    }

                    # replace the tag output, preserving the opening tag's attributes
                    $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 . $new_inner . $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 = wp_parse_url($home_url);
            $parsed_link = wp_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>';
    }

    /**
     * Resolve the current singular post id reliably inside the OTTO output
     * buffer / direct-render context.
     *
     * get_the_ID() depends on the global $post/loop state, which is not reliable
     * once WordPress has finished rendering and the buffer/shutdown pass runs.
     * get_queried_object_id() reads the parsed main query and stays correct there
     * (same approach as apply_metabox_og_precedence()). Restricted to
     * singular views: on archives/home the queried object id can be a term or
     * user id, which must not be read as post meta.
     *
     * @return int Post id, or 0 when not applicable.
     */
    private function get_buffer_post_id() {
        if (!function_exists('is_singular') || !is_singular()) {
            return 0;
        }
        $post_id = function_exists('get_queried_object_id') ? get_queried_object_id() : 0;
        return $post_id ? (int) $post_id : 0;
    }

    /**
     * Replace the first <meta $attr="$val"> content with $content_escaped.
     *
     * @param string $html
     * @param string $attr          "name" or "property"
     * @param string $val           e.g. "description", "og:description"
     * @param string $content_escaped Already HTML-escaped content value
     * @param string $marker        Provenance marker for the winning tier
     * @return string
     */
    private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
        $marker_attribute = $marker === 'otto'
            ? ' data-metasync-otto="true"'
            : ' data-metasync-seo="custom"';
        $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
        # The optional prefix group must end on whitespace or a quote so the
        # attribute matched is $attr itself, not something merely ending in it:
        # a bare [^>]*name= also matches data-name="keywords", so a third party's
        # unrelated tag was consumed and rewritten while the real one survived —
        # destroying their value AND leaving the duplicate the dedup had just
        # collapsed. Making the group optional keeps the common
        # <meta name="..." ...> form matching, where <meta\s has already consumed
        # the only separator. Same rule as deduplicate_keywords_tags(); this
        # helper serves title, description and keywords, so all three are covered.
        $pattern = '/<meta\s(?:[^>]*[\s"\'])?' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
        $count = 0;
        $new = preg_replace_callback($pattern, function ($m) use ($tag) {
            return $tag;
        }, $html, 1, $count);
        if ($count > 0) {
            return $new;
        }
        # None present — insert after <head>.
        return preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($tag) {
            return $m[1] . "\n" . $tag;
        }, $html, 1);
    }

    /**
     * Make sure the precedence resolver is loaded before calling it.
     *
     * @return bool
     */
    private static function precedence_available() {
        if (class_exists('Metasync_Seo_Precedence')) {
            return true;
        }

        $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
        if (is_readable($file)) {
            require_once $file;
        }

        return class_exists('Metasync_Seo_Precedence');
    }

    /**
     * Enforce the resolved SEO title, description and keywords as the FINAL
     * word over OTTO.
     *
     * The resolver owns the global custom-versus-OTTO order. This final buffer
     * pass must use it too: otherwise the setting works through the sidebar
     * and third-party filters but OTTO's later string pass puts the custom
     * value back on the page.
     *
     * OG and Twitter descriptions remain outside this method deliberately;
     * social precedence is a separate follow-up scope.
     *
     * @param  string $html Full HTML document (post-dedup).
     * @return string
     */
    private function apply_custom_seo_precedence($html) {
        if ($html === '') {
            return $html;
        }
        $post_id = $this->get_buffer_post_id();
        if (!$post_id) {
            return $html;
        }

        # This runs inside the output buffer, where an unresolvable class is a
        # fatal that takes the whole page down — otto_guard_html() only catches
        # a NULL return, not a thrown Error. A partial update can leave newer
        # PHP files beside an older committed autoload classmap, so resolve the
        # file directly before trusting the class, and leave the document
        # untouched if it is genuinely absent. Same reasoning as
        # Metasync_SEO_Conflict_Handler::precedence_available().
        if (!self::precedence_available()) {
            return $html;
        }

        # Title — replace the resolved page title into the (single, post-dedup) <title>.
        $resolved_title = Metasync_Seo_Precedence::resolve(
            $post_id,
            Metasync_Seo_Precedence::FIELD_TITLE
        );
        if (!empty($resolved_title['value'])) {
            $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
            $count = 0;
            $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
                return $title_tag;
            }, $html, 1, $count);
            if ($count > 0) {
                $html = $replaced;
            } elseif (stripos($html, '<title') === false) {
                $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
                    return $m[1] . "\n" . $title_tag;
                }, $html, 1);
            }
        }

        # Description — replace the resolved page description into the SEO
        # <meta name="description"> only. OG/Twitter description is social
        # precedence and remains outside this setting's scope.
        $resolved_description = Metasync_Seo_Precedence::resolve(
            $post_id,
            Metasync_Seo_Precedence::FIELD_DESCRIPTION
        );
        if (!empty($resolved_description['value'])) {
            $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
            $marker = Metasync_Seo_Precedence::is_otto_value(
                $post_id,
                Metasync_Seo_Precedence::FIELD_DESCRIPTION,
                $resolved_description
            )
                ? 'otto'
                : 'custom';
            $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
        }

        # Keywords — the same final pass as the description above, on the chain
        # the resolver owns, so the global priority setting governs this tag
        # too. OTTO's keywords tag arrives via header_html_insertion carrying
        # a data-otto marker and is spliced in after wp_head, so it is always
        # the later tag and always wins deduplicate_keywords_tags(); forcing
        # the resolved winner here is what keeps the losing tier reachable at
        # all — the single-printing-owner rule the description branch follows.
        #
        # The tier this forces is NOT guaranteed customer-typed, unlike the
        # title and description keys above: the meta_keywords persistence flag
        # writes OTTO's value straight to _metasync_focus_keyword, and neither
        # that conditional write nor the clone cleaner (which strips only the
        # _metasync_otto_* keys) ever clears it, so a stale flag copy can
        # outrank a newer suggestion under custom-first. That key sharing is
        # tracked separately — the fix is a key of its own for the persisted
        # keyword, not a special case here.
        $resolved_keyword = Metasync_Seo_Precedence::resolve(
            $post_id,
            Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD
        );
        if (!empty($resolved_keyword['value'])) {
            $esc = htmlspecialchars($resolved_keyword['value'], ENT_QUOTES, 'UTF-8');
            $marker = Metasync_Seo_Precedence::is_otto_value(
                $post_id,
                Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD,
                $resolved_keyword
            )
                ? 'otto'
                : 'custom';
            $html = $this->force_custom_meta($html, 'name', 'keywords', $esc, $marker);
        }

        return $html;
    }

    # 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;
    }

    /**
     * Read one of the meta box's social title/description keys with the "Auto Draft"
     * placeholder collapsed to ''.
     *
     * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
     * place. Falls back to a raw read when that class — or that method on it — is
     * unavailable: this buffer filter runs on the front end, and a partially updated
     * install can pair a newer otto/ file with an older includes/ one, where calling
     * a method the loaded class doesn't define would fatal the page rather than
     * degrade.
     *
     * @param  int    $post_id
     * @param  string $key
     * @return string
     */
    private function social_meta($post_id, $key) {
        // PHPStan resolves the literal class-string against the current source and so
        // sees the method as always present; the check is deliberate runtime
        // version-skew defence. Suppressed the same way otto_pixel.php does.
        // @phpstan-ignore-next-line function.alreadyNarrowedType
        if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
            return Metasync_OpenGraph::get_social_meta($post_id, $key);
        }
        return (string) get_post_meta($post_id, $key, true);
    }

    /**
     * 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.
        #
        # The four social title/description keys are read through
        # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
        # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
        # brand-new post reads as a deliberate override here (it differs from the real
        # title) and would beat OTTO's correct og:title.
        $og_title_set     = $this->social_meta($post_id, '_metasync_og_title');
        $og_desc_set      = $this->social_meta($post_id, '_metasync_og_description');
        $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     = $this->social_meta($post_id, '_metasync_twitter_title');
        $tw_desc_set      = $this->social_meta($post_id, '_metasync_twitter_description');
        $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.
        #
        # When a third-party SEO plugin is active the conflict handler may have suppressed
        # that plugin's og:title / twitter:title so MetaSync can supply the replacement
        # (Metasync_SEO_Sidebar::output_seo_meta_description). Gap-filling those two tags
        # here would inject the meta-box post-title default over that replacement, so skip
        # them in that case. User-customized overrides are unaffected — they were already
        # applied above.
        $seo_plugin_active = class_exists('Metasync_SEO_Conflict_Handler')
            && Metasync_SEO_Conflict_Handler::get_instance()->has_active_seo_plugin();

        $additions = '';
        foreach ($tags as $t) {
            list($attr, $key, $val, $is_url, $is_override) = $t;
            if ($val === '' || $val === null) {
                continue;
            }
            if (!$is_override && $seo_plugin_active && ($key === 'og:title' || $key === 'twitter:title')) {
                continue; # never gap-fill the post title over a third-party plugin's own title
            }
            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];

        # Choose the keeper by precedence: custom sidebar → OTTO → first.
        # A user-set SEO sidebar value (data-metasync-seo="custom") must win over
        # OTTO, matching deduplicate_description_tags() and the sidebar's documented
        # "custom always wins over OTTO" intent. Without this, the generic keeper
        # (OTTO-or-first) drops the sidebar's og:/twitter: description in favor of
        # OTTO's injected tag.
        $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') !== false) {
                $otto_tag = $tag;
            }
        }

        # Determine the keeper: custom sidebar tag, else OTTO tag, else the first tag
        $keeper = $custom_tag ?: ($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 <meta name="keywords"> tags from HTML.
     *
     * OTTO ships its keywords tag inside header_html_insertion, and MetaSync's
     * own SEO output now emits one too whenever the precedence chain resolves a
     * keyword — so a server-side-rendered page can carry both.
     *
     * The keeper is OTTO's tag, or the first occurrence when no OTTO tag is
     * present. Unlike deduplicate_description_tags() there is no
     * customer-authored branch here: print_metatag() emits no data attribute, so
     * a keywords tag never carries data-metasync-seo="custom" at this point in
     * the pipeline. The customer tier is re-applied afterwards by
     * apply_custom_seo_precedence(), which runs later and stamps the marker via
     * force_custom_meta(). Collapsing to OTTO's tag here is therefore safe: it is
     * not the final word on what the page renders.
     *
     * @param  string $html Full HTML document.
     * @return string HTML with at most one <meta name="keywords">.
     */
    private function deduplicate_keywords_tags($html) {
        # No is_string() guard, unlike deduplicate_description_tags() beside it.
        # That asymmetry is forced, not an oversight: @param string makes the
        # check provably always-true at PHPStan level 5, and the description
        # sibling's copy is one of a fixed number of occurrences the baseline
        # pins by count, so adding another fails the build. Both methods are
        # only ever called with the buffer string.
        if ($html === '') {
            return $html;
        }

        # Match <meta ... name="keywords" ...> in either attribute order.
        #
        # The optional prefix group must end on whitespace or a quote so the
        # attribute is `name`, not something merely ending in it: a bare
        # [^>]*name= also matches data-name="keywords". Making the group
        # optional keeps the common `<meta name="keywords" ...>` form matching,
        # where <meta\s has already consumed the only separator.
        #
        # The opening quote must be immediately followed by "keywords", so
        # name="news_keywords" is not matched.
        $pattern = '/<meta\s(?:[^>]*[\s"\'])?name\s*=\s*["\']keywords["\'][^>]*\/?>/i';

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

        $all_tags = $matches[0];

        $otto_tag = null;
        foreach ($all_tags as $tag) {
            if (stripos($tag, 'data-otto-pixel') !== false ||
                stripos($tag, 'data-metasync-otto') !== false ||
                stripos($tag, 'data-otto') !== false
            ) {
                $otto_tag = $tag;
                break;
            }
        }
        $keeper = $otto_tag ?: $all_tags[0];

        # Callback form avoids backreference injection when the keyword list
        # contains $ followed by digits.
        $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;
    }

    /**
     * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
     *
     * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
     * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
     * Collapsing such a node to its first token hides it from a plain-typed
     * duplicate, so every token is returned and indexed separately.
     *
     * @param  mixed $raw The raw `@type` value.
     * @return string[]   Unique lowercased tokens, possibly empty.
     */
    private function schema_type_tokens($raw) {
        $tokens = [];

        foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
            if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
                continue;
            }
            $type = strtolower(trim((string) $type));
            if ($type === '') {
                continue;
            }
            $tokens[$type] = true;
        }

        return array_keys($tokens);
    }

    /**
     * Deduplicate JSON-LD schema blocks.
     *
     * Two nodes collide when they share an `@id`, or when one claims an `@type`
     * token the page already carries. Collisions resolve strictly in OTTO's
     * favour:
     *
     *  - an OTTO node (marked `data-otto`) displaces every colliding
     *    third-party node, whichever appeared first in the document;
     *  - two OTTO nodes describing the same entity keep the first;
     *  - two third-party nodes never evict each other, even when they share a
     *    type: a Yoast graph legitimately carries two distinct ImageObjects,
     *    and MetaSync's own breadcrumb / local-business blocks are third-party
     *    output this pass has no mandate to fold.
     *
     * When OTTO contributed no node at all the document comes back untouched —
     * a third-party-only page is not ours to rewrite. Edits are offset splices
     * into the original document, so a block that loses nothing keeps its exact
     * bytes (attributes, `class`, `@context`, position — head, body or footer)
     * and nothing is ever relocated.
     *
     * Blocks are left exactly where they are when their JSON does not parse,
     * when they carry neither `@type` nor `@graph`, or when they sit inside an
     * HTML comment or <noscript> — a schema node the site disabled by wrapping
     * it in a comment must stay disabled, not be re-published by this pass.
     *
     * @param  string $html Full HTML.
     * @return string
     */
    private function deduplicate_schema_tags($html) {
        // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
        // capturing only the part before `type=` would hide a `data-otto` marker
        // that sits after it, which is where every insertion path puts it. The
        // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
        // is recognised too.
        $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 | PREG_OFFSET_CAPTURE)) {
            return $html;
        }

        // Byte ranges this pass must not reach into. A commented-out (or
        // <noscript>-wrapped) schema block is deliberately disabled output.
        $masked = [];
        if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
            foreach ($comments as $comment) {
                $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
            }
        }

        // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
        // `data-otto`. A substring test would also catch a hypothetical
        // `data-otto-thing="x"` and misattribute that block to OTTO.
        $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';

        // Blocks able to take part, and their entities in document order. An
        // entity is one addressable node: a flat block's document, one
        // `@graph` entry, or one node of a top-level array.
        $blocks   = [];
        $entities = [];

        foreach ($matches as $m) {
            $start = $m[0][1];
            foreach ($masked as $range) {
                if ($start >= $range[0] && $start < $range[1]) {
                    continue 2;
                }
            }

            $attrs    = $m[1][0];
            $json_str = $m[3][0];
            $decoded  = json_decode($json_str, true);
            if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
                continue; // unparseable — leave the block in place
            }

            $is_otto = preg_match($is_otto_attr, $attrs) === 1;
            $end     = $start + strlen($m[0][0]);

            // A trailing newline swallowed on full removal keeps the output tidy.
            $end_ws = $end;
            if (substr($html, $end, 2) === "\r\n") {
                $end_ws = $end + 2;
            } elseif (substr($html, $end, 1) === "\n") {
                $end_ws = $end + 1;
            }

            $register = function (array $node) use (&$entities, $is_otto) {
                // Keyed like metasync_otto_structured_entity_key(): @id first,
                // @type tokens for the type fold. Entries with neither stay in
                // their block but cannot collide, so they keep entity === null.
                $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
                $id    = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
                if ($types === [] && $id === '') {
                    return null;
                }
                $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
                return count($entities) - 1;
            };

            $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];

            if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
                foreach ($decoded['@graph'] as $node) {
                    $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
                }
                $block['shape'] = 'graph';
            } elseif (isset($decoded['@type'])) {
                $block['shape'] = 'flat';
                $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
            } elseif (isset($decoded[0])) {
                // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
                // persistence path already folds this shape; the render path
                // used to skip it entirely, leaving its duplicates standing.
                $block['shape'] = 'list';
                foreach ($decoded as $node) {
                    $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
                }
            } else {
                continue; // neither @type nor @graph nor array — nothing to key on
            }

            $blocks[] = $block;
        }

        foreach ($entities as $entity) {
            if ($entity['otto']) {
                $otto_present = true;
                break;
            }
        }
        if (empty($otto_present)) {
            return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
        }

        // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
        // node can displace a whole set of third-party duplicates at once.
        /** @var array<string, int> $by_id */
        $by_id   = []; // '@id:...' => entity index
        /** @var array<string, int[]> $by_type */
        $by_type = []; // type token => entity index[]

        $release = function ($i) use (&$by_id, &$by_type, &$entities) {
            foreach ($entities[$i]['types'] as $token) {
                $holders = $by_type[$token] ?? [];
                $holders = array_values(array_diff($holders, [$i]));
                if ($holders === []) {
                    unset($by_type[$token]);
                } else {
                    $by_type[$token] = $holders;
                }
            }
            $id = $entities[$i]['id'];
            if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
                unset($by_id[$id]);
            }
        };

        foreach ($entities as $i => $entity) {
            $alive = true;

            // The same real-world entity twice, whatever @type each copy carries.
            if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
                $occupant = $by_id[$entity['id']];
                if ($entity['otto'] && !$entities[$occupant]['otto']) {
                    $entities[$occupant]['alive'] = false;
                    $release($occupant);
                    unset($by_id[$entity['id']]); // re-claimed below if the entity survives
                } else {
                    $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
                }
            }

            if ($alive) {
                foreach ($entity['types'] as $token) {
                    $holders = $by_type[$token] ?? [];
                    if ($holders === []) {
                        continue;
                    }
                    $otto_holder = false;
                    foreach ($holders as $holder) {
                        if ($entities[$holder]['otto']) {
                            $otto_holder = true;
                            break;
                        }
                    }
                    if ($otto_holder) {
                        $alive = false; // an OTTO node already holds this token
                        break;
                    }
                    if ($entity['otto']) {
                        // OTTO displaces every third-party holder of the token and
                        // releases ALL their claims, not just this one — otherwise
                        // tokens the evicted node held alone would stay blocked and
                        // later, legitimate nodes of that type would lose.
                        foreach ($holders as $holder) {
                            $entities[$holder]['alive'] = false;
                            $release($holder);
                        }
                    }
                    // Third party meeting third-party holders: both stand.
                }
            }

            if ($alive) {
                foreach ($entity['types'] as $token) {
                    $by_type[$token][] = $i;
                }
                if ($entity['id'] !== '') {
                    $by_id[$entity['id']] = $i;
                }
            } else {
                $entities[$i]['alive'] = false;
            }
        }

        $any_evicted = false;
        foreach ($entities as $entity) {
            if (!$entity['alive']) {
                $any_evicted = true;
                break;
            }
        }
        if (!$any_evicted) {
            return $html; // no collision — every original byte stays
        }

        // Rebuild only the blocks that lost something, back-to-front so the
        // recorded offsets of earlier blocks stay valid.
        $edits = [];
        foreach ($blocks as $block) {
            $kept = [];
            $lost = 0;
            foreach ($block['items'] as $item) {
                if ($item['e'] === null || $entities[$item['e']]['alive']) {
                    $kept[] = $item['node'];
                } else {
                    $lost++;
                }
            }
            if ($lost === 0) {
                continue;
            }
            if ($kept === []) {
                $edits[] = [$block['start'], $block['end_ws'], ''];
                continue;
            }

            if ($block['shape'] === 'list') {
                $payload = $kept;
            } else { // graph — a flat block that lost its only entity is removed above
                $payload = $block['decoded'];
                $payload['@graph'] = $kept;
            }

            $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            if (!is_string($json) || $json === '') {
                continue; // never drop a block whose payload failed to re-encode
            }

            // The original attribute string is reused verbatim so class, id and
            // the data-otto marker itself survive the rebuild.
            $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
        }

        foreach (array_reverse($edits) as $edit) {
            $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[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') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
            return true;
        }
        
        # Check for other common AMP query parameters
        if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
            return true;
        }
        
        return false;
    }

    # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
    # tell them apart from a third-party SEO plugin's blocks. Every insertion path
    # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
    # bytes they insert are identical and the "already applied" checks still match.
    private function stamp_otto_json_ld($html){

        $stamped = preg_replace_callback(
            '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
            function ($m) {
                # idempotent: a block already carrying a data-otto attribute —
                # anywhere in the tag, not just before `type` — is left alone,
                # never given a second one
                if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
                    return $m[0];
                }
                return '<script' . $m[1] . ' data-otto="true">';
            },
            $html
        );

        # preg_replace returns null on a PCRE failure — never hand back less than we got
        return is_string($stamped) ? $stamped : $html;
    }

    # 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, skip the processed marker
            $is_amp_page = $this->is_amp_page();

            # Append the new HTML at the start of the <head> tag. The tag itself
            # stays a literal '<head>' either way, so plugins that inject with a
            # literal str_replace('<head>', ...) still match the real tag —
            # see mark_head_optimized() for what breaks when it does not.
            $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;

            # Escape JSON-LD payload closers so a `</script>` inside a JSON
            # string value cannot terminate the element early (XSS/page corruption).
            # Then stamp the blocks as OTTO's, exactly as the string and HTTP
            # fallbacks do, so the deduper can attribute them.
            $escaped_header_insertion = $this->stamp_otto_json_ld(
                metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
            );

            $head->outertext = '<head>' . $marker . $escaped_header_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);

            # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
            # SimpleHtmlDom swallows every following attribute into such a value.
            $html = $this->quote_unquoted_attribute_values($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'])) {
                # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
                # path inserts the same bytes, so the already-applied check must compare
                # against the stamped form or it would insert a second copy.
                $header_html_insertion = $this->stamp_otto_json_ld(
                    metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
                );
                $header_html_check = trim($header_html_insertion);
                if (strpos($result_html, $header_html_check) === false) {
                    $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(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
                            if ($inner_text !== $current_value) {
                                return $m[0];
                            }
                            # Keep the author's inline formatting; skip when it can't
                            # be preserved rather than flattening the heading.
                            $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
                            if ($new_inner === null) {
                                return $m[0];
                            }
                            return $m[1] . $new_inner . $m[3];
                        },
                        $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_keywords_tags($result_html), $result_html, 'deduplicate_keywords_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->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_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 = [];

            # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
            if (!$this->is_amp_page()) {
                $result_html = $this->mark_head_optimized($result_html);
            }

            $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');

            # restore literal '&' query-string separators the serializer escaped to
            # '&amp;', which silently truncates multi-family Google Fonts URLs.
            $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');

            # 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) {
            if (function_exists('metasync_otto_report_render_failure')) {
                metasync_otto_report_render_failure(
                    'MODIFICATION_EXCEPTION',
                    'OTTO rewrite threw: ' . $e->getMessage(),
                    ['exception' => get_class($e)],
                    'error'
                );
            }
            return false;
        } catch (Error $e) {
            if (function_exists('metasync_otto_report_render_failure')) {
                metasync_otto_report_render_failure(
                    'MODIFICATION_ERROR',
                    'OTTO rewrite errored: ' . $e->getMessage(),
                    ['error' => get_class($e)],
                    'error'
                );
            }
            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 = '';
                }
            }
        }

        # Preserve third-party og:title/twitter:title tags here. They used to be
        # stripped whenever $remove_title was set, but that also removes them
        # when no custom title exists to replace them, leaving pages with no
        # og:title/twitter:title at all. OTTO's own title/OG dedup and
        # precedence passes already reconcile duplicates, so this branch no
        # longer needs to delete those tags.

        # 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).
        #
        # IMPORTANT: never collapse "??". It is valid, load-bearing syntax for
        # file-concatenation endpoints — Jetpack Boost (/_jb_static/??<hash>) and
        # WordPress core (load-styles.php??... / load-scripts.php??...). Collapsing
        # "??" -> "?" points those at a different URL that returns HTTP 400 / empty,
        # so every affected CSS/JS bundle fails to load and the page renders broken
        # (Kadence + Jetpack Boost "defer non-essential CSS" was the reported case).
        # Removing an OTTO fetch param never produces a "??" anyway, so there is
        # nothing to normalize there.
        # (A previous implementation ran str_replace('??','?') over the ENTIRE
        # document, which additionally corrupted inline JavaScript nullish operators.)
        $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;
    }


}

```
