<?php
/**
 * SCHEMAFY Schema Injector Class - IMPROVED VERSION
 *
 * Handles injection of schemas in the frontend wp_head
 *
 * @package CustomSchemaSchemafy
 * @since 1.1.86
 * @author SCHEMAFY Team
 *
 * MEJORAS EN ESTA VERSIÓN:
 * - ✅ Reemplazo correcto de variables (decodifica JSON primero)
 * - ✅ Soporte para 10 variables (no solo 2)
 * - ✅ Consulta directa a DB (sin cache para reflejar cambios inmediatos)
 * - ✅ Normalización de URLs (trailing slash)
 * - ✅ Detección inteligente de SEO plugins para meta tags
 * - ✅ Mejor manejo de errores
 */

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Class SCHEMAFY_Schema_Injector
 *
 * Injects schema markup and meta tags in the frontend
 */
class SCHEMAFY_Schema_Injector {

    /**
     * SEO Detector instance
     *
     * @var SCHEMAFY_SEO_Detector
     */
    private $seo_detector;

    /**
     * Validator instance
     *
     * @var SCHEMAFY_Validator
     */
    private $validator;

    /**
     * Brand name
     *
     * @var string
     */
    private $brand_name = 'Schemafy';

    /**
     * Memoized [ title, description ] for this request, or null before resolve.
     *
     * @var array|null
     */
    private $meta_overrides = null;

    /**
     * Constructor
     *
     * @param SCHEMAFY_SEO_Detector $seo_detector SEO detector instance
     * @param SCHEMAFY_Validator $validator Validator instance
     */
    public function __construct($seo_detector, $validator) {
        $this->seo_detector = $seo_detector;
        $this->validator = $validator;

        // Strip JSON-LD that other plugins and blocks render into post content.
        // This deletes another component's output, so it is opt-in like every
        // other suppression: off until the site owner enables it under Plugin
        // Compatibility. Priority 9999 runs after do_blocks() (priority 9) so
        // dynamically rendered blocks have already emitted their markup.
        if (self::compat_enabled('strip_embedded_schemas')) {
            add_filter('the_content', array($this, 'strip_embedded_jsonld'), 9999);
        }
    }

    /**
     * Whether a Plugin Compatibility toggle is on.
     *
     * Every behaviour that suppresses, removes or rewrites another plugin's
     * output goes through here. All toggles are off until the site owner turns
     * them on, so Schemafy never overrides another plugin on its own.
     *
     * Public so SCHEMAFY_Sitemap uses the same gate for its own conflict guards —
     * one rule, one storage, one place to reason about overrides.
     *
     * @param string $key Toggle key stored in the 'schemafy_compatibility' option.
     * @return bool
     */
    public static function compat_enabled($key) {
        $compat = get_option('schemafy_compatibility', array());
        if (!is_array($compat)) {
            $compat = array();
        }

        /**
         * Filters a single compatibility toggle.
         *
         * @param bool   $enabled Toggle state (off unless enabled in the admin).
         * @param string $key     Toggle key.
         */
        return (bool) apply_filters('schemafy_' . $key, !empty($compat[$key]), $key);
    }

    /**
     * Strip <script type="application/ld+json"> blocks from post content.
     *
     * JSON-LD schemas should live in <head>, not inside the post body.
     * Embedded ones cause duplicate-type errors and are invalid markup.
     *
     * @param string $content Post content
     * @return string Clean content
     */
    const CACHE_VERSION_OPTION = 'schemafy_sch_cache_ver';

    /**
     * Current schema-cache version. It is part of every transient key, so
     * bumping it instantly invalidates ALL cached schemas regardless of the
     * storage backend (DB transients OR a persistent object cache like Redis).
     *
     * @return string
     */
    public static function get_cache_version() {
        $ver = get_option(self::CACHE_VERSION_OPTION);
        if (!$ver) {
            $ver = '1';
            update_option(self::CACHE_VERSION_OPTION, $ver, true);
        }
        return (string) $ver;
    }

    /**
     * Invalidate all cached schemas after any mutation.
     *
     * Bumping the version key is what actually works on object-cache sites —
     * the old direct DELETE only touched wp_options and silently missed
     * transients stored in Redis/Memcached, leaving stale schemas live for
     * the full TTL. The DELETE is kept to stop orphan rows accumulating on
     * DB-transient sites.
     *
     * Called by REST API and AJAX handlers after any schema mutation.
     */
    public static function clear_all_schema_cache() {
        // Version bump — backend-agnostic, instant invalidation.
        update_option(self::CACHE_VERSION_OPTION, (string) time(), true);

        // Best-effort cleanup of orphaned DB transients (no-op on object caches).
        global $wpdb;
        $wpdb->query(
            "DELETE FROM {$wpdb->options}
             WHERE option_name LIKE '_transient_schemafy_sch_%'
                OR option_name LIKE '_transient_timeout_schemafy_sch_%'"
        );

        // Schemas inject into the page <head>, so a cached HTML page keeps
        // serving the old markup to Google until its page cache is purged.
        // This is the real reason toggles took "5-10 minutes" to show up.
        self::purge_page_caches();
    }

