# thinkrank/2.2.0/includes/seo/class-schema-management-system.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version 2.2.0. 2,686 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/2.2.0/code/includes/seo/class-schema-management-system.php
- Raw: https://pluginprobe.com/plugins/thinkrank/2.2.0/raw/includes/seo/class-schema-management-system.php
- Modified: 2026-08-25T10:58:14+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/thinkrank/2.2.0/code/includes/seo/class-schema-management-system.php#L10-L20`.

```php
<?php
/**
 * Schema Management System Class
 *
 * Streamlined schema markup generation and management system with structured
 * data creation, validation, and automated deployment. Implements 2025 Schema.org
 * standards with clean separation of concerns.
 *
 * @package ThinkRank
 * @subpackage SEO
 * @since 1.0.0
 */

declare(strict_types=1);

namespace ThinkRank\SEO;

use ThinkRank\Config\Schema_Settings_Config;

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

/**
 * Schema Management System Class
 *
 * Provides streamlined schema markup management with generation, validation,
 * rich snippets optimization, and automated deployment. Focuses on core
 * structured data creation with clean separation of concerns.
 *
 * @since 1.0.0
 */
class Schema_Management_System extends Abstract_SEO_Manager {

    /**
     * Schema.org types with 2025 specifications
     *
     * @since 1.0.0
     * @var array
     */
    private array $schema_types = [
        'Article' => [
            'name' => 'Article',
            'description' => 'News articles, blog posts, and editorial content',
            'required_properties' => ['headline', 'author', 'datePublished'],
            'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
            'rich_snippets' => ['article', 'news_article', 'blog_posting'],
            'context_types' => ['post', 'page'],
            'priority' => 'high'
        ],
        'TechnicalArticle' => [
            'name' => 'TechnicalArticle',
            'description' => 'Technical documentation and tutorials',
            'required_properties' => ['headline', 'author', 'datePublished'],
            'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dependencies', 'proficiencyLevel'],
            'rich_snippets' => ['article', 'technical_article'],
            'context_types' => ['post', 'page'],
            'priority' => 'high'
        ],
        'NewsArticle' => [
            'name' => 'NewsArticle',
            'description' => 'News articles and press releases',
            'required_properties' => ['headline', 'author', 'datePublished'],
            'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dateline'],
            'rich_snippets' => ['article', 'news_article'],
            'context_types' => ['post', 'page'],
            'priority' => 'high'
        ],
        'ScholarlyArticle' => [
            'name' => 'ScholarlyArticle',
            'description' => 'Academic and research articles',
            'required_properties' => ['headline', 'author', 'datePublished'],
            'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'citation', 'abstract'],
            'rich_snippets' => ['article', 'scholarly_article'],
            'context_types' => ['post', 'page'],
            'priority' => 'high'
        ],
        'Report' => [
            'name' => 'Report',
            'description' => 'Reports and analytical content',
            'required_properties' => ['headline', 'author', 'datePublished'],
            'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
            'rich_snippets' => ['article', 'report'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],
        'Product' => [
            'name' => 'Product',
            'description' => 'Products for e-commerce and retail',
            'required_properties' => ['name', 'description'],
            'recommended_properties' => ['image', 'offers', 'brand', 'sku', 'gtin', 'review', 'aggregateRating'],
            'rich_snippets' => ['product', 'offer', 'review'],
            'context_types' => ['product', 'page', 'post'],
            'priority' => 'critical'
        ],
        'LocalBusiness' => [
            'name' => 'LocalBusiness',
            'description' => 'Local businesses and service providers',
            'required_properties' => ['name', 'address'],
            'recommended_properties' => ['telephone', 'url', 'openingHours', 'geo', 'priceRange'],
            'rich_snippets' => ['local_business', 'organization'],
            'context_types' => ['site', 'page'],
            'priority' => 'high'
        ],
        'Organization' => [
            'name' => 'Organization',
            'description' => 'Companies, corporations, and institutions',
            'required_properties' => ['name', 'url'],
            'recommended_properties' => ['logo', 'contactPoint', 'sameAs', 'address'],
            'rich_snippets' => ['organization', 'corporation'],
            'context_types' => ['site'],
            'priority' => 'medium'
        ],
        'WebSite' => [
            'name' => 'WebSite',
            'description' => 'Website and web application information',
            'required_properties' => ['name', 'url'],
            'recommended_properties' => ['description', 'author', 'publisher', 'potentialAction'],
            'rich_snippets' => ['website', 'sitelinks_searchbox'],
            'context_types' => ['site'],
            'priority' => 'high'
        ],
        'SoftwareApplication' => [
            'name' => 'SoftwareApplication',
            'description' => 'Software applications and web apps',
            'required_properties' => ['name', 'applicationCategory'],
            'recommended_properties' => ['description', 'url', 'offers', 'creator', 'features', 'aggregateRating'],
            'rich_snippets' => ['software', 'app_rating', 'pricing'],
            'context_types' => ['post', 'page'],
            'priority' => 'high'
        ],
        'Person' => [
            'name' => 'Person',
            'description' => 'Individual people and authors',
            'required_properties' => ['name'],
            'recommended_properties' => ['url', 'image'],
            'optional_properties' => ['jobTitle', 'worksFor', 'sameAs', 'description'],
            'rich_snippets' => ['person', 'author'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],

        'HowTo' => [
            'name' => 'HowTo',
            'description' => 'Step-by-step instructions and tutorials',
            'required_properties' => ['name', 'step'],
            'recommended_properties' => ['image', 'totalTime', 'estimatedCost', 'tool', 'supply'],
            'rich_snippets' => ['how_to', 'recipe'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],
        'Event' => [
            'name' => 'Event',
            'description' => 'Events, conferences, and gatherings',
            'required_properties' => ['name', 'startDate', 'location'],
            'recommended_properties' => ['endDate', 'description', 'image', 'offers', 'performer'],
            'rich_snippets' => ['event', 'social_event'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],
        'VideoObject' => [
            'name' => 'VideoObject',
            'description' => 'Videos and embedded media content',
            'required_properties' => ['name', 'description', 'thumbnailUrl', 'uploadDate'],
            'recommended_properties' => ['contentUrl', 'embedUrl', 'duration'],
            'rich_snippets' => ['video', 'video_carousel'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],
        // Offered by the metabox dropdown and registered in Schema_Factory, but
        // absent here — generate_schema_markup() keys off this array, so a
        // Review request was silently skipped (#462).
        'Review' => [
            'name' => 'Review',
            'description' => 'Reviews and ratings of a product, service or place',
            'required_properties' => ['itemReviewed', 'reviewRating', 'author'],
            'recommended_properties' => ['reviewBody', 'datePublished', 'publisher'],
            'rich_snippets' => ['review', 'review_snippet'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],
        'Recipe' => [
            'name' => 'Recipe',
            'description' => 'Cooking recipes and food preparation',
            'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'],
            'recommended_properties' => ['cookTime', 'prepTime', 'totalTime', 'recipeYield', 'nutrition'],
            'rich_snippets' => ['recipe', 'cooking'],
            'context_types' => ['post', 'page'],
            'priority' => 'medium'
        ],
        'BlogPosting' => [
            'name' => 'BlogPosting',
            'description' => 'Blog posts and personal articles',
            'required_properties' => ['headline', 'author', 'datePublished'],
            'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'wordCount'],
            'rich_snippets' => ['article', 'blog_posting'],
            'context_types' => ['post'],
            'priority' => 'high'
        ],
        'WebPage' => [
            'name' => 'WebPage',
            'description' => 'Individual web pages',
            'required_properties' => ['name', 'url'],
            'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
            'rich_snippets' => ['webpage', 'breadcrumb'],
            'context_types' => ['page'],
            'priority' => 'medium'
        ],
        'FAQPage' => [
            'name' => 'FAQPage',
            'description' => 'Frequently Asked Questions pages',
            'required_properties' => ['mainEntity'],
            'recommended_properties' => ['about', 'author'],
            'rich_snippets' => ['faq', 'question'],
            'context_types' => ['page', 'post'],
            'priority' => 'high'
        ],

    ];

    /**
     * Rich snippets configuration with Google guidelines
     *
     * @since 1.0.0
     * @var array
     */
    private array $rich_snippets_config = [
        'testing_tools' => [
            'google_structured_data' => 'https://search.google.com/test/rich-results',
            'schema_markup_validator' => 'https://validator.schema.org/',
            'google_rich_results' => 'https://search.google.com/search-console/rich-results'
        ],
        'appearance_tracking' => [
            'search_appearance' => ['title', 'description', 'image', 'rating', 'price'],
            'rich_features' => ['breadcrumbs', 'sitelinks', 'reviews', 'faq', 'how_to'],
            'performance_metrics' => ['click_through_rate', 'impressions', 'position']
        ],
        'optimization_guidelines' => [
            'image_requirements' => [
                'min_width' => 1200,
                'min_height' => 675,
                'aspect_ratio' => '16:9',
                'formats' => ['jpg', 'png', 'webp']
            ],
            'content_requirements' => [
                'min_description_length' => 50,
                'max_description_length' => 300,
                'required_fields_completion' => 80
            ]
        ]
    ];

    /**
     * Schema validation rules and requirements
     *
     * @since 1.0.0
     * @var array
     */
    private array $validation_rules = [
        'required_context' => '@context',
        'required_type' => '@type',
        'url_validation' => [
            'protocols' => ['http', 'https'],
            'format_check' => true
        ],
        'date_validation' => [
            'format' => 'ISO8601',
            'timezone_aware' => true
        ],
        'image_validation' => [
            'url_required' => true,
            'dimensions_check' => true,
            'format_validation' => true
        ],
        'text_validation' => [
            'html_allowed' => false,
            'length_limits' => true,
            'encoding' => 'UTF-8'
        ]
    ];

    /**
     * Schema deployment configuration
     *
     * @since 1.0.0
     * @var array
     */
    private array $deployment_config = [
        'output_methods' => [
            'json_ld' => [
                'enabled' => true,
                'priority' => 1,
                'location' => 'head'
            ]
        ],
        'caching' => [
            'enabled' => true,
            'duration' => 3600, // 1 hour
            'invalidation_triggers' => ['content_update', 'settings_change']
        ],
        'conditional_loading' => [
            'context_specific' => true,
            'user_agent_detection' => false,
            'performance_based' => true
        ]
    ];

    /**
     * Schema Builder instance for schema construction
     *
     * @var \ThinkRank\SEO\Schema_Builder|null
     */
    private ?\ThinkRank\SEO\Schema_Builder $schema_builder = null;

    /**
     * Schema Cache Manager instance for performance optimization
     *
     * @since 1.0.0
     * @var Schema_Cache_Manager|null
     */
    private ?Schema_Cache_Manager $cache_manager = null;

    /**
     * Whether the foreign-settings listener has been registered this request.
     *
     * Static because `thinkrank_seo_settings_saved` is a global hook — one
     * listener serves every instance. See the constructor for why (#463).
     *
     * @since 1.16.0
     * @var bool
     */
    private static bool $foreign_settings_listener_registered = false;

    /**
     * Constructor
     *
     * @since 1.0.0
     */
    public function __construct() {
        parent::__construct('schema_management_system');

        // Load shared settings configuration
        if (!class_exists('ThinkRank\\Config\\Schema_Settings_Config')) {
            require_once THINKRANK_PLUGIN_DIR . 'includes/config/schema-settings-config.php';
        }

        // Initialize Schema Builder for schema construction
        $this->initialize_schema_builder();

        // Initialize Schema Cache Manager for performance optimization
        $this->initialize_cache_manager();

        // LocalBusiness and Organization both read Business Info, which Site
        // Identity owns. Without this, editing an address or phone number never
        // refreshed the deployed schema (#455).
        //
        // Registered at most once per request. WordPress keys callbacks by
        // object hash, so binding $this here added a fresh listener for every
        // instance — and this class is constructed from inside the very callback
        // it registers, which doubled the listener count on every settings save
        // (#463). The guard is static because the hook itself is global.
        if (!self::$foreign_settings_listener_registered) {
            self::$foreign_settings_listener_registered = true;
            add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4);
        }
    }

    /**
     * Regenerate schema when another manager saves settings this schema reads.
     *
     * Site Identity owns the Business Info fields that feed LocalBusiness and
     * the Organization address/contactPoint, so a save there has to refresh the
     * deployed schema even though no schema setting changed.
     *
     * @since 2.0.2
     *
     * @param string   $manager_type Settings category that was saved.
     * @param array    $settings     Settings that were written.
     * @param string   $context_type Context type.
     * @param int|null $context_id   Context ID.
     * @return void
     */
    public function refresh_schema_for_foreign_settings(
        string $manager_type,
        array $settings,
        string $context_type,
        ?int $context_id
    ): void {
        if ('site_identity' !== $manager_type) {
            return;
        }

        $business_keys = [
            'business_name', 'business_type', 'business_address', 'business_city',
            'business_state', 'business_postal_code', 'business_country',
            'business_phone', 'business_email', 'business_hours',
            'business_latitude', 'business_longitude', 'business_price_range',
        ];

        if (empty(array_intersect_key($settings, array_flip($business_keys)))) {
            return;
        }

        $schema_settings = $this->get_settings($context_type, $context_id);
        if (empty($schema_settings['auto_deploy'])) {
            return;
        }

        // Only refresh types that are actually deployed, so this never adds a
        // type the admin did not enable.
        $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id));
        $affected = array_values(array_intersect($deployed, ['LocalBusiness', 'Organization']));

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

        try {
            $generation = $this->generate_schema_markup($context_type, $context_id, $affected);

            // Deploy the types that validated, not all-or-nothing. Gating on
            // deployment_ready meant one invalid type blocked every valid one
            // in the same batch (#470).
            $deployable = [];
            foreach ($affected as $type) {
                if (!empty($generation['generated_schemas'][$type])
                    && !empty($generation['validation_results'][$type]['is_valid'])
                ) {
                    $deployable[$type] = $generation['generated_schemas'][$type];
                }
            }

            if (!empty($deployable)) {
                $this->deploy_schema_markup($context_type, $context_id, $deployable);
            }
        } catch (\Exception $e) {
            if (defined('WP_DEBUG') && WP_DEBUG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage());
            }
        }
    }

    /**
     * Initialize Schema Builder
     *
     * @return void
     */
    private function initialize_schema_builder(): void {
        if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
            require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
        }

        if (class_exists('ThinkRank\\SEO\\Schema_Builder')) {
            $this->schema_builder = new \ThinkRank\SEO\Schema_Builder();
        }
    }

    /**
     * Initialize Schema Cache Manager
     *
     * @since 1.0.0
     *
     * @return void
     */
    private function initialize_cache_manager(): void {
        if (!class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
            require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php';
        }

        if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
            // Honour the stored cache_duration setting. It is exposed in
            // get_settings_schema() (min 300 / max 86400), validated, persisted
            // and surfaced through both abilities — but the cache manager was
            // always built from the hardcoded config value, so the setting had
            // no effect (#473). Falls back to the config default.
            $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600;

            $stored = $this->get_settings('site', null)['cache_duration'] ?? null;
            if (is_numeric($stored) && (int) $stored > 0) {
                $cache_duration = (int) $stored;
            }

            $this->cache_manager = new Schema_Cache_Manager($cache_duration);
        }
    }

    /**
     * Generate schema markup with comprehensive content analysis integration
     *
     * @since 1.0.0
     *
     * Generation is read-only by default. Persisting the result is opt-in via
     * `$options['persist']`, because this method is also reached from the
     * front-end read path (get_output_data()) and from GET routes — where a
     * DELETE + INSERT would destroy the admin's deployed rows and publish
     * types nobody deployed (#460).
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @param array    $schema_types Schema types to generate
     * @param array    $options      Generation options. Pass `persist => true`
     *                               from explicit write paths only.
     * @return array Comprehensive schema generation results
     */
    public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array {
        $generation = [
            'context_type' => $context_type,
            'context_id' => $context_id,
            'generated_schemas' => [],
            'validation_results' => [],
            'rich_snippets_preview' => [],
            'optimization_recommendations' => [],
            'deployment_ready' => false,
            'generation_timestamp' => current_time('mysql')
        ];

        // Auto-detect schema types if not provided
        if (empty($schema_types)) {
            $schema_types = $this->auto_detect_schema_types($context_type, $context_id);
        }

        // Apply content schema settings from options
        $schema_types = $this->apply_content_schema_settings_from_options($schema_types, $options, $context_type);

        // Simplified: Content analysis and optimization moved to separate services
        // Schema generation focuses on core structured data creation

        // Generate schema for each type using Schema_Builder directly
        foreach ($schema_types as $schema_type) {
            if (isset($this->schema_types[$schema_type])) {
                // Prepare content data for Schema_Builder
                $content_data = $this->prepare_content_data_for_generator(
                    $context_type,
                    $context_id,
                    [], // Simplified: content analysis handled by separate services
                    [], // Simplified: optimization data handled by separate services
                    $options
                );

                // Simplified: Knowledge graph enhancements moved to separate service

                // Generate schema using Schema_Builder directly
                $schema_data = $this->schema_builder->build_schema(
                    $schema_type,
                    $content_data,
                    $context_type
                );

                // Apply rich snippets optimization if enabled
                if ($options['rich_snippets_optimization'] ?? true) {
                    $schema_data = $this->apply_rich_snippets_optimization($schema_data, $schema_type);
                }

                $generation['generated_schemas'][$schema_type] = $schema_data;

                // Validate generated schema using proper Schema_Validator
                $validation = $this->validate_schema_markup($schema_data, $schema_type);
                $generation['validation_results'][$schema_type] = $validation;

                // Generate rich snippets preview
                $preview = $this->generate_rich_snippets_preview($schema_data, $schema_type);
                $generation['rich_snippets_preview'][$schema_type] = $preview;
            }
        }

        // Generate optimization recommendations
        $generation['optimization_recommendations'] = $this->generate_schema_optimization_recommendations(
            $generation['generated_schemas'],
            $generation['validation_results']
        );

        // Check deployment readiness
        $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']);

        // Persistence belongs to deployment, not generation. Every write path
        // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(),
        // the deploy route) calls deploy_schema_markup() straight after generating,
        // so nothing needs to opt in today — the flag exists to keep this an
        // explicit decision rather than an accident.
        if (!empty($options['persist'])) {
            $this->store_schema_data($context_type, $context_id, $generation);
        }

        return $generation;
    }

    /**
     * Validate schema markup with comprehensive testing
     *
     * @since 1.0.0
     *
     * @param array  $schema_data Schema data to validate
     * @param string $schema_type Schema type
     * @param array  $options     Validation options
     * @return array Comprehensive validation results
     */
    public function validate_schema_markup(array $schema_data, string $schema_type, array $options = []): array {
        $validation = [
            'schema_type' => $schema_type,
            'is_valid' => false,
            'validation_score' => 0,
            'required_properties_check' => [],
            'recommended_properties_check' => [],
            'structural_validation' => [],
            'google_guidelines_compliance' => [],
            'rich_snippets_eligibility' => [],
            'errors' => [],
            'warnings' => [],
            'suggestions' => [],
            'validation_timestamp' => current_time('mysql')
        ];

        // Use Schema_Validator for validation
        if (!class_exists('ThinkRank\\SEO\\Schema_Validator')) {
            require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-validator.php';
        }

        $schema_validator = new \ThinkRank\SEO\Schema_Validator();
        $validator_validation = $schema_validator->validate_schema($schema_data);

        // Map Schema_Validator validation to our format
        if ($validator_validation) {
            $validation['is_valid'] = $validator_validation['valid'] ?? false;
            $validation['validation_score'] = $validator_validation['score'] ?? 0;
            $validation['errors'] = $validator_validation['errors'] ?? [];
            $validation['warnings'] = $validator_validation['warnings'] ?? [];
            $validation['suggestions'] = $validator_validation['suggestions'] ?? [];

            // Set basic validation checks
            $validation['required_properties_check'] = ['status' => 'checked'];
            $validation['recommended_properties_check'] = ['status' => 'checked'];
            $validation['structural_validation'] = ['valid_structure' => $validation['is_valid']];
            $validation['google_guidelines_compliance'] = ['compliant' => $validation['is_valid']];
            $validation['rich_snippets_eligibility'] = ['eligible' => $validation['is_valid']];
        } else {
            // Fallback validation
            $validation['is_valid'] = !empty($schema_data['@type']);
            $validation['validation_score'] = $validation['is_valid'] ? 85 : 0;
        }

        return $validation;
    }

    /**
     * Optimize rich snippets with preview and testing capabilities
     *
     * @since 1.0.0
     *
     * @param array  $schema_data Schema data to optimize
     * @param string $schema_type Schema type
     * @param array  $options     Optimization options
     * @return array Rich snippets optimization results
     */
    public function optimize_rich_snippets(array $schema_data, string $schema_type, array $options = []): array {
        $optimization = [
            'schema_type' => $schema_type,
            'original_schema' => $schema_data,
            'optimized_schema' => [],
            'rich_snippets_preview' => [],
            'optimization_score' => 0,
            'appearance_probability' => 0,
            'optimization_changes' => [],
            'testing_results' => [],
            'recommendations' => [],
            'optimization_timestamp' => current_time('mysql')
        ];

        // Rich snippets optimization is not yet fully implemented
        // Return the original schema with basic optimization info
        $optimization['optimized_schema'] = $schema_data;

        // Generate rich snippets preview
        $optimization['rich_snippets_preview'] = $this->generate_rich_snippets_preview($schema_data, $schema_type);

        // Basic optimization metrics
        $optimization['optimization_score'] = 85; // Default score
        $optimization['appearance_probability'] = 75; // Default probability
        $optimization['optimization_changes'] = [];
        $optimization['testing_results'] = ['status' => 'not_implemented'];
        $optimization['recommendations'] = ['message' => 'Rich snippets optimization is not yet fully implemented'];

        return $optimization;
    }

    /**
     * Deploy schema markup with automated implementation
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @param array    $schema_data  Schema data to deploy
     * @param array    $options      Deployment options
     * @return array Schema deployment results
     */
    public function deploy_schema_markup(string $context_type, ?int $context_id, array $schema_data, array $options = []): array {
        $deployment = [
            'context_type' => $context_type,
            'context_id' => $context_id,
            'deployment_method' => 'json_ld', // Always JSON-LD (only supported method)
            'deployment_status' => 'pending',
            'deployed_schemas' => [],
            'deployment_location' => 'head',
            'cache_status' => [],
            'validation_post_deployment' => [],
            'deployment_timestamp' => current_time('mysql')
        ];

        // Determine deployment method
        $deployment['deployment_method'] = $this->determine_deployment_method($options);

        // When the caller owns the whole context — the user pressing Deploy, where
        // the payload is exactly what the preview showed — anything not in that
        // payload should come off the page (#464). Incremental callers such as
        // auto_deploy_schema_on_settings_change() pass only the types they
        // regenerated, so they must NOT retire the rest.
        if (!empty($options['authoritative'])) {
            $deployment['retired_schemas'] = $this->retire_schema_types(
                $context_type,
                $context_id,
                array_diff(
                    array_keys($this->get_deployed_schemas($context_type, $context_id)),
                    array_keys($schema_data)
                )
            );
        }

        // Deploy each schema
        foreach ($schema_data as $schema_type => $schema) {
            $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
            $deployment['deployed_schemas'][$schema_type] = $deploy_result;
        }

        // Clean up duplicate schemas
        $this->cleanup_duplicate_schemas($context_type, $context_id);

        // CACHE INVALIDATION: Clear cache after successful deployment
        $cache_invalidated = false;
        if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
            $this->cache_manager->invalidate_context_cache($context_type, $context_id);
            $cache_invalidated = true;
        }

        $deployment['cache_status'] = $cache_invalidated
            ? ['cache_updated' => true, 'message' => 'Schema cache invalidated']
            : ['cache_updated' => false, 'message' => 'No schema cache to invalidate'];

        // Post-deployment verification: read back through the same accessor the
        // front end uses, so a row that was written but is not retrievable (wrong
        // context, inactive, stale cache) is reported as a failure instead of
        // being assumed successful.
        $deployment['validation_post_deployment'] = $this->verify_deployment(
            $context_type,
            $context_id,
            array_keys($deployment['deployed_schemas'])
        );

        $writes_ok = !empty($deployment['deployed_schemas']);
        foreach ($deployment['deployed_schemas'] as $deploy_result) {
            if (empty($deploy_result['deployed'])) {
                $writes_ok = false;
                break;
            }
        }

        $deployment['deployment_status'] =
            ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed']))
                ? 'success'
                : 'failed';

        return $deployment;
    }

    /**
     * Verify deployed schema is retrievable after a deploy.
     *
     * Reads back through get_deployed_schemas() — the same accessor
     * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so
     * the check reflects what will actually reach the page rather than only that
     * an INSERT returned without error.
     *
     * @since 1.32.0
     *
     * @param string   $context_type   Context type
     * @param int|null $context_id     Context ID
     * @param array    $expected_types Schema types that were just deployed
     * @return array Validation result
     */
    private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array {
        if (empty($expected_types)) {
            return [
                'validation_passed' => false,
                'message' => 'No schema was deployed',
                'missing_types' => []
            ];
        }

        $retrieved = $this->get_deployed_schemas($context_type, $context_id);
        $missing   = array_values(array_diff($expected_types, array_keys($retrieved)));

        if (!empty($missing)) {
            return [
                'validation_passed' => false,
                'message' => sprintf(
                    /* translators: %s: comma-separated list of schema types */
                    __('Deployed schema could not be read back: %s', 'thinkrank'),
                    implode(', ', $missing)
                ),
                'missing_types' => $missing
            ];
        }

        return [
            'validation_passed' => true,
            'message' => __('Schema deployed and read back from storage', 'thinkrank'),
            'missing_types' => []
        ];
    }

    /**
     * Track schema performance and rich snippet appearances
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @param array    $options      Tracking options
     * @return array Schema performance tracking results
     */
    public function track_schema_performance(string $context_type, ?int $context_id, array $options = []): array {
        // Performance tracking is not yet implemented
        // This method returns empty data structure for API compatibility
        return [
            'context_type' => $context_type,
            'context_id' => $context_id,
            'rich_snippets_appearances' => [],
            'search_performance' => [],
            'click_through_rates' => [],
            'schema_errors' => [],
            'performance_trends' => [],
            'optimization_impact' => [],
            'tracking_timestamp' => current_time('mysql'),
            'tracking_enabled' => false,
            'message' => 'Performance tracking feature is not yet implemented'
        ];
    }

    /**
     * Validate SEO settings (implements interface)
     *
     * @since 1.0.0
     *
     * @param array $settings Settings array to validate
     * @return array Validation results
     */
    public function validate_settings(array $settings): array {
        $validation = [
            'valid' => true,
            'errors' => [],
            'warnings' => [],
            'suggestions' => [],
            'score' => 100
        ];

        // Validate schema types configuration
        if (isset($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
            foreach ($settings['enabled_schema_types'] as $schema_type) {
                if (!isset($this->schema_types[$schema_type])) {
                    $validation['errors'][] = "Invalid schema type: {$schema_type}";
                    $validation['valid'] = false;
                }
            }
        }

        // Note: Only JSON-LD deployment method is supported (no validation needed since it's hardcoded)

        // Validate auto-generation settings
        if (isset($settings['auto_generate_schema']) && !is_bool($settings['auto_generate_schema'])) {
            $validation['errors'][] = 'Auto-generate schema setting must be boolean';
            $validation['valid'] = false;
        }

        // Validate validation requirements
        if (isset($settings['validation_level'])) {
            $valid_levels = ['strict', 'moderate', 'lenient'];
            if (!in_array($settings['validation_level'], $valid_levels, true)) {
                $validation['errors'][] = 'Invalid validation level specified';
                $validation['valid'] = false;
            }
        }

        // Validate rich snippets optimization
        if (isset($settings['rich_snippets_optimization']) && !is_bool($settings['rich_snippets_optimization'])) {
            $validation['errors'][] = 'Rich snippets optimization setting must be boolean';
            $validation['valid'] = false;
        }

        // Validate performance tracking
        if (isset($settings['performance_tracking']) && !is_bool($settings['performance_tracking'])) {
            $validation['errors'][] = 'Performance tracking setting must be boolean';
            $validation['valid'] = false;
        }

        // Validate cache settings
        if (isset($settings['cache_duration'])) {
            if (!is_numeric($settings['cache_duration']) || $settings['cache_duration'] < 0) {
                $validation['errors'][] = 'Cache duration must be a positive number';
                $validation['valid'] = false;
            }
        }

        // Calculate validation score
        $validation['score'] = $this->calculate_validation_score($validation);

        return $validation;
    }

    /**
     * Get output data for frontend rendering (implements interface)
     *
     * @since 1.0.0
     *
     * @param string   $context_type The context type
     * @param int|null $context_id   Optional. Context ID
     * @return array Output data ready for frontend rendering
     */
    public function get_output_data(string $context_type, ?int $context_id): array {
        $settings = $this->get_settings($context_type, $context_id);

        $output = [
            'schema_dashboard' => [],
            'generated_schemas' => [],
            'validation_results' => [],
            'rich_snippets_preview' => [],
            'performance_data' => [],
            'recommendations' => [],
            // Report the real setting. Hardcoding true here told every consumer
            // the feature was on even when the master switch was off (#461).
            'enabled' => (bool) ($settings['enabled'] ?? true)
        ];

        // Get enabled schema types
        $enabled_types = $settings['enabled_schema_types'] ?? [];

        // Auto-generate schema types if enabled and no manual types specified
        if (empty($enabled_types) && ($settings['auto_generate_schema'] ?? true)) {
            $enabled_types = $this->auto_detect_schema_types($context_type, $context_id);
        }

        // Add content-specific schema types based on settings
        $enabled_types = $this->apply_content_schema_settings($enabled_types, $settings, $context_type);

        if (!empty($enabled_types)) {
            // Generate schema markup with enhanced options
            $generation_options = [
                'knowledge_graph' => $settings['knowledge_graph'] ?? true,
                'rich_snippets_optimization' => $settings['rich_snippets_optimization'] ?? true,
                'validation_level' => $settings['validation_level'] ?? 'moderate'
            ];

            $generation_results = $this->generate_schema_markup($context_type, $context_id, $enabled_types, $generation_options);

            // Populate output data
            $output['generated_schemas'] = $generation_results['generated_schemas'] ?? [];
            $output['validation_results'] = $generation_results['validation_results'] ?? [];
            $output['rich_snippets_preview'] = $generation_results['rich_snippets_preview'] ?? [];
            $output['recommendations'] = $generation_results['optimization_recommendations'] ?? [];

            // Get schema dashboard data
            $output['schema_dashboard'] = [
                'total_schemas' => count($generation_results['generated_schemas'] ?? []),
                'valid_schemas' => count(array_filter($generation_results['validation_results'] ?? [], function($v) { return $v['is_valid'] ?? false; })),
                'deployment_ready' => $generation_results['deployment_ready'] ?? false,
                'last_generated' => current_time('mysql')
            ];

            // Get performance data if tracking enabled
            if ($settings['performance_tracking'] ?? false) {
                $output['performance_data'] = $this->track_schema_performance($context_type, $context_id);
            }
        }

        return $output;
    }

    // Removed knowledge graph enhancements - moved to separate service
    // Social links and enhanced data handled by Schema_Builder directly

    /**
     * Apply rich snippets optimization to schema data
     *
     * @since 1.0.0
     *
     * @param array  $schema_data Schema data
     * @param string $schema_type Schema type
     * @return array Optimized schema data
     */
    private function apply_rich_snippets_optimization(array $schema_data, string $schema_type): array {
        switch ($schema_type) {
            case 'Article':
            case 'BlogPosting':
                // Rich snippets optimization for articles
                // Note: Image is optional - only include if user provides one
                // No default image fallback to avoid non-existent image URLs

                // Optimize headline length for rich snippets
                if (!empty($schema_data['headline']) && strlen($schema_data['headline']) > 110) {
                    $schema_data['headline'] = substr($schema_data['headline'], 0, 107) . '...';
                }
                break;

            case 'Organization':
                // Ensure logo for rich snippets
                if (empty($schema_data['logo'])) {
                    $schema_data['logo'] = $this->get_default_organization_logo();
                }
                break;

            case 'Product':
                // Ensure required properties for product rich snippets
                if (empty($schema_data['offers'])) {
                    $schema_data['offers'] = [
                        '@type' => 'Offer',
                        'availability' => 'https://schema.org/InStock',
                        'priceCurrency' => 'USD'
                    ];
                }
                break;
        }

        return $schema_data;
    }

    /**
     * Apply content schema settings from options to enabled types
     *
     * @since 1.0.0
     *
     * @param array  $enabled_types Current enabled types
     * @param array  $options       Generation options
     * @param string $context_type  Context type
     * @return array Enhanced enabled types
     */
    private function apply_content_schema_settings_from_options(array $enabled_types, array $options, string $context_type): array {
        // For site context: only apply site-level schema settings
        if ($context_type === 'site') {
            // Add local business schema if enabled
            if ($options['enable_local_business'] ?? false) {
                if (!in_array('LocalBusiness', $enabled_types, true)) {
                    $enabled_types[] = 'LocalBusiness';
                }
            }

            return $enabled_types;
        }

        // For post/page context: apply all schema settings (metabox functionality)

        // Add article schema if enabled and context is appropriate
        if ($options['enable_article_schema'] ?? false) {
            if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
                $enabled_types[] = 'Article';
            }
        }

        // Add FAQ schema if enabled
        if ($options['enable_faq_schema'] ?? false) {
            if (!in_array('FAQPage', $enabled_types, true)) {
                $enabled_types[] = 'FAQPage';
            }
        }

        // Add How-To schema if enabled
        if ($options['enable_howto_schema'] ?? false) {
            if (!in_array('HowTo', $enabled_types, true)) {
                $enabled_types[] = 'HowTo';
            }
        }

        // Add product schema if enabled and context is appropriate
        if ($options['enable_product_schema'] ?? false) {
            if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
                $enabled_types[] = 'Product';
            }
        }

        // Add local business schema if enabled
        if ($options['enable_local_business'] ?? false) {
            if (!in_array('LocalBusiness', $enabled_types, true)) {
                $enabled_types[] = 'LocalBusiness';
            }
        }

        return $enabled_types;
    }

    /**
     * Apply content schema settings to enabled types
     *
     * @since 1.0.0
     *
     * @param array  $enabled_types Current enabled types
     * @param array  $settings      Schema settings
     * @param string $context_type  Context type
     * @return array Enhanced enabled types
     */
    private function apply_content_schema_settings(array $enabled_types, array $settings, string $context_type): array {
        // For site context: only apply site-level schema settings
        if ($context_type === 'site') {
            // Add local business schema if enabled
            if ($settings['enable_local_business'] ?? false) {
                if (!in_array('LocalBusiness', $enabled_types, true)) {
                    $enabled_types[] = 'LocalBusiness';
                }
            }

            // Add breadcrumbs schema if enabled (site-wide feature)
            if ($settings['enable_breadcrumbs_schema'] ?? false) {
                if (!in_array('BreadcrumbList', $enabled_types, true)) {
                    $enabled_types[] = 'BreadcrumbList';
                }
            }

            return $enabled_types;
        }

        // For post/page context: apply all schema settings (metabox functionality)

        // Add article schema if enabled and context is appropriate
        if ($settings['enable_article_schema'] ?? false) {
            if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
                $enabled_types[] = 'Article';
            }
        }

        // Add FAQ schema if enabled
        if ($settings['enable_faq_schema'] ?? false) {
            if (!in_array('FAQPage', $enabled_types, true)) {
                $enabled_types[] = 'FAQPage';
            }
        }

        // Add How-To schema if enabled
        if ($settings['enable_howto_schema'] ?? false) {
            if (!in_array('HowTo', $enabled_types, true)) {
                $enabled_types[] = 'HowTo';
            }
        }

        // Add product schema if enabled and context is appropriate
        if ($settings['enable_product_schema'] ?? false) {
            if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
                $enabled_types[] = 'Product';
            }
        }

        // Add local business schema if enabled
        if ($settings['enable_local_business'] ?? false) {
            if (!in_array('LocalBusiness', $enabled_types, true)) {
                $enabled_types[] = 'LocalBusiness';
            }
        }

        return $enabled_types;
    }

    /**
     * Get default organization logo for rich snippets
     *
     * @since 1.0.0
     *
     * @return string Default logo URL
     */
    private function get_default_organization_logo(): string {
        // Try to get custom logo
        $custom_logo_id = get_theme_mod('custom_logo');
        if ($custom_logo_id) {
            $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
            if ($logo_url) {
                return $logo_url;
            }
        }

        // Fallback to site icon or default
        $site_icon_url = get_site_icon_url();
        if ($site_icon_url) {
            return $site_icon_url;
        }

        // Final fallback
        return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
    }

    /**
     * Schema keys outside the shared config defaults.
     *
     * @since 2.0.1
     *
     * @return string[]
     */
    protected function additional_setting_keys(): array {
        return [
            'enable_article_schema', 'enable_product_schema',
            'enable_faq_schema', 'enable_howto_schema',
        ];
    }

    /**
     * Per-entity schema fields are an open set.
     *
     * Each schema type the UI can edit contributes its own field family —
     * organization_*, person_*, website_*, business_*, software_*, howto_* —
     * and a new type adds another. The families this manager owns are matched
     * rather than enumerated, so adding a form does not silently start
     * dropping its fields (#452).
     *
     * @since 2.0.1
     *
     * @return string[]
     */
    protected function dynamic_setting_key_patterns(): array {
        return [
            '/^organization_[a-z0-9_]+$/',
            '/^person_[a-z0-9_]+$/',
            '/^website_[a-z0-9_]+$/',
            '/^business_[a-z0-9_]+$/',
            '/^software_[a-z0-9_]+$/',
            '/^howto_[a-z0-9_]+$/',
            '/^product_[a-z0-9_]+$/',
        ];
    }

    /**
     * Get default settings for a context type (implements interface)
     *
     * @since 1.0.0
     *
     * @param string $context_type The context type to get defaults for
     * @return array Default settings array
     */
    public function get_default_settings(string $context_type): array {
        return Schema_Settings_Config::get_default_settings($context_type);
    }

    /**
     * Get settings schema definition (implements interface)
     *
     * @since 1.0.0
     *
     * @param string $context_type The context type to get schema for
     * @return array Settings schema definition
     */
    public function get_settings_schema(string $context_type): array {
        return Schema_Settings_Config::get_settings_schema($context_type);
    }

    /**
     * Save SEO settings with cache invalidation and auto-deployment
     *
     * Overrides parent method to add schema cache invalidation when settings change.
     * This ensures cached schema data is refreshed when configuration changes.
     * Also triggers auto-deployment of schema when enabled.
     *
     * @since 1.0.0
     *
     * @param string   $context_type The context type
     * @param int|null $context_id   Optional. Context ID
     * @param array    $settings     Settings array to save
     * @return bool True on success, false on failure
     */
    public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
        // Call parent method to save settings
        $success = parent::save_settings($context_type, $context_id, $settings);

        // CACHE INVALIDATION: Clear all schema cache when settings change
        if ($success && $this->cache_manager) {
            $this->cache_manager->invalidate_all_cache();
        }

        // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
        if ($success && !empty($settings['auto_deploy'])) {
            $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
        }

        return $success;
    }

    /**
     * Auto-deploy schema when settings change
     *
     * Automatically regenerates and deploys schema markup when organization or other
     * schema settings are modified, ensuring the frontend output stays in sync.
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @param array    $settings     Updated settings
     * @return void
     */
    private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
        // Determine which schema types need to be regenerated based on changed settings
        $schema_types_to_regenerate = [];

        // Organization schema - regenerate if organization settings changed
        if ($this->has_organization_settings_changed($settings)) {
            $schema_types_to_regenerate[] = 'Organization';
        }

        // Website schema - regenerate if website settings changed. The type is
        // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types;
        // using 'Website' here made generate_schema_markup() silently skip it.
        if ($this->has_website_settings_changed($settings)) {
            $schema_types_to_regenerate[] = 'WebSite';
        }

        // LocalBusiness schema - regenerate if business settings changed
        if ($this->has_business_settings_changed($settings)) {
            $schema_types_to_regenerate[] = 'LocalBusiness';
        }

        // Person schema - regenerate if person settings changed
        if ($this->has_person_settings_changed($settings)) {
            $schema_types_to_regenerate[] = 'Person';
        }

        // Honour the user's Schema Types selection. Without this the payload
        // shape alone decided what shipped, so every save deployed all four
        // types — including ones the user had explicitly deselected (#461).
        // An empty selection means "auto", so only filter when one is set.
        $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];

        if (!empty($enabled_types) && is_array($enabled_types)) {
            $schema_types_to_regenerate = array_values(
                array_intersect($schema_types_to_regenerate, $enabled_types)
            );
        }

        // Types that were deployed but are no longer wanted must come back off
        // the page — deployment used to be additive-only (#464).
        $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);

        // If no schema types need regeneration, return early
        if (empty($schema_types_to_regenerate)) {
            return;
        }

        // Generate every affected type in ONE call. Generating them one at a
        // time re-entered store_schema_data() per type, and each pass replaced
        // the rows written by the previous one, so only the last type survived
        // (#454). One batch also means one delete and one cache flush.
        try {
            $generation_result = $this->generate_schema_markup(
                $context_type,
                $context_id,
                $schema_types_to_regenerate
            );

            $deployable = [];
            foreach ($schema_types_to_regenerate as $schema_type) {
                // Only deploy what validated — see #470.
                if (!empty($generation_result['generated_schemas'][$schema_type])
                    && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
                ) {
                    $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
                }
            }

            if (!empty($deployable)) {
                $this->deploy_schema_markup($context_type, $context_id, $deployable);
            }
        } catch (\Exception $e) {
            // Log error but don't fail the settings save
            if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
            }
        }
    }

    /**
     * Check if organization settings have changed
     *
     * @since 1.0.0
     *
     * @param array $settings Updated settings
     * @return bool True if organization settings changed
     */
    private function has_organization_settings_changed(array $settings): bool {
        $org_keys = [
            'organization_name', 'organization_type', 'organization_logo', 'organization_url',
            'organization_description', 'organization_social_facebook', 'organization_social_twitter',
            'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
            'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram',
            'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
            'organization_contact_hours'
        ];

        foreach ($org_keys as $key) {
            if (isset($settings[$key])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if website settings have changed
     *
     * @since 1.0.0
     *
     * @param array $settings Updated settings
     * @return bool True if website settings changed
     */
    private function has_website_settings_changed(array $settings): bool {
        // These are the keys the Website tab actually stores. It previously
        // looked for site_name/site_description/site_url, which belong to Site
        // Identity and never appear in a schema settings payload — so WebSite
        // schema never auto-deployed no matter what was edited (#455).
        $website_keys = [
            'website_name', 'website_url', 'website_description', 'website_author',
        ];

        foreach ($website_keys as $key) {
            if (isset($settings[$key])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if business settings have changed
     *
     * @since 1.0.0
     *
     * @param array $settings Updated settings
     * @return bool True if business settings changed
     */
    private function has_business_settings_changed(array $settings): bool {
        // Only the keys this manager actually stores. business_name/address/
        // phone/hours live in the site_identity category and never reach a
        // schema settings save, so keying off them meant LocalBusiness never
        // auto-deployed (#455). Edits to those fields refresh LocalBusiness
        // through the Site Identity save path instead — see
        // refresh_schema_for_foreign_settings().
        $business_keys = [
            'enable_local_business',
            'business_price_range',
            'business_geo_latitude',
            'business_geo_longitude',
            'business_opening_hours',
        ];

        foreach ($business_keys as $key) {
            if (isset($settings[$key])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if person settings have changed
     *
     * @since 1.0.0
     *
     * @param array $settings Updated settings
     * @return bool True if person settings changed
     */
    private function has_person_settings_changed(array $settings): bool {
        $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];

        foreach ($person_keys as $key) {
            if (isset($settings[$key])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Auto-detect appropriate schema types for context
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @return array Detected schema types
     */
    private function auto_detect_schema_types(string $context_type, ?int $context_id): array {
        $detected_types = [];

        switch ($context_type) {
            case 'site':
                // The admin's Schema Types selection is the answer to "what
                // does this site need"; detection is only the fallback for an
                // install that has not chosen yet (#456).
                $settings = $this->get_settings($context_type, $context_id);
                $enabled  = array_values(array_filter(
                    array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
                    'strlen'
                ));

                // Drop stale names the factory no longer registers rather than
                // handing them to the builder to silently skip.
                $enabled = array_values(array_filter(
                    $enabled,
                    fn($type) => isset($this->schema_types[$type])
                ));

                if (!empty($enabled)) {
                    $detected_types = $enabled;
                    break;
                }

                $detected_types = ['Organization'];
                // Check if it's a local business
                if ($this->is_local_business()) {
                    $detected_types[] = 'LocalBusiness';
                }
                break;
            case 'post':
                $detected_types = ['Article'];
                // Check content type for specific article types
                if ($context_id) {
                    $post = get_post($context_id);
                    if ($post && $this->is_how_to_content($post->post_content)) {
                        $detected_types[] = 'HowTo';
                    }
                }
                break;
            case 'page':
                $detected_types = ['Article'];
                if ($context_id) {
                    $page = get_post($context_id);
                    if ($page && $this->is_faq_content($page->post_content)) {
                        $detected_types[] = 'FAQPage';
                    }
                }
                break;
            case 'product':
                $detected_types = ['Product'];
                break;
        }

        return $detected_types;
    }

    /**
     * Prepare content data for Schema_Builder
     *
     * @since 1.0.0
     *
     * @param string   $context_type      Context type
     * @param int|null $context_id        Context ID
     * @param array    $content_analysis  Content analysis data
     * @param array    $optimization_data Optimization data
     * @param array    $options           Generation options (may contain custom content_data)
     * @return array Prepared content data for schema generation
     */
    private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array {
        $content_data = [];

        // Handle different context types
        if ($context_type === 'site') {
            // Site-level data
            $content_data = [
                'title' => get_bloginfo('name'),
                'url' => home_url(),
                'excerpt' => get_bloginfo('description'),
                'content' => get_bloginfo('description'),
                'business_data' => $this->get_business_data_from_local_seo(),
                'site_data' => $this->get_site_data_for_schema(),
                'social_data' => $this->get_social_data_for_schema()
            ];
        } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
            // Post/page/product data
            $post = get_post($context_id);
            if ($post) {
                $content_data = [
                    'title' => $post->post_title,
                    'url' => get_permalink($post->ID),
                    'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
                    'content' => $post->post_content,
                    'author' => [
                        'name' => get_the_author_meta('display_name', $post->post_author),
                        'url' => get_author_posts_url($post->post_author)
                    ],
                    // ISO 8601 with offset. post_date/post_modified are raw
                    // MySQL columns in site-local time with no timezone, which
                    // Google rejects as "Invalid value in field datePublished"
                    // and drops the Article rich result (#465).
                    'date' => get_the_date('c', $post),
                    'modified' => get_the_modified_date('c', $post),
                    'image' => get_the_post_thumbnail_url($post->ID, 'full'),
                    'focus_keywords' => Focus_Keywords::get($post->ID),
                    'business_data' => $this->get_business_data_from_local_seo(),
                    'site_data' => $this->get_site_data_for_schema(),
                    'social_data' => $this->get_social_data_for_schema()
                ];

                // Override with custom content data if provided (for metabox usage)
                if (!empty($options['content_data'])) {
                    $custom_data = $options['content_data'];

                    // Override title if provided and not empty
                    if (!empty($custom_data['title'])) {
                        $content_data['title'] = $custom_data['title'];
                    }

                    // Override excerpt/description if provided and not empty
                    if (!empty($custom_data['description'])) {
                        $content_data['excerpt'] = $custom_data['description'];
                    }

                    // Override content if provided and not empty
                    if (!empty($custom_data['content'])) {
                        $content_data['content'] = $custom_data['content'];
                    }

                    // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL
                    if (!empty($custom_data['post_url'])) {
                        // If the URL is an admin edit URL, convert it to the post permalink
                        if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) {
                            $content_data['url'] = get_permalink($context_id);
                        } else {
                            $content_data['url'] = $custom_data['post_url'];
                        }
                    }

                    // Add focus keyword(s) if provided
                    if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) {
                        $content_data['focus_keywords'] = $custom_data['focus_keywords'];
                    }
                    if (!empty($custom_data['focus_keyword'])) {
                        $content_data['focus_keyword'] = $custom_data['focus_keyword'];
                    }

                    // Add word count if provided (from frontend calculation)
                    if (!empty($custom_data['word_count'])) {
                        $content_data['word_count'] = (int) $custom_data['word_count'];
                    }

                    // CRITICAL: Override site_data fields that take precedence in schema builder
                    // The schema builder checks site_data first, so we need to clear these
                    // to ensure our custom data is used instead
                    if (isset($content_data['site_data'])) {
                        // Clear site-level article settings so custom data takes precedence
                        unset($content_data['site_data']['article_headline']);
                        unset($content_data['site_data']['article_description']);
                        unset($content_data['site_data']['article_author']);
                    }
                }

                // Merge schema form data into site_data if provided (for content-specific schemas)
                if (!empty($options['schema_form_data'])) {
                    $form_data = $options['schema_form_data'];

                    // Ensure site_data exists
                    if (!isset($content_data['site_data'])) {
                        $content_data['site_data'] = [];
                    }

                    // Merge form data into site_data so schema builder can access it
                    $content_data['site_data'] = array_merge($content_data['site_data'], $form_data);
                }

                // Simplified: Content analysis moved to separate services
                // Word count and reading time handled by Schema_Builder directly from content
            }
        }

        return $content_data;
    }

    /**
     * Store schema data in database
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @param array    $generation   Generation results
     * @return bool Success status
     */
    private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool {
        global $wpdb;

        $table_name = $wpdb->prefix . 'thinkrank_seo_schema';

        // Replace only the types in this batch. Clearing the whole context
        // destroyed types the caller never asked about — and callers do
        // regenerate a subset, one type at a time (#454).
        $generated_types = array_keys($generation['generated_schemas'] ?? []);
        if (empty($generated_types)) {
            return false;
        }
        $this->delete_existing_schemas($context_type, $context_id, $generated_types);

        foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
            // Prepare schema data with validation status embedded
            $schema_data_with_validation = $schema_data;
            $schema_data_with_validation['_validation'] = [
                'is_valid' => $generation['validation_results'][$schema_type]['is_valid'],
                'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [],
                'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [],
                'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0
            ];

            $data = [
                'context_type' => $context_type,
                'context_id' => $context_id,
                'schema_type' => $schema_type,
                'schema_data' => wp_json_encode($schema_data_with_validation),
                'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
                // Per-type, not batch-wide. deployment_ready is only true when
                // EVERY type in the batch validated, so one invalid type (a site
                // with no Business Info makes LocalBusiness invalid) deactivated
                // all the valid ones alongside it (#470).
                'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
            ];

            // Insert new schema (existing ones were already deleted)
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
            $wpdb->insert($table_name, $data);
        }

        // CACHE INVALIDATION: Clear cache after storing new schema data
        if ($this->cache_manager) {
            $this->cache_manager->invalidate_context_cache($context_type, $context_id);
        }

        return true;
    }

    /**
     * Calculate validation score
     *
     * @since 1.0.0
     *
     * @param array $validation Validation results
     * @return int Score (0-100)
     */
    private function calculate_validation_score(array $validation): int {
        $score = 100;
        $score -= count($validation['errors'] ?? []) * 20;
        $score -= count($validation['warnings'] ?? []) * 10;
        $score -= count($validation['suggestions'] ?? []) * 5;

        return max(0, (int) round($score));
    }

    /**
     * Simple implementations for helper methods referenced in the main functions
     * These would be enhanced with more sophisticated algorithms in production
     */

    // Removed complex AI integration methods - moved to separate services
    // Schema management focuses on core structured data generation
    private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
        return [
            'preview_type' => $schema_type,
            'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
            'description' => $schema_data['description'] ?? 'Description',
            'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null,
            'additional_info' => $this->extract_additional_info($schema_data, $schema_type)
        ];
    }

    private function extract_additional_info(array $schema_data, string $schema_type): array {
        $info = [];

        switch ($schema_type) {
            case 'Article':
                if (isset($schema_data['author']['name'])) {
                    $info['author'] = $schema_data['author']['name'];
                }
                if (isset($schema_data['datePublished'])) {
                    $timestamp = strtotime($schema_data['datePublished']);
                    if ($timestamp !== false) {
                        $info['date'] = gmdate('M j, Y', $timestamp);
                    }
                }
                break;
            case 'Product':
                if (isset($schema_data['offers']['price'])) {
                    $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price'];
                }
                if (isset($schema_data['brand']['name'])) {
                    $info['brand'] = $schema_data['brand']['name'];
                }
                break;
        }

        return $info;
    }

    private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array {
        $recommendations = [];

        foreach ($validation_results as $schema_type => $validation) {
            if (!$validation['is_valid']) {
                $recommendations[] = [
                    'type' => 'validation_error',
                    'schema_type' => $schema_type,
                    'priority' => 'high',
                    'message' => "Schema validation failed for {$schema_type}",
                    'action' => 'Fix validation errors before deployment'
                ];
            }

            if (!empty($validation['warnings'])) {
                // Extract missing properties from warnings
                $missing_properties = [];
                foreach ($validation['warnings'] as $warning) {
                    if (strpos($warning, 'Missing recommended property:') === 0) {
                        $property = trim(str_replace('Missing recommended property:', '', $warning));
                        $missing_properties[] = $property;
                    }
                }

                if (!empty($missing_properties)) {
                    $properties_list = implode(', ', $missing_properties);
                    $recommendations[] = [
                        'type' => 'missing_properties',
                        'schema_type' => $schema_type,
                        'priority' => 'medium',
                        'message' => "Missing recommended properties for {$schema_type}: {$properties_list}",
                        'action' => 'Add these properties to improve rich snippets eligibility'
                    ];
                } else {
                    $recommendations[] = [
                        'type' => 'missing_properties',
                        'schema_type' => $schema_type,
                        'priority' => 'medium',
                        'message' => "Missing recommended properties for {$schema_type}",
                        'action' => 'Add recommended properties to improve rich snippets eligibility'
                    ];
                }
            }
        }

        return $recommendations;
    }

    private function check_deployment_readiness(array $validation_results): bool {
        foreach ($validation_results as $validation) {
            if (!$validation['is_valid']) {
                return false;
            }
        }
        return true;
    }

    // Content detection helper methods
    private function is_local_business(): bool {
        // Simple check - would be enhanced with actual business detection
        $description = get_bloginfo('description');
        $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service'];

        foreach ($local_keywords as $keyword) {
            if (stripos($description, $keyword) !== false) {
                return true;
            }
        }

        return false;
    }

    private function is_how_to_content(string $content): bool {
        $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
        $content_lower = strtolower($content);

        foreach ($how_to_keywords as $keyword) {
            if (stripos($content_lower, $keyword) !== false) {
                return true;
            }
        }

        return false;
    }

    private function is_faq_content(string $content): bool {
        $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:'];
        $content_lower = strtolower($content);

        foreach ($faq_keywords as $keyword) {
            if (stripos($content_lower, $keyword) !== false) {
                return true;
            }
        }

        return false;
    }

    private function determine_deployment_method(array $options): string {
        // Always use JSON-LD as it's the only supported method
        return 'json_ld';
    }

    private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array {
        global $wpdb;

        // Use existing seo_schema table
        $table_name = $wpdb->prefix . 'thinkrank_seo_schema';

        $deployment_data = [
            'context_type' => $context_type,
            'context_id' => $context_id,
            'schema_type' => $schema_type,
            'schema_data' => wp_json_encode($schema),
            'validation_status' => 'deployed',
            'is_active' => 1
        ];

        // Check if schema already exists for this context and type
        if (null === $context_id) {
            // Handle NULL context_id case
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access, table name is validated
			$sql = sprintf(
				'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
				$table_name
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				$context_type,
				$schema_type
			)
		);
        } else {
            // Handle regular context_id case
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access, table name is validated
			$sql = sprintf(
				'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
				$table_name
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				$context_type,
				$context_id,
				$schema_type
			)
		);
        }

        if ($existing) {
            // Update existing deployment
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access
            $result = $wpdb->update(
                $table_name,
                [
                    'schema_data' => wp_json_encode($schema),
                    'validation_status' => 'deployed',
                    'is_active' => 1,
                    'updated_at' => current_time('mysql')
                ],
                ['schema_id' => $existing],
                ['%s', '%s', '%d', '%s'],
                ['%d']
            );

        } else {
            // Insert new deployment
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
            $result = $wpdb->insert(
                $table_name,
                $deployment_data,
                ['%s', '%d', '%s', '%s', '%s', '%d']
            );

        }

        return [
            'deployed' => $result !== false,
            'method' => $method,
            'schema_type' => $schema_type,
            'schema_id' => $existing ?: $wpdb->insert_id
        ];
    }

    /**
     * Get deployed schemas for frontend integration
     *
     * PERFORMANCE OPTIMIZED: This method now uses:
     * 1. Schema caching layer (90% reduction in database queries)
     * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
     * 3. Composite index: idx_context_schema_active
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @return array Deployed schemas for current context
     */
    public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array {
        // CACHE LAYER: Check cache first for immediate 90% performance improvement
        if ($this->cache_manager) {
            $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
            $cached_data = $this->cache_manager->get($cache_key);

            if ($cached_data !== null) {
                // CACHE FIX: Extract actual data from cache wrapper
                return isset($cached_data['data']) ? $cached_data['data'] : $cached_data;
            }
        }

        global $wpdb;

        // Use existing seo_schema table
        $table_name = $wpdb->prefix . 'thinkrank_seo_schema';

        // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
        // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
        if (null === $context_id) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
			$sql = sprintf(
				'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id IS NULL AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type',
				$table_name
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
		$deployed_schemas = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				$context_type
			),
			ARRAY_A
		);
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
			$sql = sprintf(
				'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id = %%d AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type',
				$table_name
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
		$deployed_schemas = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				$context_type,
				$context_id
			),
			ARRAY_A
		);
        }

        // Deliberately no early return on an empty result: it has to reach the
        // cache write below. Most URLs have no deployed schema, so gating the
        // write on a non-empty result made the majority of front-end requests
        // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
        // ...) query with two filesorts on every pageview (#392).
        $deployed_schemas = $deployed_schemas ?: [];

        // Process schemas for return
        $processed_schemas = [];
        foreach ($deployed_schemas as $deployed_schema) {
            $schema_data = json_decode($deployed_schema['schema_data'], true);
            $schema_type = $deployed_schema['schema_type'];

            if (!empty($schema_data)) {
                // Remove internal validation metadata before frontend output
                if (isset($schema_data['_validation'])) {
                    unset($schema_data['_validation']);
                }

                // Deployed schema is a snapshot, so rows written before #465
                // still carry raw MySQL datetimes. Normalise on read so the
                // fix reaches existing sites without a migration.
                $schema_data = $this->normalize_stored_schema($schema_data);

                // The permalink was frozen at deploy time, so schema deployed
                // while a post was a draft advertised "?p=123" as both url and
                // mainEntityOfPage forever — contradicting the node's own @id
                // and the canonical (#470). Resolve it live instead.
                $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);

                // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
                // but the form stored whatever was typed, so free text entered
                // in a social-profile field shipped as a sameAs member and made
                // the whole entity invalid (#480). Drop bad values on read, so
                // existing sites stop emitting them without a migration.
                $schema_data = $this->filter_entity_urls($schema_data);

                $processed_schemas[$schema_type] = [
                    'data' => $schema_data,
                    'method' => 'json_ld', // Default method
                    'type' => $schema_type
                ];
            }
        }

        // CACHE LAYER: Store result in cache for future requests — including
        // an empty one. Cache_Manager::set() wraps the payload in a metadata
        // envelope, so an empty result is still stored as a truthy value and
        // reads back as a hit rather than a miss (#392).
        if ($this->cache_manager) {
            $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
            $this->cache_manager->set($cache_key, $processed_schemas);
        }

        return $processed_schemas;
    }

    /**
     * Properties schema.org defines as URLs.
     *
     * @since 2.0.2
     * @var string[]
     */
    private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];

    /**
     * Whether a value is a URL safe to publish in structured data.
     *
     * @since 2.0.2
     *
     * @param mixed $url Candidate value.
     * @return bool
     */
    private function is_publishable_url($url): bool {
        if (!is_string($url) || '' === trim($url)) {
            return false;
        }

        if (!filter_var($url, FILTER_VALIDATE_URL)) {
            return false;
        }

        $scheme = wp_parse_url($url, PHP_URL_SCHEME);

        return in_array(strtolower((string) $scheme), ['http', 'https'], true);
    }

    /**
     * Drop values that are not URLs from URL-typed properties.
     *
     * An absent property is valid; one holding free text is not, and it can
     * invalidate the entity around it. Nested objects (`logo` and `image` are
     * frequently ImageObjects) are walked so a bad `url` inside one is caught
     * too. A property left with nothing is removed rather than emitted empty.
     *
     * @since 2.0.2
     *
     * @param array $schema Decoded schema data.
     * @return array Schema carrying only publishable URLs.
     */
    private function filter_entity_urls(array $schema): array {
        foreach ($schema as $key => $value) {
            if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
                $schema[$key] = $this->filter_entity_urls($value);
                continue;
            }

            if (!in_array($key, self::URL_PROPERTIES, true)) {
                continue;
            }

            // A nested object (ImageObject and friends) carries its own url.
            if (is_array($value) && isset($value['@type'])) {
                $schema[$key] = $this->filter_entity_urls($value);
                continue;
            }

            if (is_array($value)) {
                $kept = [];

                foreach ($value as $item) {
                    if (is_array($item)) {
                        $kept[] = $this->filter_entity_urls($item);
                    } elseif ($this->is_publishable_url($item)) {
                        $kept[] = $item;
                    }
                }

                if ([] === $kept) {
                    unset($schema[$key]);
                } else {
                    $schema[$key] = array_values($kept);
                }

                continue;
            }

            if (!$this->is_publishable_url($value)) {
                unset($schema[$key]);
            }
        }

        return $schema;
    }

    /**
     * Schema types whose `url` identifies the entity, not the page.
     *
     * On a Person or an Organization, `url` is that entity's own website, so
     * overwriting it with the permalink of whichever post the schema happens to
     * be deployed on is simply wrong. It also breaks graph assembly: the site
     * identity emits the same entity with its real `url`, and once the two
     * copies disagree they can no longer be recognised as one entity (#479).
     *
     * @since 2.0.2
     * @var string[]
     */
    private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];

    /**
     * Replace a stored permalink snapshot with the post's live permalink.
     *
     * Only touches `url` and `mainEntityOfPage`, and only for post-like
     * contexts where a permalink actually exists. Identity entities are
     * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
     *
     * @since 1.16.0
     *
     * @param array    $schema       Decoded schema data.
     * @param string   $context_type Context type.
     * @param int|null $context_id   Context ID.
     * @return array Schema with a current permalink.
     */
    private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
        if ('site' === $context_type || empty($context_id)) {
            return $schema;
        }

        $permalink = get_permalink($context_id);

        if (!$permalink) {
            return $schema;
        }

        $type = $schema['@type'] ?? '';
        $type = is_array($type) ? reset($type) : $type;
        $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true);

        if (isset($schema['url']) && !$is_entity) {
            $schema['url'] = $permalink;
        }

        if (isset($schema['mainEntityOfPage'])) {
            if (is_array($schema['mainEntityOfPage'])) {
                if (isset($schema['mainEntityOfPage']['@id'])) {
                    $schema['mainEntityOfPage']['@id'] = $permalink;
                }
            } else {
                $schema['mainEntityOfPage'] = $permalink;
            }
        }

        return $schema;
    }

    /**
     * Normalise properties that stored snapshots may hold in a stale format.
     *
     * Deployed schema is written once and read forever, so a formatting fix in
     * the builder never reaches rows already on disk. Correcting on read means
     * existing sites benefit without a migration.
     *
     * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
     * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
     * inside author/publisher/@graph entries are covered too.
     *
     * @since 1.16.0
     *
     * @param array $schema Decoded schema data.
     * @return array Normalised schema.
     */
    private function normalize_stored_schema(array $schema): array {
        static $date_keys = [
            'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
            'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
        ];

        foreach ($schema as $key => $value) {
            if (is_array($value)) {
                $schema[$key] = $this->normalize_stored_schema($value);
                continue;
            }

            if ('inLanguage' === $key && is_string($value) && '' !== $value) {
                $schema[$key] = str_replace('_', '-', $value);
                continue;
            }

            if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
                continue;
            }

            // Already ISO 8601 — leave it alone.
            if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
                continue;
            }

            $timestamp = strtotime($value);

            if (false !== $timestamp) {
                $schema[$key] = (string) wp_date('c', $timestamp);
            }
        }

        return $schema;
    }

    /**
     * Clean up duplicate schemas in database
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @return int Number of duplicate schemas removed
     */
    public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int {
        global $wpdb;

        $table_name = $wpdb->prefix . 'thinkrank_seo_schema';

        if (null === $context_id) {
            // Clean up duplicates for NULL context_id
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
			$sql = sprintf(
				'DELETE t1 FROM %s t1 INNER JOIN %s t2 WHERE t1.context_type = %%s AND t1.context_id IS NULL AND t2.context_type = %%s AND t2.context_id IS NULL AND t1.schema_type = t2.schema_type AND t1.created_at < t2.created_at',
				$table_name,
				$table_name
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				$context_type,
				$context_type
			)
		);
        } else {
            // Clean up duplicates for specific context_id
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
			$sql = sprintf(
				'DELETE t1 FROM %s t1 INNER JOIN %s t2 WHERE t1.context_type = %%s AND t1.context_id = %%d AND t2.context_type = %%s AND t2.context_id = %%d AND t1.schema_type = t2.schema_type AND t1.created_at < t2.created_at',
				$table_name,
				$table_name
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				$context_type,
				$context_id,
				$context_type,
				$context_id
			)
		);
        }

        return $deleted ?: 0;
    }

    /**
     * Deactivate deployed schema rows for the given types.
     *
     * Deployment was insert-only, so anything ever deployed to a context stayed
     * on the page forever — switching a post's schema type left the old one live
     * and deactivating a saved schema did nothing (#464). Rows are deactivated
     * rather than deleted so a later redeploy can revive them and so there is a
     * trail of what was published.
     *
     * @since 1.16.0
     *
     * @param string   $context_type Context type.
     * @param int|null $context_id   Context ID.
     * @param string[] $schema_types Types to retire.
     * @return int Number of rows deactivated.
     */
    private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
        $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));

        if (empty($schema_types)) {
            return 0;
        }

        global $wpdb;

        $table_name   = $wpdb->prefix . 'thinkrank_seo_schema';
        $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));

        if (null === $context_id) {
            $sql  = sprintf(
                'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
                $table_name,
                $placeholders
            );
            $args = array_merge([$context_type], $schema_types);
        } else {
            $sql  = sprintf(
                'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
                $table_name,
                $placeholders
            );
            $args = array_merge([$context_type, $context_id], $schema_types);
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
        $updated = $wpdb->query(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
                $sql,
                $args
            )
        );

        if ($updated && $this->cache_manager) {
            $this->cache_manager->invalidate_context_cache($context_type, $context_id);
        }

        return (int) ($updated ?: 0);
    }

    /**
     * Retire deployed types that are no longer in the user's Schema Types selection.
     *
     * An empty selection means "auto-detect", so nothing is retired in that case.
     *
     * @since 1.16.0
     *
     * @param string   $context_type  Context type.
     * @param int|null $context_id    Context ID.
     * @param array    $enabled_types The user's selected types.
     * @return int Number of rows deactivated.
     */
    private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
        if (empty($enabled_types)) {
            return 0;
        }

        $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
        $stale    = array_diff($deployed, $enabled_types);

        return $this->retire_schema_types($context_type, $context_id, $stale);
    }

    /**
     * Delete stored schemas for a context before storing new ones.
     *
     * `$schema_types` scopes the delete to the types actually being rewritten.
     * Without it this wiped every type in the context, which silently destroyed
     * deployed schema whenever a caller regenerated a subset — and
     * auto_deploy_schema_on_settings_change() regenerates one type at a time
     * (#454). Passing an empty array keeps the original clear-the-context
     * behaviour for callers that genuinely rewrite everything.
     *
     * @since 1.0.0
     *
     * @param string   $context_type Context type
     * @param int|null $context_id   Context ID
     * @param string[] $schema_types Optional. Limit the delete to these types.
     * @return int Number of schemas deleted
     */
    private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
        global $wpdb;

        $table_name = $wpdb->prefix . 'thinkrank_seo_schema';

        // Build an optional `AND schema_type IN (…)` clause with one prepared
        // placeholder per type, so the scoping cannot be injected through.
        $type_clause = '';
        $type_values = [];
        $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
        if (!empty($schema_types)) {
            $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
            $type_values = $schema_types;
        }

        if (null === $context_id) {
            // Delete all schemas for NULL context_id
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
			$sql = sprintf(
				'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
				$table_name,
				$type_clause
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				array_merge([$context_type], $type_values)
			)
		);
        } else {
            // Delete all schemas for specific context_id
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
			$sql = sprintf(
				'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
				$table_name,
				$type_clause
			);
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
				$sql,
				array_merge([$context_type, $context_id], $type_values)
			)
		);
        }

        return $deleted ?: 0;
    }

    /**
     * Get business data from Site Identity Local settings
     *
     * @return array
     */
    private function get_business_data_from_local_seo(): array {
        // Get Site Identity settings which include Local SEO data
        $site_identity_settings = get_option('thinkrank_site_identity_settings', []);

        return [
            'business_name' => $site_identity_settings['business_name'] ?? '',
            'business_address' => $site_identity_settings['business_address'] ?? '',
            'business_city' => $site_identity_settings['business_city'] ?? '',
            'business_state' => $site_identity_settings['business_state'] ?? '',
            'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '',
            'business_country' => $site_identity_settings['business_country'] ?? '',
            'business_phone' => $site_identity_settings['business_phone'] ?? '',
            'business_email' => $site_identity_settings['business_email'] ?? '',
            'business_hours' => $site_identity_settings['business_hours'] ?? [],
            'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
        ];
    }
    public function get_settings(string $context_type, ?int $context_id = null): array {
        // Get base settings from parent
        $settings = parent::get_settings($context_type, $context_id);

        // For site context, automatically include Site Identity data
        if ($context_type === 'site') {
            // Get Site Identity settings from the Site Identity Manager
            $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
            $site_identity_settings = $site_identity_manager->get_settings('site', null);

            // Include Site Identity assets if not already set in Schema Manager
            if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) {
                $settings['logo_url'] = $site_identity_settings['logo_url'];
            }
            if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) {
                $settings['favicon_url'] = $site_identity_settings['favicon_url'];
            }
            if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) {
                $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url'];
            }

            // Include Site Identity organization data if not already set in Schema Manager
            if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) {
                $settings['organization_name'] = $site_identity_settings['site_name'];
            }
            if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) {
                $settings['organization_url'] = $site_identity_settings['site_url'];
            }
            if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) {
                $settings['organization_description'] = $site_identity_settings['site_description'];
            }
        }

        return $settings;
    }

    /**
     * Get site data for rich schema generation
     *
     * @return array
     */
    private function get_site_data_for_schema(): array {
        // Get Schema Manager's own settings first (highest priority)
        $schema_settings = $this->get_settings('site', null);

        // Get Site Identity settings for additional data
        $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
        $site_identity_settings = $site_identity_manager->get_settings('site', null);

        return [
            'site_name' => get_bloginfo('name'),
            'site_description' => get_bloginfo('description'),
            'site_url' => home_url(),
            'admin_email' => get_option('admin_email'),
            'language' => get_locale(),
            'timezone' => get_option('timezone_string'),
            'founded_date' => $site_identity_settings['founded_date'] ?? '',
            'founder_name' => $site_identity_settings['founder_name'] ?? '',
            'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
            // Site Identity assets
            'logo_url' => $site_identity_settings['logo_url'] ?? '',
            'favicon_url' => $site_identity_settings['favicon_url'] ?? '',
            // Schema Manager organization settings (highest priority)
            'organization_name' => $schema_settings['organization_name'] ?? '',
            'organization_description' => $schema_settings['organization_description'] ?? '',
            'organization_url' => $schema_settings['organization_url'] ?? '',

            // Removed post/page-specific schema settings (Product, Event, Article, Software Application)
            // These are now handled only at the post/page level via metabox

            // Person schema settings (site-wide)
            'person_name' => $schema_settings['person_name'] ?? '',
            'person_job_title' => $schema_settings['person_job_title'] ?? '',
            'person_description' => $schema_settings['person_description'] ?? '',
            'person_image' => $schema_settings['person_image'] ?? '',
            'person_url' => $schema_settings['person_url'] ?? '',
            'person_email' => $schema_settings['person_email'] ?? '',
            'person_telephone' => $schema_settings['person_telephone'] ?? '',
            'person_address' => $schema_settings['person_address'] ?? '',
            'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
            'person_nationality' => $schema_settings['person_nationality'] ?? '',
            'person_works_for' => $schema_settings['person_works_for'] ?? '',
            'person_same_as' => $schema_settings['person_same_as'] ?? [],

            // Website schema settings (site-wide)
            'website_name' => $schema_settings['website_name'] ?? '',
            'website_url' => $schema_settings['website_url'] ?? '',
            'website_description' => $schema_settings['website_description'] ?? '',
            'website_author' => $schema_settings['website_author'] ?? '',

            'organization_logo' => $schema_settings['organization_logo'] ?? '',
            // Social media links (sameAs)
            'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',
            'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
            'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
            'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
            'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
            'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '',
            'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '',
            'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '',
            // Contact point information
            'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
            'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
            'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
            'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '',
            // LocalBusiness specific fields
            'business_price_range' => $schema_settings['business_price_range'] ?? '',
            'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '',
            'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '',
            'business_opening_hours' => $schema_settings['business_opening_hours'] ?? []
        ];
    }

    /**
     * Get social media data for schema generation
     *
     * @return array
     */
    private function get_social_data_for_schema(): array {
        // Get Social Media settings
        $social_settings = get_option('thinkrank_social_media_settings', []);

        $social_profiles = [];

        // Common social platforms
        $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest'];

        foreach ($platforms as $platform) {
            $url = $social_settings["{$platform}_url"] ?? '';
            if (!empty($url)) {
                $social_profiles[] = $url;
            }
        }

        return [
            'social_profiles' => $social_profiles,
            'social_settings' => $social_settings
        ];
    }
}

```
