# metasync/2.6.22/includes/class-metasync-seo-conflict-handler.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version 2.6.22. 1,756 lines.

- Page: https://pluginprobe.com/plugins/metasync/2.6.22/code/includes/class-metasync-seo-conflict-handler.php
- Raw: https://pluginprobe.com/plugins/metasync/2.6.22/raw/includes/class-metasync-seo-conflict-handler.php
- Modified: 2026-08-13T00:12:54+00:00

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

```php
<?php
/**
 * Centralized SEO Plugin Conflict Handler
 *
 * Prevents duplicate meta descriptions when MetaSync coexists with
 * third-party SEO plugins (AIOSEO, Yoast, RankMath, etc.).
 *
 * Strategy:
 *   - When MetaSync (OTTO or sidebar) has a value → suppress the third-party plugin.
 *   - When MetaSync has NO value → let the third-party plugin output its own.
 *   - When NEITHER has a value → let MetaSync's legacy auto-generated description through.
 *
 * @package    MetaSync
 * @subpackage MetaSync/includes
 * @since      2.8.23
 */

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

class Metasync_SEO_Conflict_Handler {

    /**
     * Singleton instance.
     *
     * @var self|null
     */
    private static $instance = null;

    /**
     * Cached result for whether MetaSync has a description for the current page.
     *
     * @var bool|null
     */
    private $has_description_cache = null;

    /**
     * Cached result for whether AIOSEO provides a description for the current page.
     *
     * @var bool|null
     */
    private $aioseo_has_description_cache = null;

    /**
     * Cached result: whether the current post has been synced via.
     *
     * @var array Keyed by post_id => bool
     */
    private $sync_cache = [];

    /**
     * Cached result for whether OTTO has live transient-cached suggestions
     * for the current request URL.
     *
     * @var bool|null
     */
    private $live_suggestions_cache = null;

    /**
     * Get singleton instance.
     *
     * @return self
     */
    public static function get_instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Private constructor — use get_instance().
     */
    private function __construct() {
        // Only hook on the frontend
        if (is_admin()) {
            return;
        }

        add_action('wp', [$this, 'register_filters'], 0);
    }

    /**
     * Register filters after the query is parsed (so is_singular() etc. work).
     */
    public function register_filters() {
        if ($this->is_aioseo_active()) {
            $this->register_aioseo_filters();
        }

        // Ensure is_plugin_active() is available
        if (!function_exists('is_plugin_active')) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        if (is_plugin_active('wordpress-seo/wp-seo.php') ||
            is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
            $this->register_yoast_filters();
        }

        if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
            $this->register_rankmath_filters();
        }
    }

    // ------------------------------------------------------------------
    // Third-party SEO plugin detection
    // ------------------------------------------------------------------

    /**
     * Check whether AIOSEO (free or pro) is active.
     *
     * @return bool
     */
    public function is_aioseo_active() {
        // Ensure is_plugin_active() is available on the frontend
        if (!function_exists('is_plugin_active')) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
            || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
    }

    /**
     * Check whether any supported third-party SEO plugin is active.
     *
     * @return bool
     */
    public function has_active_seo_plugin() {
        // is_plugin_active() availability ensured by is_aioseo_active() call
        return $this->is_aioseo_active()
            || is_plugin_active('wordpress-seo/wp-seo.php')
            || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
            || is_plugin_active('seo-by-rank-math/rank-math.php')
            || is_plugin_active('seo-by-rankmath/rank-math.php');
    }

    /**
     * Whether an active third-party SEO plugin actually holds a
     * non-empty meta description for this post in its OWN storage.
     *
     * The native-first output guards previously stood down whenever a
     * sync timestamp (_metasync_plugin_sync_ts) existed, assuming the plugin
     * would render the description. But a stale or partial sync leaves the
     * plugin's field empty — MetaSync suppresses its own tag, the plugin has
     * nothing to emit, and the description is dropped entirely. Callers use this
     * to only defer when the plugin can genuinely output a description.
     *
     * @param int $post_id Post ID.
     * @return bool True if the active/primary SEO plugin has a description.
     */
    public function active_plugin_has_description($post_id) {
        $post_id = (int) $post_id;
        if ($post_id <= 0) {
            return false;
        }

        $this->ensure_plugin_api();

        // Yoast (free or premium)
        if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
            if (!empty(get_post_meta($post_id, '_yoast_wpseo_metadesc', true))) {
                return true;
            }
        }

        // Rank Math
        if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
            if (!empty(get_post_meta($post_id, 'rank_math_description', true))) {
                return true;
            }
        }

        // AIOSEO stores its description in a custom table, not post meta.
        if ($this->is_aioseo_active()) {
            global $wpdb;
            // Defensive: $wpdb is always present on a booted frontend, but never
            // assume — a method call on a null/!object $wpdb would be fatal.
            if (isset($wpdb) && is_object($wpdb)) {
                $table = $wpdb->prefix . 'aioseo_posts';
                $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id));
                if (!empty($desc)) {
                    return true;
                }
            }
        }

        return false;
    }

    // ------------------------------------------------------------------
    // MetaSync description resolution
    // ------------------------------------------------------------------

    /**
     * Determine whether MetaSync holds an intentional meta description
     * for the current request.
     *
     * Only considers explicitly set values:
     *   1. SEO sidebar custom value  (_metasync_seo_desc)
     *   2. OTTO persisted description (_metasync_otto_description)
     *
     * Auto-generated excerpts (legacy `meta_description` key) are NOT
     * counted — they should not suppress a third-party plugin.
     *
     * @return bool
     */
    public function metasync_has_description() {
        if ($this->has_description_cache !== null) {
            return $this->has_description_cache;
        }

        if (!empty($this->get_metasync_description())) {
            $this->has_description_cache = true;
            return true;
        }

        // Term-level: on taxonomy archives MetaSync may have term meta
        // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
        $term = $this->get_current_term();
        if ($term) {
            $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
            if (!empty($term_desc)) {
                $this->has_description_cache = true;
                return true;
            }
        }

        $this->has_description_cache = false;
        return false;
    }

    /**
     * Return MetaSync's intentional meta description for the current request.
     *
     * Only returns values that were explicitly set (sidebar or OTTO), NOT
     * auto-generated excerpts from the legacy `meta_description` key.
     * This ensures we only suppress third-party plugins when MetaSync has
     * a deliberate SEO value.
     *
     * @return string
     */
    public function get_metasync_description() {
        $post_id = $this->get_current_object_id();

        if (!$post_id) {
            return '';
        }

        // 1. SEO sidebar (highest priority — user-edited)
        $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
        if (!empty($desc)) {
            return $desc;
        }

        // 2. OTTO description
        $desc = get_post_meta($post_id, '_metasync_otto_description', true);
        if (!empty($desc)) {
            return $desc;
        }

        return '';
    }

    /**
     * Return MetaSync's intentional SEO title for the current request.
     *
     * Mirrors get_metasync_description(): only values that were explicitly set
     * (sidebar or OTTO), never an auto-generated fallback such as the post title.
     * This is the value the og:title / twitter:title suppression keys off, so the
     * sidebar emitter can render the same title back as a replacement.
     *
     * @return string
     */
    public function get_metasync_title() {
        $post_id = $this->get_current_object_id();

        if (!$post_id) {
            return '';
        }

        // 1. Social Media & Open Graph meta box (most specific — a social-only title)
        $og_title = $this->get_customized_og_title($post_id);
        if ($og_title !== '') {
            return $og_title;
        }

        // 2. SEO sidebar (user-edited)
        $title = get_post_meta($post_id, '_metasync_seo_title', true);
        if (!empty($title)) {
            return $title;
        }

        // 3. OTTO title
        $title = get_post_meta($post_id, '_metasync_otto_title', true);
        if (!empty($title)) {
            return $title;
        }

        return '';
    }

    /**
     * The per-post OG meta box title, but only when the user genuinely set it.
     *
     * The meta box pre-fills its Title from the post title and PERSISTS that
     * default on save, so a non-empty `_metasync_og_title` alone does not prove
     * intent — treating it as one would let an auto-filled post title override a
     * deliberately-set SEO title on every ordinary edit. A value counts as the
     * user's only when it differs from that default, the same comparison
     * Otto_html_class::apply_metabox_og_precedence() makes.
     *
     * @param  int $post_id
     * @return string The customized OG title, or '' when unset or auto-filled.
     */
    private function get_customized_og_title($post_id) {
        // Per-post OG meta box values only exist on singular views. On archives
        // get_current_object_id() returns a TERM id, and reading post meta with it
        // would consult an unrelated post — suppressing the third-party tag on a
        // page where the singular-only replacement emitter never runs.
        if (!is_singular()) {
            return '';
        }

        $og_title = (string) get_post_meta($post_id, '_metasync_og_title', true);
        if ($og_title === '') {
            return '';
        }

        $post    = get_post($post_id);
        $default = ($post instanceof WP_Post) ? (string) $post->post_title : '';

        return $og_title === $default ? '' : $og_title;
    }

    /**
     * Whether this request suppressed a third-party plugin's og:title /
     * twitter:title and therefore owes a replacement tag.
     *
     * Only true in the narrow case where the suppression leaves a gap:
     *   - Yoast or AIOSEO is active. Those are the only plugins whose og:title
     *     we filter; Rank Math / SEOPress / TSF render their own untouched, and
     *     on a MetaSync-only site Metasync_OpenGraph::output_opengraph_tags()
     *     still emits og:title itself — emitting here would duplicate it.
     *   - That plugin is not the primary output owner for the post (a synced
     *     post renders the plugin's own tags, so we must not double up).
     *   - The per-post OG toggle is not explicitly off (toggle off suppresses
     *     nothing, so nothing is owed).
     *   - MetaSync has an intentional title — the value that triggered the
     *     suppression in filter_yoast_og_title() / filter_aioseo_facebook_tags().
     *
     * Pages where OTTO owns og:title are deliberately excluded: OTTO injects its
     * own tag through the output buffer, so there is no gap to fill.
     *
     * @return bool
     */
    public function og_title_needs_replacement() {
        if (!$this->suppresses_third_party_og_title()) {
            return false;
        }

        $post_id = $this->get_current_object_id();
        if (!$post_id) {
            return false;
        }

        if ($this->og_output_disabled($post_id)) {
            return false;
        }

        foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
            if ($this->is_primary_output_plugin($post_id, $slug)) {
                return false;
            }
        }

        return $this->metasync_has_title($post_id);
    }

    /**
     * Whether an active third-party plugin is one whose og:title we filter
     * (Yoast or AIOSEO). Rank Math, SEOPress and The SEO Framework emit their
     * own og:title untouched, so they never leave a gap to fill.
     *
     * @return bool
     */
    private function suppresses_third_party_og_title() {
        $this->ensure_plugin_api();

        return is_plugin_active('wordpress-seo/wp-seo.php')
            || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
            || $this->is_aioseo_active();
    }

    /**
     * Whether an active third-party SEO plugin renders the og:description /
     * twitter:description for this request, so MetaSync must not add its own.
     *
     * Mirrors the condition the suppression filters use: when that plugin is the
     * primary output owner, filter_yoast_og_description() (and the AIOSEO/Rank
     * Math equivalents) pass its tag through untouched, so a second tag from the
     * sidebar emitter would duplicate it.
     *
     * Deliberately does NOT ask whether the plugin's OG description field is
     * populated. Yoast falls back to its meta description, and then to the
     * excerpt, so it emits an og:description either way — an "is the field set"
     * test would miss those fallbacks and let the duplicate through.
     *
     * @return bool
     */
    public function third_party_owns_og_description() {
        if (!$this->has_active_seo_plugin()) {
            return false;
        }

        // Per-post ownership only means anything on singular views. On archives
        // get_current_object_id() returns a TERM id, and the sync timestamp would
        // be read from an unrelated post of the same numeric id.
        if (!is_singular()) {
            return false;
        }

        $post_id = $this->get_current_object_id();
        if (!$post_id) {
            return false;
        }

        foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
            if ($this->is_primary_output_plugin($post_id, $slug)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Resolve the MetaSync-managed canonical URL for a post, if any.
     *
     * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta
     * box value (meta_canonical). Restricted to singular views so term/archive
     * queried-object ids are never misread as post ids. Mirrors the fallback in
     * Metasync_Seo_Output::get_canonical_url() so both the no-SEO-plugin path and
     * the third-party-plugin path honor the same value.
     *
     * @param int $post_id Current object id.
     * @return string Escaped canonical URL, or '' when none is set.
     */
    private function get_metasync_canonical($post_id) {
        if (!$post_id || !is_singular()) {
            return '';
        }

        // Validate both sources: legacy rows corrupted to the literal "Array"
        // (or stored as arrays) must never be emitted as a canonical.
        $canonical = Metasync_Canonical_Sanitizer::sanitize(
            get_post_meta($post_id, '_metasync_canonical_url', true)
        );
        if ($canonical === '') {
            $canonical = Metasync_Canonical_Sanitizer::sanitize(
                get_post_meta($post_id, 'meta_canonical', true)
            );
        }

        return $canonical !== '' ? esc_url($canonical) : '';
    }

    /**
     * Reset the cached description flag (useful when the queried object changes).
     */
    public function reset_cache() {
        $this->has_description_cache = null;
        $this->aioseo_has_description_cache = null;
        $this->sync_cache = [];
        $this->live_suggestions_cache = null;
    }

    /**
     * Check whether a post has been synced to third-party plugins via.
     *
     * When native-first sync is active, each plugin reads MetaSync values from
     * its own storage — no filter suppression needed. This method returns true
     * when _metasync_plugin_sync_ts exists and contains a timestamp for the
     * given plugin slug.
     *
     * @param int    $post_id     Post ID.
     * @param string $plugin_slug Plugin slug: 'yoast', 'rankmath', or 'aioseo'.
     * @return bool True if the post has been synced to this plugin.
     */
    private function is_post_synced($post_id, $plugin_slug) {
        if ($post_id <= 0) {
            return false;
        }

        if (!isset($this->sync_cache[$post_id])) {
            $ts_raw = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
            $this->sync_cache[$post_id] = !empty($ts_raw) ? json_decode($ts_raw, true) : [];
            if (!is_array($this->sync_cache[$post_id])) {
                $this->sync_cache[$post_id] = [];
            }
        }

        return !empty($this->sync_cache[$post_id][$plugin_slug]);
    }

    /**
     * For synced posts with multiple SEO plugins active, determine if a
     * specific plugin is the designated output owner.
     *
     * Only the first active plugin in priority order (Yoast > Rank Math > AIOSEO)
     * is allowed to output — the others are suppressed to prevent duplicate tags.
     *
     * @param int    $post_id     Post ID.
     * @param string $plugin_slug Plugin slug to check.
     * @return bool True if this plugin should output its tags.
     */
    private function is_primary_output_plugin($post_id, $plugin_slug) {
        // For synced posts: only the primary synced plugin passes through.
        // For unsynced posts with multiple plugins: only the highest-priority
        // active plugin outputs to prevent duplicate tags.
        $this->ensure_plugin_api();
        $priority = ['yoast', 'rankmath', 'aioseo'];
        $active_check = [
            'yoast'    => is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'),
            'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
            'aioseo'   => is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'),
        ];

        $has_any_sync = $post_id > 0 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));

        if ($has_any_sync) {
            // Synced: primary = first active + synced plugin
            foreach ($priority as $slug) {
                if ($active_check[$slug] && $this->is_post_synced($post_id, $slug)) {
                    return $slug === $plugin_slug;
                }
            }
            return false;
        }

        // Unsynced / multiple plugins active: pick the first active plugin
        // as the sole outputter to prevent duplicate tags.
        $active_count = count(array_filter($active_check));
        if ($active_count > 1) {
            foreach ($priority as $slug) {
                if ($active_check[$slug]) {
                    return $slug === $plugin_slug;
                }
            }
        }

        // Single plugin active or no plugins — don't interfere
        return false;
    }

    /**
     * Ensure is_plugin_active() is loaded on the frontend.
     */
    private function ensure_plugin_api() {
        if (!function_exists('is_plugin_active')) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }
    }

    // ------------------------------------------------------------------
    // AIOSEO integration
    // ------------------------------------------------------------------

    /**
     * Register AIOSEO-specific filters to suppress its output
     * when MetaSync/OTTO already provides the same tags.
     */
    private function register_aioseo_filters() {
        // Suppress AIOSEO meta description
        add_filter('aioseo_description', [$this, 'filter_aioseo_description'], 999);

        // Suppress AIOSEO title
        add_filter('aioseo_title', [$this, 'filter_aioseo_title'], 999);

        // Suppress AIOSEO OG/Twitter tags that OTTO already provides
        add_filter('aioseo_facebook_tags', [$this, 'filter_aioseo_facebook_tags'], 999);
        add_filter('aioseo_twitter_tags', [$this, 'filter_aioseo_twitter_tags'], 999);

        // Suppress AIOSEO robots when MetaSync has an intentional robots value
        add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999);

        // Suppress AIOSEO schema/JSON-LD when OTTO has structured data
        add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999);

        // Canonical — override AIOSEO's canonical with the MetaSync/OTTO value when set
        add_filter('aioseo_canonical_url', [$this, 'filter_aioseo_canonical'], 999);
    }

    /**
     * Filter AIOSEO's canonical URL.
     *
     * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
     * (OTTO or the Canonical meta box) when set, else pass AIOSEO's through.
     */
    public function filter_aioseo_canonical($canonical) {
        $custom = $this->get_metasync_canonical($this->get_current_object_id());
        return $custom !== '' ? $custom : $canonical;
    }

    /**
     * Filter AIOSEO description output.
     * Returns empty string when MetaSync has a description, letting MetaSync output it.
     *
     * @param  string $description AIOSEO's computed description.
     * @return string
     */
    public function filter_aioseo_description($description) {
        // Cache whether AIOSEO actually has a description (before we suppress it).
        // This is used later by should_output_legacy_description().
        if ($this->aioseo_has_description_cache === null) {
            $this->aioseo_has_description_cache = !empty($description);
        }

        // Primary plugin check — only the designated plugin outputs.
        $post_id = $this->get_current_object_id();
        if ($post_id && $this->has_active_seo_plugin()) {
            if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
                return $description;
            }
            if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
                return '';
            }
        }

        // Term archives: AIOSEO free doesn't read per-term custom descriptions
        // from its `wp_aioseo_terms` table, so return the MetaSync value
        // directly so AIOSEO renders it.
        $term = $this->get_current_term();
        if ($term) {
            $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
            if (!empty($term_desc)) {
                return $term_desc;
            }
        }

        // Suppress when: OTTO active + has description, OR MetaSync sidebar has description
        if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
            return '';
        }
        return $description;
    }

    /**
     * Filter AIOSEO title output.
     *
     * On term archives: AIOSEO free doesn't read custom per-term titles from
     * its `wp_aioseo_terms` table, so we replace AIOSEO's template-based title
     * with the MetaSync term title directly.
     *
     * @param  string $title AIOSEO's computed title.
     * @return string
     */
    public function filter_aioseo_title($title) {
        // Primary plugin check — only the designated plugin outputs.
        $post_id = $this->get_current_object_id();
        if ($post_id && $this->has_active_seo_plugin()) {
            if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
                return $title;
            }
            if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
                return '';
            }
        }

        // Term archives: return MetaSync term title directly.
        $term = $this->get_current_term();
        if ($term) {
            $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
            if (!empty($term_title)) {
                return $term_title;
            }
        }

        if ($this->should_suppress_third_party_title()) {
            return '';
        }

        return $title;
    }

    /**
     * Filter AIOSEO Facebook/OG tags.
     *
     * Per-tag suppression: only remove a tag when OTTO is active AND has
     * a persisted value for that specific tag, OR when MetaSync sidebar
     * provides the equivalent value.
     *
     * @param  array $meta AIOSEO's OG meta array.
     * @return array
     */
    public function filter_aioseo_facebook_tags($meta) {
        if (!is_array($meta)) {
            return $meta;
        }

        $post_id = $this->get_current_object_id();

        // Per-post OG toggle off — MetaSync emits no OG, so leave AIOSEO's tags intact.
        if ($this->og_output_disabled($post_id)) {
            return $meta;
        }

        // Post synced to AIOSEO — let AIOSEO read from its own storage.
        if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
            return $meta;
        }

        // og:title — suppress when OTTO has og:title OR MetaSync has title
        if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
            unset($meta['og:title']);
        }

        // og:description — suppress when OTTO has og:description OR MetaSync has description
        if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
            unset($meta['og:description']);
        }

        // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
        // (OTTO injects these structural OG tags alongside og:title in its block)
        if ($this->otto_has_tag('og:title')) {
            unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
        }

        return $meta;
    }

    /**
     * Filter AIOSEO Twitter tags.
     *
     * Per-tag suppression: only remove a tag when OTTO is active AND has
     * a persisted value for that specific tag, OR when MetaSync sidebar
     * provides the equivalent value.
     *
     * @param  array $meta AIOSEO's Twitter meta array.
     * @return array
     */
    public function filter_aioseo_twitter_tags($meta) {
        if (!is_array($meta)) {
            return $meta;
        }

        $post_id = $this->get_current_object_id();

        // Per-post OG toggle off — MetaSync emits no Twitter tags, so leave AIOSEO's intact.
        if ($this->og_output_disabled($post_id)) {
            return $meta;
        }

        // Post synced to AIOSEO — let AIOSEO read from its own storage.
        if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
            return $meta;
        }

        if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
            unset($meta['twitter:title']);
        }

        if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
            unset($meta['twitter:description']);
        }

        // twitter:card — suppress when OTTO has any twitter tag
        if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
            unset($meta['twitter:card']);
        }

        return $meta;
    }

    /**
     * Filter AIOSEO robots meta output.
     *
     * When MetaSync has an intentional robots value (admin checkbox or REST API),
     * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
     * hook_metasync_metatags() will output the MetaSync value instead.
     *
     * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
     * Returning an empty array suppresses AIOSEO's robots tag entirely.
     *
     * @param  array $robots AIOSEO's computed robots attributes array.
     * @return array
     */
    public function filter_aioseo_robots($robots) {
        $post_id = $this->get_current_object_id();
        if (!$post_id) {
            return $robots;
        }

        // Post synced to AIOSEO — let AIOSEO read from its own storage.
        if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
            return $robots;
        }

        if ($this->metasync_has_robots($post_id)) {
            // MetaSync has robots — suppress AIOSEO's tag.
            return [];
        }

        return $robots;
    }

    /**
     * Filter AIOSEO schema/JSON-LD output.
     * Suppress when OTTO has structured data for the current page.
     * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
     * so MetaSync's own BreadcrumbList is the only one on the page.
     *
     * @param  array $output AIOSEO's @graph array.
     * @return array
     */
    public function filter_aioseo_schema($output) {
        if ($this->otto_has_schema_for_current_page()) {
            return [];
        }

        if ($this->metasync_breadcrumb_enabled() && is_array($output)) {
            $output = $this->strip_breadcrumb_from_graph($output);
        }

        return $output;
    }

    /**
     * Check whether MetaSync holds an intentional robots directive for a post.
     *
     * Checks both storage formats:
     *   - meta_robots              (string from REST API)
     *   - metasync_common_robots   (array from admin checkbox)
     *
     * @param  int $post_id Post ID.
     * @return bool
     */
    public function metasync_has_robots($post_id) {
        $meta_robots = get_post_meta($post_id, 'meta_robots', true);
        if (!empty($meta_robots)) {
            return true;
        }

        $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
        if (is_array($common_robots) && !empty(array_filter($common_robots))) {
            return true;
        }

        return false;
    }

    /**
     * Check whether MetaSync/OTTO has a title for a given post.
     *
     * @param  int $post_id Post ID.
     * @return bool
     */
    private function metasync_has_title($post_id) {
        // Social Media & Open Graph meta box, when genuinely customized. Counted
        // here so a post whose ONLY title is the OG one still suppresses the
        // third-party tag — otherwise the plugin keeps rendering its own and the
        // replacement below never gets the chance to emit.
        if ($this->get_customized_og_title($post_id) !== '') {
            return true;
        }

        $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
        if (!empty($seo_title)) {
            return true;
        }

        $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
        if (!empty($otto_title)) {
            return true;
        }

        // Term-level fallback: on taxonomy archives the "object" is a term,
        // so read `_metasync_metatitle` from term meta when we're rendering one.
        $term = $this->get_current_term();
        if ($term) {
            $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
            if (!empty($term_title)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine whether a third-party SEO plugin's title should be suppressed.
     *
     * Suppress when either condition is met:
     *   1. OTTO is active AND has a persisted title for this page
     *   2. MetaSync sidebar has an explicit title for this page
     *
     * @return bool True if the third-party title should be suppressed.
     */
    private function should_suppress_third_party_title() {
        // Condition 1: OTTO active + has title for this page
        if ($this->otto_has_tag('title')) {
            return true;
        }

        // Condition 2: MetaSync sidebar has explicit title
        $post_id = $this->get_current_object_id();
        if ($post_id) {
            return $this->metasync_has_title($post_id);
        }

        return false;
    }

    /**
     * Check whether the OTTO pixel is active.
     *
     * @return bool
     */
    private function is_otto_active() {
        if (class_exists('Metasync_Otto_Config')) {
            return Metasync_Otto_Config::is_otto_enabled();
        }

        return false;
    }

    /**
     * Check whether the OTTO transient cache has live suggestions for the
     * current request URL.
     *
     * Passive get_transient() lookup only — no OTTO API call. Mirrors the
     * cache-key format used by Metasync_Otto_Transient_Cache and the URL
     * construction from Otto_pixel_class::get_route().
     *
     * @return bool
     */
    public function otto_has_live_suggestions() {
        if ($this->live_suggestions_cache !== null) {
            return $this->live_suggestions_cache;
        }

        if (!$this->is_otto_active()) {
            $this->live_suggestions_cache = false;
            return false;
        }

        if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) {
            $this->live_suggestions_cache = false;
            return false;
        }

        $scheme      = is_ssl() ? 'https' : 'http';
        $host        = $_SERVER['HTTP_HOST'];
        $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
        $url         = $scheme . '://' . $host . $request_uri;

        $hash    = md5(rtrim(strtolower($url), '/'));
        $site_id = is_multisite() ? get_current_blog_id() : 0;
        $cached  = get_transient('otto_suggestions_' . $site_id . '_' . $hash);

        $this->live_suggestions_cache = ($cached !== false && !empty($cached));
        return $this->live_suggestions_cache;
    }

    /**
     * Check whether OTTO has a persisted value for a specific meta tag.
     *
     * Two conditions must be true to suppress a third-party tag:
     *   1. OTTO is active (globally enabled)
     *   2. OTTO has a value for this specific tag on the current page
     *
     * For OG/Twitter tags where OTTO's pixel injects dynamically (the
     * specific _metasync_otto_og_* key may be empty), the buffer-level
     * dedup in Otto_html_class::deduplicate_og_twitter_tags() handles
     * removal after all sources have output. This method only does the
     * direct per-tag check.
     *
     * @param  string $tag Tag identifier (e.g. 'title', 'og:title', 'twitter:description').
     * @return bool True when OTTO is active AND has a persisted value for this tag.
     */
    private function otto_has_tag($tag) {
        if (!$this->is_otto_active()) {
            return false;
        }

        $post_id = $this->get_current_object_id();
        if (!$post_id) {
            return false;
        }

        if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
            return false;
        }

        $meta_key_map = [
            'title'                => '_metasync_otto_title',
            'description'          => '_metasync_otto_description',
            'og:title'             => '_metasync_otto_og_title',
            'og:description'       => '_metasync_otto_og_description',
            'twitter:title'        => '_metasync_otto_twitter_title',
            'twitter:description'  => '_metasync_otto_twitter_description',
        ];

        if (!isset($meta_key_map[$tag])) {
            return false;
        }

        return !empty(get_post_meta($post_id, $meta_key_map[$tag], true));
    }

    /**
     * Whether the per-post "Enable Open Graph & Social Media Tags" toggle is
     * explicitly turned off for the given object.
     *
     * Mirrors the guard in Metasync_OpenGraph::will_emit()/output_opengraph_tags():
     * only an explicit '0' opt-out disables MetaSync's OG/Twitter output; an
     * unset/empty value counts as enabled. When disabled, MetaSync emits no
     * OG/Twitter tags of its own, so it must NOT strip a third-party SEO
     * plugin's OG/Twitter tags either — otherwise the page is left with none.
     *
     * @param  int $post_id
     * @return bool
     */
    private function og_output_disabled($post_id) {
        return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
    }

    // ------------------------------------------------------------------
    // Yoast SEO integration
    // ------------------------------------------------------------------

    /**
     * Register Yoast SEO-specific filters to suppress its title,
     * description, and OG/Twitter output when MetaSync/OTTO provides them.
     */
    private function register_yoast_filters() {
        add_filter('wpseo_title', [$this, 'filter_yoast_title'], 999);
        add_filter('wpseo_metadesc', [$this, 'filter_yoast_description'], 999);

        // OG tags — per-tag suppression
        add_filter('wpseo_opengraph_title', [$this, 'filter_yoast_og_title'], 999);
        add_filter('wpseo_opengraph_desc', [$this, 'filter_yoast_og_description'], 999);
        add_filter('wpseo_opengraph_url', [$this, 'filter_yoast_og_structural'], 999);
        add_filter('wpseo_opengraph_type', [$this, 'filter_yoast_og_structural'], 999);
        add_filter('wpseo_opengraph_site_name', [$this, 'filter_yoast_og_structural'], 999);
        add_filter('wpseo_og_locale', [$this, 'filter_yoast_og_structural'], 999);
        add_filter('wpseo_opengraph_image', [$this, 'filter_yoast_og_structural'], 999);

        // Twitter tags — per-tag suppression
        add_filter('wpseo_twitter_title', [$this, 'filter_yoast_twitter_title'], 999);
        add_filter('wpseo_twitter_description', [$this, 'filter_yoast_twitter_description'], 999);
        add_filter('wpseo_twitter_image', [$this, 'filter_yoast_twitter_structural'], 999);
        add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999);

        // Suppress Yoast schema/JSON-LD when OTTO has structured data
        add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999);

        // Canonical — override Yoast's canonical with the MetaSync/OTTO value when set
        add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 999);
    }

    /**
     * Filter Yoast's canonical URL.
     *
     * When a MetaSync-managed canonical exists (OTTO or the Canonical meta box),
     * return it so Yoast emits our value instead of its own — avoiding a duplicate
     * <link rel="canonical"> while still honoring the per-post override.
     * Otherwise let Yoast's canonical through unchanged.
     */
    public function filter_yoast_canonical($canonical) {
        $custom = $this->get_metasync_canonical($this->get_current_object_id());
        return $custom !== '' ? $custom : $canonical;
    }

    /**
     * Filter Yoast SEO title output.
     *
     * When the MetaSync sidebar has an explicit SEO title, return that title so
     * Yoast's Title_Presenter renders it inside the <title> tag it controls.
     * Returning '' would cause Title_Presenter to emit NO <title> tag at all,
     * because Yoast has already removed WordPress's native _wp_render_title_tag
     * action and is the sole renderer of the title element.
     *
     * When only OTTO has a title (no sidebar override), we let Yoast output its
     * own title normally — OTTO's buffer post-processing replaces it in the final
     * HTML. Returning '' here would again leave the page with no <title> tag.
     */
    public function filter_yoast_title($title) {
        $post_id = $this->get_current_object_id();

        // When Yoast is the primary output plugin, let it through.
        // For synced posts, Yoast reads from its own storage (already has the value).
        // For unsynced posts as primary, still check for MetaSync sidebar override.
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            // Even in passthrough, a sidebar title override takes precedence
            $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
            if (!empty($sidebar_title)) {
                return $sidebar_title;
            }
            return $title;
        }

        // Another plugin is primary — suppress Yoast.
        if ($post_id && $this->has_active_seo_plugin()) {
            if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
                return '';
            }
        }

        // Term-level archives
        $term = $this->get_current_term();
        if ($term) {
            $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
            if (!empty($term_title)) {
                return $term_title;
            }
        }

        // MetaSync sidebar has an explicit title — return it so Yoast renders it.
        if ($post_id) {
            $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
            if (!empty($sidebar_title)) {
                return $sidebar_title;
            }
        }

        // Case 2: OTTO has a persisted title — do NOT suppress Yoast here.
        // OTTO's output-buffer post-processing (Otto_html_class) replaces the
        // <title> tag in the final HTML after WordPress renders. Returning '' would
        // remove the <title> tag entirely before OTTO can inject its replacement.

        return $title;
    }

    /**
     * Filter Yoast SEO description output.
     *
     * On term archives: the term-level sync writes MetaSync's description
     * into Yoast's native storage, so Yoast already computes the correct
     * value — let it through.
     *
     * On singular pages: suppress when OTTO or MetaSync sidebar provides
     * the description (MetaSync outputs its own tag).
     */
    public function filter_yoast_description($description) {
        $post_id = $this->get_current_object_id();
        if ($post_id && $this->has_active_seo_plugin()) {
            if ($this->is_primary_output_plugin($post_id, 'yoast')) {
                return $description;
            }
            if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
                return '';
            }
        }

        // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
        $term = $this->get_current_term();
        if ($term) {
            $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
            if (!empty($term_desc)) {
                return $description;
            }
        }

        if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
            return '';
        }
        return $description;
    }

    /**
     * Filter Yoast og:title output.
     * Suppress when: OTTO active + has og:title, OR MetaSync has title.
     */
    public function filter_yoast_og_title($value) {
        $post_id = $this->get_current_object_id();
        if ($this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            return $value;
        }
        if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
            return '';
        }
        return $value;
    }

    /**
     * Filter Yoast og:description output.
     * Suppress when: OTTO active + has og:description, OR MetaSync has description.
     */
    public function filter_yoast_og_description($value) {
        $post_id = $this->get_current_object_id();
        if ($this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            return $value;
        }
        if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
            return '';
        }
        return $value;
    }

    /**
     * Filter Yoast OG structural tags (og:url, og:type, og:locale, og:site_name, og:image).
     * Suppress when: OTTO active + has og:title (OTTO provides these alongside og:title).
     */
    public function filter_yoast_og_structural($value) {
        $post_id = $this->get_current_object_id();
        if ($this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            return $value;
        }
        if ($this->otto_has_tag('og:title')) {
            return '';
        }
        return $value;
    }

    /**
     * Filter Yoast twitter:title output.
     * Suppress when: OTTO active + has twitter:title, OR MetaSync has title.
     */
    public function filter_yoast_twitter_title($value) {
        $post_id = $this->get_current_object_id();
        if ($this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            return $value;
        }
        if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
            return '';
        }
        return $value;
    }

    /**
     * Filter Yoast twitter:description output.
     * Suppress when: OTTO active + has twitter:description, OR MetaSync has description.
     */
    public function filter_yoast_twitter_description($value) {
        $post_id = $this->get_current_object_id();
        if ($this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            return $value;
        }
        if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
            return '';
        }
        return $value;
    }

    /**
     * Filter Yoast Twitter structural tags (twitter:image, twitter:card).
     * Suppress when: OTTO active + has any twitter tag.
     */
    public function filter_yoast_twitter_structural($value) {
        $post_id = $this->get_current_object_id();
        if ($this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
            return $value;
        }
        if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
            return '';
        }
        return $value;
    }

    /**
     * Filter Yoast SEO schema/JSON-LD output.
     * Suppress when OTTO has structured data for the current page.
     * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
     * so MetaSync's own BreadcrumbList is the only one on the page.
     *
     * @param  array|false $data Yoast's JSON-LD data.
     * @return array|false
     */
    public function filter_yoast_schema($data) {
        if ($this->otto_has_schema_for_current_page()) {
            return false;
        }

        if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
            $data = $this->strip_breadcrumb_from_graph($data);
        }

        return $data;
    }

    // ------------------------------------------------------------------
    // RankMath integration
    // ------------------------------------------------------------------

    /**
     * Register RankMath-specific filters to suppress its title and
     * description output when MetaSync/OTTO already provides them.
     */
    private function register_rankmath_filters() {
        add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
        add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);

        // OG/Twitter description — per-tag suppression, mirroring the Yoast filters.
        // Without these Rank Math renders its own og:description alongside the one
        // MetaSync emits, leaving two conflicting tags on the page.
        add_filter('rank_math/opengraph/facebook/og_description', [$this, 'filter_rankmath_og_description'], 999);
        add_filter('rank_math/opengraph/twitter/twitter_description', [$this, 'filter_rankmath_twitter_description'], 999);

        // Suppress RankMath schema/JSON-LD when OTTO has structured data
        add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);

        // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
        add_filter('rank_math/frontend/canonical', [$this, 'filter_rankmath_canonical'], 999);
    }

    /**
     * Filter RankMath's canonical URL.
     *
     * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
     * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
     */
    public function filter_rankmath_canonical($canonical) {
        $custom = $this->get_metasync_canonical($this->get_current_object_id());
        return $custom !== '' ? $custom : $canonical;
    }

    /**
     * Filter RankMath title output.
     *
     * On taxonomy archive pages: when MetaSync has an explicit `_metasync_metatitle`
     * term meta value, return it so Rank Math renders the MetaSync-managed archive
     * title inside <title>. This mirrors the Yoast term-level title override in
     * filter_yoast_title().
     *
     * On singular pages: return empty string when MetaSync/OTTO has a title (Rank
     * Math controls the sole <title> renderer on classic themes, so OTTO's buffer
     * post-processing will replace it — returning '' would leave no <title> at all).
     *
     * @param  string $title RankMath's computed title.
     * @return string
     */
    public function filter_rankmath_title($title) {
        $post_id = $this->get_current_object_id();
        // If another plugin is the primary output owner, suppress Rank Math.
        if ($post_id && $this->has_active_seo_plugin()) {
            if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
                return $title;
            }
            // Another plugin is primary — suppress this one
            if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
                return '';
            }
        }

        // Term-level archives (category/tag/custom taxonomy): when MetaSync has
        // an explicit `_metasync_metatitle`, return it so Rank Math renders the
        // MetaSync-managed archive title inside <title>.
        $term = $this->get_current_term();
        if ($term) {
            $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
            if (!empty($term_title)) {
                return $term_title;
            }
        }

        $post_id = $this->get_current_object_id();

        // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
        if ($post_id) {
            $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
            if (!empty($sidebar_title)) {
                return $sidebar_title;
            }
        }

        // OTTO has a persisted title — do NOT suppress Rank Math here.
        // OTTO's output-buffer post-processing replaces the <title> tag in the
        // final HTML. Returning '' would remove the tag before OTTO can inject.

        return $title;
    }

    /**
     * Filter RankMath description output.
     *
     * On term archives: the term-level sync writes MetaSync's description
     * into Rank Math's native term meta, so Rank Math already computes the
     * correct value — let it through.
     *
     * On singular pages: suppress when OTTO or MetaSync sidebar provides
     * the description.
     *
     * @param  string $description RankMath's computed description.
     * @return string
     */
    public function filter_rankmath_description($description) {
        $post_id = $this->get_current_object_id();
        if ($post_id && $this->has_active_seo_plugin()) {
            if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
                return $description;
            }
            if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
                return '';
            }
        }

        // Term archives: MetaSync syncs to Rank Math storage — let it render.
        $term = $this->get_current_term();
        if ($term) {
            $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
            if (!empty($term_desc)) {
                return $description;
            }
        }

        if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
            return '';
        }

        return $description;
    }

    /**
     * Filter Rank Math's og:description / twitter:description output.
     *
     * Mirrors filter_yoast_og_description(): suppress when OTTO has the tag or
     * MetaSync has an intentional description, so exactly one description tag
     * reaches the page. Returning '' removes Rank Math's.
     *
     * @param  string $value Rank Math's computed description.
     * @return string
     */
    public function filter_rankmath_og_description($value) {
        return $this->filter_rankmath_social_description($value, 'og:description');
    }

    /**
     * Filter Rank Math's twitter:description output.
     *
     * Kept separate from the og:description filter so each consults its OWN
     * OTTO key — twitter:description resolves to _metasync_otto_twitter_description,
     * not the og one. Sharing a callback would suppress Rank Math's tag on the
     * strength of the wrong key, either leaving a duplicate or a blank.
     *
     * @param  string $value Rank Math's computed description.
     * @return string
     */
    public function filter_rankmath_twitter_description($value) {
        return $this->filter_rankmath_social_description($value, 'twitter:description');
    }

    /**
     * Shared body for the two Rank Math social-description filters.
     *
     * @param  string $value    Rank Math's computed description.
     * @param  string $otto_tag OTTO tag identifier to test for this specific tag.
     * @return string
     */
    private function filter_rankmath_social_description($value, $otto_tag) {
        $post_id = $this->get_current_object_id();
        if ($post_id && $this->og_output_disabled($post_id)) {
            return $value;
        }
        if ($post_id && $this->is_primary_output_plugin($post_id, 'rankmath')) {
            return $value;
        }
        if ($this->otto_has_tag($otto_tag) || $this->metasync_has_description()) {
            return '';
        }
        return $value;
    }

    /**
     * Filter RankMath schema/JSON-LD output.
     * Suppress when OTTO has structured data for the current page.
     * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
     * so MetaSync's own BreadcrumbList is the only one on the page.
     *
     * @param  array $data RankMath's JSON-LD data array.
     * @return array
     */
    public function filter_rankmath_schema($data) {
        if ($this->otto_has_schema_for_current_page()) {
            return [];
        }

        if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
            $data = $this->strip_breadcrumb_from_graph($data);
        }

        return $data;
    }

    // ------------------------------------------------------------------
    // MetaSync output gating
    // ------------------------------------------------------------------

    /**
     * Whether the legacy `hook_metasync_metatags()` should output a description tag.
     *
     * Decision matrix (when a third-party SEO plugin is active):
     *   MetaSync has value  → true  (MetaSync outputs, AIOSEO suppressed via filter)
     *   AIOSEO has value    → false (let AIOSEO handle it)
     *   Neither has value   → true  (fallback: legacy auto-generated description)
     *
     * When no third-party SEO plugin is active → always true.
     *
     * @return bool True if the legacy output should include a description tag.
     */
    public function should_output_legacy_description() {
        // Post synced to any active plugin — that plugin now owns the
        // description output from its native storage. Suppress MetaSync's own tag.
        $post_id = $this->get_current_object_id();
        if ($post_id && $this->has_active_seo_plugin()) {
            // If synced to any plugin, a primary output plugin exists — suppress MetaSync's own tag.
            if ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo')) {
                return false;
            }
        }

        // OTTO active + has description → suppress legacy auto-generated description.
        if ($this->otto_has_tag('description')) {
            return false;
        }

        if (!$this->has_active_seo_plugin()) {
            return true;
        }

        // MetaSync has an intentional value — always output it
        if ($this->metasync_has_description()) {
            return true;
        }

        // Check if AIOSEO actually provides a description for this page.
        // If it does, suppress our legacy output to avoid duplicates.
        // If it doesn't, let our legacy auto-generated description through
        // so the page isn't left with zero descriptions.
        if ($this->is_aioseo_active() && $this->aioseo_provides_description()) {
            return false;
        }

        // For Yoast/RankMath: they always auto-generate a description,
        // so suppress our legacy output when they're active.
        if (is_plugin_active('wordpress-seo/wp-seo.php')) {
            return false;
        }
        if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
            is_plugin_active('seo-by-rankmath/rank-math.php')) {
            return false;
        }

        // No third-party plugin will provide a description — output ours
        return true;
    }

    /**
     * Check whether AIOSEO will actually output a description for the current page.
     *
     * Uses the cached value captured in filter_aioseo_description() if available.
     * Falls back to calling AIOSEO's API directly if the filter hasn't fired yet.
     *
     * @return bool
     */
    private function aioseo_provides_description() {
        // Use cached value if available (set when our filter fires)
        if ($this->aioseo_has_description_cache !== null) {
            return $this->aioseo_has_description_cache;
        }

        // Filter hasn't fired yet — query AIOSEO directly
        if (function_exists('aioseo') && isset(aioseo()->meta->description)) {
            $desc = aioseo()->meta->description->getDescription();
            $this->aioseo_has_description_cache = !empty($desc);
            return $this->aioseo_has_description_cache;
        }

        // Can't determine — assume AIOSEO has one to avoid duplicates
        $this->aioseo_has_description_cache = true;
        return true;
    }

    // ------------------------------------------------------------------
    // Helpers
    // ------------------------------------------------------------------

    /**
     * Strip BreadcrumbList nodes from a JSON-LD @graph array and remove
     * dangling breadcrumb references from WebPage-type nodes.
     *
     * Previously we only removed the BreadcrumbList entry but left
     * the WebPage's `breadcrumb: { @id: "...#breadcrumb" }` property intact.
     * Google follows that dangling @id, finds no matching node, and reports
     * "Missing field itemListElement".
     *
     * @param  array $graph The @graph array from a third-party SEO plugin.
     * @return array
     */
    private function strip_breadcrumb_from_graph($graph) {
        // Collect @ids of BreadcrumbList nodes being removed.
        $removed_ids = [];

        foreach ($graph as $key => $entry) {
            if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
                if (!empty($entry['@id'])) {
                    $removed_ids[] = $entry['@id'];
                }
                unset($graph[$key]);
            }
        }

        // Remove dangling breadcrumb references from WebPage-type nodes.
        foreach ($graph as $key => &$entry) {
            if (!is_array($entry) || !isset($entry['@type'])) {
                continue;
            }

            $type = $entry['@type'];
            $is_page_type = $type === 'WebPage'
                || (is_array($type) && in_array('WebPage', $type, true));

            if ($is_page_type && isset($entry['breadcrumb'])) {
                // Remove if the reference points to a stripped node, or if
                // no BreadcrumbList remains in this graph at all.
                $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
                if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
                    unset($entry['breadcrumb']);
                }
            }
        }
        unset($entry);

        return array_values($graph);
    }

    /**
     * Determine whether MetaSync's own BreadcrumbList output is enabled.
     *
     * Mirrors the gate logic in Metasync_Breadcrumbs_Schema::output_breadcrumb_schema():
     * enabled by default, disabled only when the `enabled` setting is explicitly falsy.
     * Used by the Yoast / RankMath / AIOSEO schema filters so we only strip their
     * BreadcrumbList entries when MetaSync will emit one itself.
     *
     * @return bool
     */
    private function metasync_breadcrumb_enabled() {
        $settings = Metasync::get_option('breadcrumbs', array());
        if (!is_array($settings)) {
            return true;
        }

        if (array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
            return false;
        }

        // When schema output is explicitly disabled, don't strip
        // third-party breadcrumbs — MetaSync won't emit its own.
        if (!empty($settings['disable_schema'])) {
            return false;
        }

        return true;
    }

    /**
     * Check whether OTTO has structured data (schema/JSON-LD) for the current page.
     *
     * @return bool
     */
    private function otto_has_schema_for_current_page() {
        if (!$this->is_otto_active()) {
            return false;
        }

        $post_id = $this->get_current_object_id();
        if (!$post_id) {
            return false;
        }

        if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
            return false;
        }

        return !empty(get_post_meta($post_id, '_metasync_otto_structured_data', true));
    }

    /**
     * Get the current queried object ID.
     *
     * Uses get_queried_object_id() as the universal fallback so every
     * public page type (singular, front page, static blog page, CPT
     * archives, WooCommerce shop, etc.) is covered without enumerating
     * each one individually.
     *
     * For blog-style homepages (show_on_front=posts) there is no backing
     * page, so this returns 0.
     *
     * @return int 0 when unknown.
     */
    private function get_current_object_id() {
        // Singular pages (posts, pages, CPTs, attachments)
        if (is_singular()) {
            return (int) get_the_ID();
        }

        // WooCommerce shop page (virtual archive backed by a real page)
        if (function_exists('is_shop') && is_shop()) {
            return function_exists('wc_get_page_id') ? (int) wc_get_page_id('shop') : 0;
        }

        // Universal fallback: static front page, static posts page,
        // or any other page type WordPress assigns a queried object to.
        $id = get_queried_object_id();
        if ($id > 0) {
            return (int) $id;
        }

        return 0;
    }

    /**
     * Return the WP_Term being rendered on taxonomy archive pages.
     *
     * Only returns a term when the current query is a category, tag, or
     * custom taxonomy archive — i.e. when MetaSync term meta could be
     * driving the rendered output. Returns null in every other context
     * (singular, blog home, search, 404, etc.) so callers don't have to
     * double-check the page type.
     *
     * @return \WP_Term|null
     */
    private function get_current_term() {
        if (!(is_category() || is_tag() || is_tax())) {
            return null;
        }

        $queried = get_queried_object();
        if ($queried instanceof \WP_Term) {
            return $queried;
        }

        return null;
    }
}

```