    /**
     * Purge known third-party page caches so the next request (and Google's
     * fetch) gets freshly-rendered HTML with the updated schema.
     *
     * Every call is guarded — it only runs for caches that are actually
     * installed, and never touches the object cache wholesale.
     */
    public static function purge_page_caches() {
        // WP Rocket
        if (function_exists('rocket_clean_domain')) {
            rocket_clean_domain();
        }

        // W3 Total Cache
        if (function_exists('w3tc_flush_all')) {
            w3tc_flush_all();
        }

        // WP Super Cache
        if (function_exists('wp_cache_clear_cache')) {
            wp_cache_clear_cache();
        }

        // WP Fastest Cache
        if (isset($GLOBALS['wp_fastest_cache']) && method_exists($GLOBALS['wp_fastest_cache'], 'deleteCache')) {
            $GLOBALS['wp_fastest_cache']->deleteCache(true);
        }

        // LiteSpeed Cache, SiteGround Optimizer and Cache Enabler each expose
        // their purge as an action of their own. These hook names belong to
        // those plugins — they are not Schemafy hooks and cannot carry our
        // prefix — so each is fired only when its owner has registered it.
        $third_party_purge_hooks = array(
            'litespeed_purge_all',
            'sg_cachepress_purge_cache',
            'cache_enabler_clear_complete_cache',
        );

        foreach ($third_party_purge_hooks as $purge_hook) {
            if (has_action($purge_hook)) {
                do_action($purge_hook);
            }
        }

        // Generic hook for any other cache/CDN integration.
        do_action('schemafy_schema_cache_cleared');
    }

    public function strip_embedded_jsonld($content) {
        if (strpos($content, 'application/ld+json') === false) {
            return $content;
        }
        return preg_replace(
            '/<script[^>]*type=["\']application\/ld\+json["\'][^>]*>[\s\S]*?<\/script>/i',
            '',
            $content
        );
    }

    /**
     * Inject schemas in wp_head
     * This is the main method called by WordPress wp_head hook
     *
     * @return void
     */
    public function inject_schema() {
        $current_url = $this->get_current_url();
        $url_candidates = $this->get_url_match_candidates($current_url);
        $post_id = 0;

        if (is_singular()) {
            $post_id = get_the_ID();
        }

        // ==========================================
        // PASO 1: Get meta values and prepare all variables
        // ==========================================
        // {meta_title} / {meta_description} must expand to finished strings.
        // Schemafy's own value first; otherwise the rendered document title,
        // never another plugin's stored template (see get_meta_overrides()).
        list($meta_title_value, $meta_description_value) = $this->get_meta_overrides();
        if ('' === $meta_title_value) {
            $meta_title_value = wp_get_document_title();
        }
        if ('' === $meta_description_value && !$this->has_active_seo_plugin()) {
            $meta_description_value = $this->seo_detector->get_meta_description($post_id, $current_url);
        }

        // Build complete replacements array with ALL supported variables
        $replacements = $this->build_variable_replacements($post_id, $current_url, $meta_title_value, $meta_description_value);

        // ==========================================
        // PASO 2: Get schemas for current URL (transient cache, 2 min TTL)
        // ==========================================
        if (empty($url_candidates)) {
            return;
        }

        $cache_key = 'schemafy_sch_' . self::get_cache_version() . '_' . md5(implode('|', $url_candidates));

        $schemas = get_transient($cache_key);

        if ($schemas === false) {
            global $wpdb;
            $table_name = $wpdb->prefix . 'schemafy_custom_schemas';
            $placeholders = implode(', ', array_fill(0, count($url_candidates), '%s'));

            $schemas = $wpdb->get_results($wpdb->prepare(
                "SELECT schema_name, schema_data FROM $table_name
                 WHERE url IN ($placeholders) AND is_active = 1
                 ORDER BY created_at ASC",
                $url_candidates
            ));

            // Never cache a DB failure as "no schemas" — that would suppress
            // injection for the TTL window even though schemas exist.
            if ($wpdb->last_error) {
                schemafy_log('[SCHEMAFY Schema] Schema lookup failed: ' . $wpdb->last_error);
                $schemas = array();
            } else {
                set_transient($cache_key, $schemas ?: array(), 2 * MINUTE_IN_SECONDS);
            }
        }

        // ==========================================
        // PASO 3: Process and output schemas
        // ==========================================
        if ($schemas) {
            echo '<!-- Custom Schema by ' . esc_html($this->brand_name) . ' -->' . "\n";

            foreach ($schemas as $schema) {
                // Decode JSON first
                $decoded = json_decode($schema->schema_data, true);

                if (json_last_error() !== JSON_ERROR_NONE) {
                    schemafy_log('[SCHEMAFY Schema] Invalid JSON in database for schema: ' . $schema->schema_name);
                    continue;
                }

                // Replace ALL variables recursively in the decoded structure
                $this->replace_variables_recursive($decoded, $replacements);

                $json_output = self::encode_jsonld($decoded);

                if ($json_output === false) {
                    schemafy_log('[SCHEMAFY Schema] Failed to encode JSON for schema: ' . $schema->schema_name);
                    continue;
                }

                // Output schema
                echo '<!-- Schema: ' . esc_html($schema->schema_name) . ' -->' . "\n";
                echo '<script type="application/ld+json">' . "\n";
                // $json_output comes from self::encode_jsonld(), which escapes every
                // "<" — no markup can survive in it, and escaping it again as HTML
                // would corrupt the schema Google reads.
                echo $json_output . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-LD from self::encode_jsonld(); see above
                echo '</script>' . "\n";
            }

            echo '<!-- End Custom Schema by ' . esc_html($this->brand_name) . ' -->' . "\n";
        }
    }

    /**
     * Build complete variable replacements array
     *
     * @param int $post_id Post ID
     * @param string $current_url Current URL
     * @param string $meta_title Meta title
     * @param string $meta_description Meta description
     * @return array Replacements array
     */
    private function build_variable_replacements($post_id, $current_url, $meta_title, $meta_description) {
        $replacements = array(
            // Meta tags
            '{{meta_title}}' => $meta_title,
            '{{meta_description}}' => $meta_description,

            // Site info
            '{{site_name}}' => get_bloginfo('name'),
            '{{site_url}}' => get_site_url(),
            '{{current_url}}' => $current_url,
            '{{site_description}}' => get_bloginfo('description'),
            '{{site_language}}' => get_bloginfo('language'),
            '{{current_year}}' => gmdate('Y'),
        );

        // Post-specific variables (only if we have a post)
        if ($post_id > 0) {
            $post = get_post($post_id);

            if ($post) {
                $replacements['{{post_title}}'] = get_the_title($post_id);
                $replacements['{{post_date}}'] = get_the_date('c', $post_id); // ISO 8601
                $replacements['{{post_modified}}'] = get_the_modified_date('c', $post_id); // ISO 8601
                $replacements['{{post_excerpt}}'] = get_the_excerpt($post_id);
                $replacements['{{post_id}}'] = strval($post_id);
                $replacements['{{post_type}}'] = get_post_type($post_id);

                // Aliases for Smart Builder compatibility
                $replacements['{{post_author}}'] = get_the_author_meta('display_name', $post->post_author);
                $replacements['{{post_url}}'] = get_permalink($post_id);

                // Author
                $replacements['{{author_name}}'] = get_the_author_meta('display_name', $post->post_author);

                // Featured image
                $featured_image = get_the_post_thumbnail_url($post_id, 'full');
                $replacements['{{featured_image}}'] = $featured_image ? $featured_image : '';

                // Category
                $categories = get_the_category($post_id);
                $replacements['{{category}}'] = !empty($categories) ? $categories[0]->name : '';

                // Tags
                $tags = get_the_tags($post_id);
                $replacements['{{tags}}'] = '';
                if (!empty($tags) && !is_wp_error($tags)) {
                    $replacements['{{tags}}'] = implode(', ', wp_list_pluck($tags, 'name'));
                }

                // WooCommerce product variables
                $this->add_woocommerce_replacements($replacements, $post_id);
            }
        } else {
            // Empty values for post-specific variables if no post
            $replacements['{{post_title}}'] = '';
            $replacements['{{post_date}}'] = '';
            $replacements['{{post_modified}}'] = '';
            $replacements['{{post_excerpt}}'] = '';
            $replacements['{{post_id}}'] = '';
            $replacements['{{post_type}}'] = '';
            $replacements['{{post_author}}'] = '';
            $replacements['{{post_url}}'] = '';
            $replacements['{{author_name}}'] = '';
            $replacements['{{featured_image}}'] = '';
            $replacements['{{category}}'] = '';
            $replacements['{{tags}}'] = '';

            // Empty WooCommerce fallbacks
            $this->add_woocommerce_replacements($replacements, 0);
        }

        return $replacements;
    }

    /**
     * Add WooCommerce product variable replacements
     *
     * @param array &$replacements Replacements array (passed by reference)
     * @param int $post_id Post ID (0 for empty fallbacks)
     */
    private function add_woocommerce_replacements(&$replacements, $post_id) {
        // Default empty values for all WC variables
        $wc_defaults = array(
            '{{product_price}}'             => '',
            '{{product_regular_price}}'     => '',
            '{{product_sale_price}}'        => '',
            '{{product_currency}}'          => '',
            '{{product_sku}}'               => '',
            '{{product_stock_status}}'      => '',
            '{{product_short_description}}' => '',
            '{{product_weight}}'            => '',
            '{{product_category}}'          => '',
            '{{product_rating}}'            => '',
            '{{product_review_count}}'      => '',
            '{{product_image}}'             => '',
        );

        if ($post_id > 0 && function_exists('wc_get_product')) {
            $product = wc_get_product($post_id);

            if ($product) {
                $wc_defaults['{{product_price}}']             = $product->get_price();
                $wc_defaults['{{product_regular_price}}']     = $product->get_regular_price();
                $wc_defaults['{{product_sale_price}}']        = $product->get_sale_price();
                $wc_defaults['{{product_currency}}']          = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : '';
                $wc_defaults['{{product_sku}}']               = $product->get_sku();

                // Stock status as Schema.org availability URL
                $stock = $product->get_stock_status();
                if ($stock === 'outofstock') {
                    $wc_defaults['{{product_stock_status}}'] = 'https://schema.org/OutOfStock';
                } elseif ($stock === 'onbackorder') {
                    $wc_defaults['{{product_stock_status}}'] = 'https://schema.org/BackOrder';
                } else {
                    $wc_defaults['{{product_stock_status}}'] = 'https://schema.org/InStock';
                }

                $short_desc = $product->get_short_description();
                $wc_defaults['{{product_short_description}}'] = !empty($short_desc) ? wp_strip_all_tags($short_desc) : '';
                $wc_defaults['{{product_weight}}']            = $product->get_weight();

                // Product category
                $product_cats = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
                if (!is_wp_error($product_cats) && !empty($product_cats)) {
                    $wc_defaults['{{product_category}}'] = $product_cats[0];
                }

                $wc_defaults['{{product_rating}}']      = $product->get_average_rating();
                $wc_defaults['{{product_review_count}}'] = strval($product->get_review_count());

                // Product main image
                $image_id = $product->get_image_id();
                $wc_defaults['{{product_image}}'] = $image_id ? wp_get_attachment_image_url($image_id, 'full') : '';
            }
        }

        $replacements = array_merge($replacements, $wc_defaults);
    }

    /**
     * Replace variables recursively in decoded JSON structure
     * THIS IS THE CORRECT WAY - replaces in values, then re-encodes
     *
     * @param mixed $data Data structure (passed by reference)
     * @param array $replacements Replacements array
     * @return void
     */
    private function replace_variables_recursive(&$data, $replacements) {
        if (is_array($data)) {
            foreach ($data as &$value) {
                $this->replace_variables_recursive($value, $replacements);
            }
        } elseif (is_string($data)) {
            // Replace all variables in this string
            foreach ($replacements as $search => $replace) {
                $data = str_replace($search, $replace, $data);
            }
        }
        // For other types (int, bool, null), do nothing
    }

    /**
     * Normalize URL for matching
     * Removes trailing slash and query string
     *
     * @param string $url URL to normalize
     * @return string Normalized URL
     */
    private function normalize_url($url) {
        // Remove trailing slash
        $url = untrailingslashit($url);

        // Remove query string for matching
        $url = strtok($url, '?');

        return $url;
    }

    /**
     * Normalize a URL for option/table lookups without losing legacy variants.
     *
     * @param string $url URL.
     * @return string
     */
    private function normalize_meta_url($url) {
        $url = trim((string) $url);
        if ($url === '') {
            return '';
        }

        $url = esc_url_raw($url);
        if (!$url) {
            return '';
        }

        $parts = wp_parse_url($url);
        if (empty($parts['scheme']) || empty($parts['host']) || !in_array(strtolower($parts['scheme']), array('http', 'https'), true)) {
            return '';
        }

        $scheme = strtolower($parts['scheme']);
        $host = strtolower($parts['host']);
        $path = isset($parts['path']) ? preg_replace('#/+#', '/', $parts['path']) : '/';
        $path = '/' . ltrim((string) $path, '/');
        $path = $path === '/' ? '/' : untrailingslashit($path);
        $port = '';

        if (!empty($parts['port'])) {
            $default_port = $scheme === 'https' ? 443 : 80;
            if ((int) $parts['port'] !== $default_port) {
                $port = ':' . absint($parts['port']);
            }
        }

        $query = isset($parts['query']) && $parts['query'] !== '' ? '?' . $parts['query'] : '';

        return esc_url_raw($scheme . '://' . $host . $port . $path . $query);
    }

    /**
     * Return URL variants used by older and newer Schemafy storage.
     *
     * @param string $url URL.
     * @return array
     */
    private function get_url_match_candidates($url) {
        $normalized = $this->normalize_meta_url($url);
        $legacy = $this->normalize_url($url);
        $queryless = $normalized ? strtok($normalized, '?') : '';
        $candidates = array_filter(array($url, $normalized, $legacy, $queryless));

        foreach ($candidates as $candidate) {
            if (strpos($candidate, '?') !== false) {
                continue;
            }

            $candidates[] = trailingslashit($candidate);
            $candidates[] = untrailingslashit($candidate);
        }

        if (empty($candidates) && $url !== '') {
            $candidates[] = (string) $url;
        }

        return array_values(array_unique(array_filter($candidates)));
    }


    /**
     * Register Schemafy meta tag overrides before wp_head runs.
     * Called on 'wp' action so filters are in place when SEO plugins output their tags.
     *
     * When Schemafy has a custom title/description for the current page, those values
     * override whatever the active SEO plugin would output, via each plugin's own filters.
     */
    public function register_meta_overrides() {
        if (is_admin()) return;

        list($t, $d) = $this->get_meta_overrides();

        if ('' === $t && '' === $d) return; // No Schemafy override for this page

        // One detection list for every SEO plugin (SCHEMAFY_Meta_Store), so a
        // plugin can never count as "active" for one decision and "absent" for
        // another — that mismatch used to drop the meta description entirely.
        foreach (SCHEMAFY_Meta_Store::active_plugins() as $seo) {
            // On a singular page an owner already renders the user's value from
            // its own rows, so overriding it would mean overriding a plugin
            // with its own data. Archives and term pages have no post to store
            // meta on, so there the filter is the only way to apply a
            // URL-level override.
            if ($seo['owns_storage'] && is_singular()) {
                continue;
            }

            if ('' !== $t) add_filter($seo['title_filter'], function() use ($t) { return $t; }, 99);
            if ('' !== $d) add_filter($seo['desc_filter'],  function() use ($d) { return $d; }, 99);
        }

        // Generic WordPress title. This is the ONLY way Schemafy sets a title:
        // WordPress renders <title> itself from this filter, so we never remove
        // core's _wp_render_title_tag and never echo a second <title> tag.
        if ('' !== $t) add_filter('pre_get_document_title',                                   function() use ($t) { return $t; }, 99);
        // Note: there is no equivalent generic WP filter for <meta name="description">.
        // When no supported SEO plugin is active, inject_meta_tags() outputs the
        // tag directly instead. We never rewrite another plugin's page output.
    }

    /**
     * The title and description Schemafy wants for the current request.
     *
     * Single source of truth, in priority order: post meta (meta box) →
     * schemafy_custom_meta_tags option (Meta Tags Manager) → the schema table's
     * meta columns. Both the filter path (register_meta_overrides) and the
     * direct-output path (inject_meta_tags) resolve through here, so the same
     * page cannot get two different titles depending on which plugins are
     * installed.
     *
     * @return array{0:string,1:string} [ title, description ]
     */
    private function get_meta_overrides() {
        // Resolved once per request: 'wp', wp_head:1 and wp_head:2 all ask.
        if (null !== $this->meta_overrides) {
            return $this->meta_overrides;
        }

        $current_url = $this->get_current_url();
        $post_id     = is_singular() ? get_the_ID() : 0;

        // On a singular page owned by an SEO plugin there is nothing for
        // Schemafy to emit: the user's value lives in that plugin's own rows
        // (SCHEMAFY_Meta_Store writes there), so it renders the value itself,
        // expanding its own %variables% as only it can. Staying silent here is
        // what makes the two screens agree instead of fight.
        if ($post_id > 0 && !SCHEMAFY_Meta_Store::schemafy_outputs_meta()) {
            $this->meta_overrides = array('', '');
            return $this->meta_overrides;
        }

        // Priority 1: post meta, when Schemafy owns the fields.
        $t = $post_id > 0 ? (string) get_post_meta($post_id, SCHEMAFY_Meta_Store::OWN_TITLE_KEY, true) : '';
        $d = $post_id > 0 ? (string) get_post_meta($post_id, SCHEMAFY_Meta_Store::OWN_DESC_KEY, true) : '';

        // Priority 2: options table (Meta Tags Manager), with URL normalization
        if ('' === $t || '' === $d) {
            $stored = get_option('schemafy_custom_meta_tags', array());
            if (is_array($stored)) {
                foreach ($this->get_url_match_candidates($current_url) as $try_url) {
                    if ($try_url && isset($stored[$try_url])) {
                        $e = $stored[$try_url];
                        if ('' === $t && !empty($e['title']))       $t = $e['title'];
                        if ('' === $d && !empty($e['description'])) $d = $e['description'];
                        break;
                    }
                }
            }
        }

        // Priority 3: the schema table's meta columns.
        //
        // Deliberately NOT falling back to another SEO plugin's stored value
        // here. Those values are templates, not finished strings — Rank Math
        // and Yoast store things like "%title% %sep% %sitename%" and expand the
        // variables themselves at render time. Feeding one back through our own
        // filter would print the placeholders verbatim. When Schemafy has no
        // value of its own it registers nothing and lets the other plugin work.
        if ('' === $t && '' === $d) {
            global $wpdb;
            $table_name     = $wpdb->prefix . 'schemafy_custom_schemas';
            $url_candidates = $this->get_url_match_candidates($current_url);

            if (!empty($url_candidates)) {
                $placeholders = implode(', ', array_fill(0, count($url_candidates), '%s'));
                $row = $wpdb->get_row($wpdb->prepare(
                    "SELECT meta_title, meta_description FROM $table_name
                     WHERE url IN ($placeholders) AND is_active = 1
                       AND (meta_title != '' OR meta_description != '')
                     ORDER BY updated_at DESC LIMIT 1",
                    $url_candidates
                ));

                if ($row) {
                    $t = (string) $row->meta_title;
                    $d = (string) $row->meta_description;
                }
            }
        }

        $this->meta_overrides = array($t, $d);

        return $this->meta_overrides;
    }

    /**
     * Inject custom meta tags
     * When an SEO plugin is active, overrides are handled via register_meta_overrides()
     * filters registered on the 'wp' action. This method only outputs directly when
     * no SEO plugin is present.
     *
     * @return void
     */
    public function inject_meta_tags() {
        // If an SEO plugin is active, our register_meta_overrides() already injected
        // filter overrides on the 'wp' action — the SEO plugin will output our values.
        if ($this->has_active_seo_plugin()) {
            return;
        }

        // The title is never echoed here. register_meta_overrides() puts it on
        // 'pre_get_document_title' and WordPress renders the single <title> tag
        // itself. Open Graph and Twitter tags belong to inject_social_meta_tags().
        // This method owns exactly one tag: <meta name="description">.
        list(, $meta_description) = $this->get_meta_overrides();

        if ('' === $meta_description) {
            return;
        }

        echo '<!-- Custom Meta Tags by ' . esc_html($this->brand_name) . ' -->' . "\n";
        echo '<meta name="description" content="' . esc_attr($meta_description) . '">' . "\n";
        echo '<!-- End Custom Meta Tags by ' . esc_html($this->brand_name) . ' -->' . "\n";
    }

    /**
     * Check if a major SEO plugin is actively managing meta tags
     *
     * @return bool True if SEO plugin is active
     */
    private function has_active_seo_plugin() {
        return SCHEMAFY_Meta_Store::any_seo_plugin_active();
    }

    /**
     * Get current URL
     * Uses WordPress core functions to prevent HTTP Host Header injection
     *
     * @return string Current URL
     */
    private function get_current_url() {
        // Use WordPress function to get current URL safely
        // This is protected against HTTP Host Header injection
        global $wp;
        $url = home_url(add_query_arg(array(), $wp->request));

        // If query string exists, add it
        if (!empty($_SERVER['QUERY_STRING'])) {
            $url .= '?' . sanitize_text_field(wp_unslash($_SERVER['QUERY_STRING']));
        }

        return $url;
    }


    /**
     * Validate schema JSON before output
     *
     * @param string $schema_data Schema JSON string
     * @return bool True if valid, false otherwise
     */
    public function validate_schema_json($schema_data) {
        $decoded = json_decode($schema_data);
        return json_last_error() === JSON_ERROR_NONE;
    }

    /**
     * Encode a decoded schema as JSON-LD safe to embed in a <script> block.
     *
     * JSON_HEX_TAG is what makes it safe: it turns every "<" into <, so no
     * value stored in a schema can produce a "</script" sequence and escape the
     * block. Matching on the literal "</script>" is not enough — HTML ends a
     * script element at "</script" followed by a space, tab, newline, "/" or ">",
     * so "</script >" would have slipped through and become live markup.
     *
     * The escape is plain JSON, so Google and every other consumer read it back
     * unchanged.
     *
     * @param mixed $data Decoded schema data.
     * @return string|false Encoded JSON-LD, or false when encoding fails.
     */
    public static function encode_jsonld($data) {
        return wp_json_encode(
            $data,
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG
        );
    }

    /**
     * Inject Open Graph and Twitter Card meta tags
     * Only injects if SEO plugins haven't already done it
     *
     * @return void
     */
    public function inject_social_meta_tags() {
        // Check if a major SEO plugin is already handling social meta tags
        if ($this->seo_plugin_handles_social_tags()) {
            return; // Let the SEO plugin handle it
        }

        $current_url = $this->get_current_url();
        $post_id = 0;

        if (is_singular()) {
            $post_id = get_the_ID();
        }

        // Get social tags from SCHEMAFY (custom or from SEO plugins)
        $social_tags = $this->get_social_tags($current_url, $post_id);

        // Fall back to what the page actually says, so the social preview never
        // disagrees with the document. wp_get_document_title() is the rendered
        // <title> with every plugin's filters and variable expansion already
        // applied — unlike a raw stored value, which can still contain
        // "%sep%"-style placeholders that only their own plugin knows how to
        // expand.
        list($fallback_title, $fallback_description) = $this->get_meta_overrides();
        if ('' === $fallback_title) {
            $fallback_title = wp_get_document_title();
        }

        $meta_title = !empty($social_tags['og_title']) ? $social_tags['og_title'] : $fallback_title;
        $meta_description = !empty($social_tags['og_description']) ? $social_tags['og_description'] : $fallback_description;

        // Get featured image as fallback for OG image
        $fallback_image = '';
        if ($post_id > 0 && has_post_thumbnail($post_id)) {
            $fallback_image = get_the_post_thumbnail_url($post_id, 'large');
        }

        echo '<!-- Open Graph & Twitter Cards by Schemafy Custom Schema -->' . "\n";

        // Open Graph tags
        if (!empty($meta_title)) {
            echo '<meta property="og:title" content="' . esc_attr($meta_title) . '" />' . "\n";
        }

        if (!empty($meta_description)) {
            echo '<meta property="og:description" content="' . esc_attr($meta_description) . '" />' . "\n";
        }

        $og_image = !empty($social_tags['og_image']) ? $social_tags['og_image'] : $fallback_image;
        if (!empty($og_image)) {
            echo '<meta property="og:image" content="' . esc_url($og_image) . '" />' . "\n";

            // Add image dimensions if available
            if ($post_id > 0 && has_post_thumbnail($post_id)) {
                $image_id = get_post_thumbnail_id($post_id);
                $image_meta = wp_get_attachment_metadata($image_id);
                if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
                    echo '<meta property="og:image:width" content="' . esc_attr($image_meta['width']) . '" />' . "\n";
                    echo '<meta property="og:image:height" content="' . esc_attr($image_meta['height']) . '" />' . "\n";
                }
            }
        }

        echo '<meta property="og:url" content="' . esc_url($current_url) . '" />' . "\n";
        echo '<meta property="og:type" content="' . esc_attr($social_tags['og_type'] ?: 'website') . '" />' . "\n";
        echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '" />' . "\n";
        echo '<meta property="og:locale" content="' . esc_attr(get_locale()) . '" />' . "\n";

        // Twitter Card tags
        echo '<meta name="twitter:card" content="' . esc_attr($social_tags['twitter_card'] ?: 'summary_large_image') . '" />' . "\n";

        $twitter_title = !empty($social_tags['twitter_title']) ? $social_tags['twitter_title'] : $meta_title;
        if (!empty($twitter_title)) {
            echo '<meta name="twitter:title" content="' . esc_attr($twitter_title) . '" />' . "\n";
        }

        $twitter_description = !empty($social_tags['twitter_description']) ? $social_tags['twitter_description'] : $meta_description;
        if (!empty($twitter_description)) {
            echo '<meta name="twitter:description" content="' . esc_attr($twitter_description) . '" />' . "\n";
        }

        $twitter_image = !empty($social_tags['twitter_image']) ? $social_tags['twitter_image'] : $og_image;
        if (!empty($twitter_image)) {
            echo '<meta name="twitter:image" content="' . esc_url($twitter_image) . '" />' . "\n";
        }

        if (!empty($social_tags['twitter_creator'])) {
            echo '<meta name="twitter:creator" content="' . esc_attr($social_tags['twitter_creator']) . '" />' . "\n";
        }

        echo '<!-- End Open Graph & Twitter Cards -->' . "\n";
    }

    /**
     * Check if SEO plugin is handling social meta tags
     *
     * @return bool True if SEO plugin handles social tags
     */
    private function seo_plugin_handles_social_tags() {
        foreach (SCHEMAFY_Meta_Store::active_plugins() as $slug => $seo) {
            // Yoast is the one that can have Open Graph switched off, in which
            // case it emits no social tags and Schemafy still should.
            if ('yoast' === $slug) {
                $options = get_option('wpseo_social');
                if (empty($options['opengraph'])) {
                    continue;
                }
            }

            return true;
        }

        return false;
    }

    /**
     * Suppress another plugin's schema output — only where the site owner asked
     * for it. Called during 'wp' action (after plugins_loaded, before wp_head).
     *
     * Every suppression here is opt-in through the Plugin Compatibility toggles
     * (option 'schemafy_compatibility'), which default to off. Schemafy never
     * disables another plugin's output on its own initiative.
     */
    public function suppress_third_party_schemas() {
        if (is_admin()) return;

        // Yoast SEO — disable JSON-LD schema graph entirely
        if (self::compat_enabled('disable_yoast_schema') && SCHEMAFY_Meta_Store::is_active('yoast')) {
            add_filter('wpseo_json_ld_output', '__return_false');
            // Also disable Yoast's breadcrumb schema
            add_filter('wpseo_schema_graph_pieces', '__return_empty_array', 99);
        }

        // Rank Math — disable JSON-LD output
        if (self::compat_enabled('disable_rankmath_schema') && SCHEMAFY_Meta_Store::is_active('rank-math')) {
            add_filter('rank_math/json_ld', '__return_empty_array', 99, 2);
        }

        // All in One SEO — disable schema markup
        if (self::compat_enabled('disable_aioseo_schema') && SCHEMAFY_Meta_Store::is_active('aioseo')) {
            add_filter('aioseo_schema_disable', '__return_true');
        }

        // SEOPress — disable JSON-LD
        if (self::compat_enabled('disable_seopress_schema') && SCHEMAFY_Meta_Store::is_active('seopress')) {
            add_filter('seopress_json_ld_output', '__return_false');
        }

        // The SEO Framework — disable schema output
        if (self::compat_enabled('disable_tsf_schema') && SCHEMAFY_Meta_Store::is_active('the-seo-framework')) {
            add_filter('the_seo_framework_schema_output', '__return_false', 99);
        }

        // WooCommerce — disable built-in product structured data.
        // Frontend only: order emails keep WooCommerce's structured data, which
        // Schemafy does not replace.
        if (self::compat_enabled('disable_woocommerce_schema')
            && class_exists('WooCommerce') && isset(WC()->structured_data)) {
            remove_action('wp_footer', array(WC()->structured_data, 'output_structured_data'), 10);
        }
    }

    /**
     * Get social tags for current page
     *
     * @param string $url Current URL
     * @param int $post_id Post ID
     * @return array Social tags data
     */
    private function get_social_tags($url, $post_id) {
        $social_data = array(
            'og_title' => '',
            'og_description' => '',
            'og_image' => '',
            'og_type' => 'website',
            'twitter_card' => 'summary_large_image',
            'twitter_title' => '',
            'twitter_description' => '',
            'twitter_image' => '',
            'twitter_creator' => ''
        );

        // Get from post meta (priority)
        if ($post_id > 0) {
            $social_data['og_title'] = get_post_meta($post_id, '_schemafy_og_title', true);
            $social_data['og_description'] = get_post_meta($post_id, '_schemafy_og_description', true);
            $social_data['og_image'] = get_post_meta($post_id, '_schemafy_og_image', true);
            $social_data['og_type'] = get_post_meta($post_id, '_schemafy_og_type', true) ?: 'website';
            $social_data['twitter_card'] = get_post_meta($post_id, '_schemafy_twitter_card', true) ?: 'summary_large_image';
            $social_data['twitter_title'] = get_post_meta($post_id, '_schemafy_twitter_title', true);
            $social_data['twitter_description'] = get_post_meta($post_id, '_schemafy_twitter_description', true);
            $social_data['twitter_image'] = get_post_meta($post_id, '_schemafy_twitter_image', true);
            $social_data['twitter_creator'] = get_post_meta($post_id, '_schemafy_twitter_creator', true);
        }

        // If empty, check options table by URL
        if (empty($social_data['og_title']) || empty($social_data['og_description'])) {
            $options_data = get_option('schemafy_social_tags', array());
            if (isset($options_data[$url])) {
                foreach ($social_data as $key => $value) {
                    if (empty($value) && !empty($options_data[$url][$key])) {
                        $social_data[$key] = $options_data[$url][$key];
                    }
                }
            }
        }

        return $social_data;
    }
}
