<?php

namespace WPSN\Modules\Schema;

use WPSN\Core\AbstractModule;
use WPSN\Core\Branding;
use WPSN\Core\Capabilities;
use WPSN\SEO\DocumentResolver;
use WPSN\SEO\MetaKeys;
use WPSN\SEO\SchemaAiGenerator;
use WPSN\SEO\SchemaCustomJsonLdResolver;
use WPSN\SEO\SchemaGraphBuilder;
use WPSN\SEO\SchemaMappingHelper;
use WPSN\SEO\SchemaTypeLibrary;
use WPSN\SEO\SeoMetaRepository;

defined('ABSPATH') || exit;

final class SchemaModule extends AbstractModule
{
    public function id(): string
    {
        return 'schema';
    }

    public function name(): string
    {
        return __('Schema', 'wp-seo-next');
    }

    public function description(): string
    {
        return __('JSON-LD graph: WebSite + Organization + BreadcrumbList + BlogPosting/Product + optional custom JSON-LD + schema starter library.', 'wp-seo-next');
    }

    public function defaultEnabled(): bool
    {
        return true;
    }

    public function register(): void
    {
        add_action('init', [$this, 'registerMetaKeys']);
        add_action('rest_api_init', [$this, 'registerRestRoutes']);
        add_action('add_meta_boxes', [$this, 'addMetaBox']);
        add_action('save_post', [$this, 'saveMetaBox'], 10, 2);
        add_action('admin_enqueue_scripts', [$this, 'enqueueEditorAssets']);

        add_action('wp_head', [$this, 'render'], 7);
    }

    public function registerMetaKeys(): void
    {
        $postTypes = get_post_types(['public' => true], 'names');
        foreach ($postTypes as $pt) {
            if ($pt === 'attachment') {
                continue;
            }

            register_post_meta($pt, MetaKeys::SCHEMA_TYPE, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => [self::class, 'sanitizeSchemaType'],
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);

            register_post_meta($pt, MetaKeys::SCHEMA_CUSTOM_JSON_LD, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => static function ($value): string {
                    return SchemaCustomJsonLdResolver::sanitizeRaw(is_string($value) ? $value : '');
                },
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);
        }
    }

    public function registerRestRoutes(): void
    {
        register_rest_route('wpsn/v1', '/schema/generate', [
            'methods' => 'POST',
            'permission_callback' => static function (\WP_REST_Request $request): bool {
                $postId = (int) $request->get_param('post_id');
                return $postId > 0 && current_user_can('edit_post', $postId) && Capabilities::canUseAI();
            },
            'callback' => function (\WP_REST_Request $request) {
                $postId = (int) $request->get_param('post_id');
                $presetSlug = sanitize_key((string) $request->get_param('preset_slug'));
                /** @var SchemaAiGenerator $generator */
                $generator = $this->container->get('schema_ai_generator');
                $result = $generator->generateForPost($postId, $presetSlug !== '' ? $presetSlug : 'auto');
                if (empty($result['ok'])) {
                    return new \WP_Error(
                        'wpsn_schema_ai_failed',
                        isset($result['error']) ? (string) $result['error'] : __('Schema AI generation failed.', 'wp-seo-next'),
                        ['status' => 400]
                    );
                }
                return rest_ensure_response($result);
            },
            'args' => [
                'post_id' => ['type' => 'integer', 'required' => true],
                'preset_slug' => ['type' => 'string', 'required' => false],
            ],
        ]);
    }

    public static function sanitizeSchemaType($value): string
    {
        $value = is_string($value) ? trim($value) : '';
        $allowed = ['default', 'none', 'article', 'webpage', 'product'];
        return in_array($value, $allowed, true) ? $value : '';
    }

    public function enqueueEditorAssets(string $hook): void
    {
        if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
            return;
        }

        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        if ($screen && isset($screen->post_type) && (string) $screen->post_type === 'attachment') {
            return;
        }

        wp_enqueue_script(
            'wpsn-schema-helper',
            WPSN_PLUGIN_URL . 'assets/admin/schema-helper.js',
            [],
            WPSN_VERSION,
            true
        );

        wp_add_inline_script('wpsn-schema-helper', 'window.WPSNSchemaHelper=' . wp_json_encode([
            'generateUrl' => rest_url('wpsn/v1/schema/generate'),
            'nonce' => wp_create_nonce('wp_rest'),
            'i18n' => [
                'generating' => __('Generating JSON-LD draft…', 'wp-seo-next'),
                'generated' => __('AI draft inserted into the textarea below. Review it before saving.', 'wp-seo-next'),
                'requestFailed' => __('Schema AI request failed.', 'wp-seo-next'),
                'missingTarget' => __('Schema textarea was not found on this page.', 'wp-seo-next'),
            ],
        ]) . ';', 'before');
    }

    public function addMetaBox(): void
    {
        $boxTitle = Branding::labelWithBrand($this->settings, __('Schema', 'wp-seo-next'));

        $postTypes = get_post_types(['public' => true], 'names');
        foreach ($postTypes as $pt) {
            if ($pt === 'attachment') {
                continue;
            }

            add_meta_box(
                'wpsn_schema_meta',
                $boxTitle,
                [$this, 'renderMetaBox'],
                $pt,
                'side',
                'default'
            );

            add_meta_box(
                'wpsn_schema_custom_json_ld',
                Branding::labelWithBrand($this->settings, __('Custom JSON-LD', 'wp-seo-next')),
                [$this, 'renderCustomJsonLdMetaBox'],
                $pt,
                'normal',
                'default'
            );
        }
    }

    public function renderMetaBox(\WP_Post $post): void
    {
        wp_nonce_field('wpsn_save_schema', 'wpsn_schema_nonce');

        $value = (string) get_post_meta($post->ID, MetaKeys::SCHEMA_TYPE, true);
        $value = $value !== '' ? $value : 'default';

        ?>
        <p>
            <label for="wpsn_schema_type"><strong><?php echo esc_html__('Schema type', 'wp-seo-next'); ?></strong></label>
        </p>

        <p>
            <select id="wpsn_schema_type" name="wpsn_schema[schema_type]" class="widefat">
                <option value="default" <?php selected($value, 'default'); ?>><?php echo esc_html__('Default (auto)', 'wp-seo-next'); ?></option>
                <option value="article" <?php selected($value, 'article'); ?>><?php echo esc_html__('Article (BlogPosting)', 'wp-seo-next'); ?></option>
                <?php if ($post->post_type === 'product') : ?>
                    <option value="product" <?php selected($value, 'product'); ?>><?php echo esc_html__('Product (WooCommerce)', 'wp-seo-next'); ?></option>
                <?php endif; ?>
                <option value="webpage" <?php selected($value, 'webpage'); ?>><?php echo esc_html__('WebPage', 'wp-seo-next'); ?></option>
                <option value="none" <?php selected($value, 'none'); ?>><?php echo esc_html__('Disable schema output for this page', 'wp-seo-next'); ?></option>
            </select>
            <p class="description"><?php echo esc_html__('Controls structured data output for this post/page.', 'wp-seo-next'); ?></p>
        </p>
        <?php
    }

    public function renderCustomJsonLdMetaBox(\WP_Post $post): void
    {
        wp_nonce_field('wpsn_save_schema', 'wpsn_schema_nonce');

        $value = (string) get_post_meta($post->ID, MetaKeys::SCHEMA_CUSTOM_JSON_LD, true);
        $value = SchemaCustomJsonLdResolver::sanitizeRaw($value);

        ?>
        <p>
            <?php echo esc_html__('Append extra JSON-LD nodes for this page. Use this for FAQ, HowTo, Event, Recipe, Review, Product, Comparison / ItemList, Service, or other schema types that are not part of the default graph.', 'wp-seo-next'); ?>
        </p>

        <?php $this->renderContentAutofillHelper($post); ?>
        <?php $this->renderMappingHelper($post); ?>
        <?php $this->renderAiHelper($post); ?>

        <?php SchemaTypeLibrary::renderPicker('wpsn_schema_custom_json_ld'); ?>

        <p>
            <textarea id="wpsn_schema_custom_json_ld" name="wpsn_schema[custom_json_ld]" class="large-text code" rows="10" placeholder='{"@type":"FAQPage","name":%title|json%}'><?php echo esc_textarea($value); ?></textarea>
        </p>

        <p class="description">
            <?php echo esc_html__('Accepted formats: a single node object, an array of node objects, or a full object with @graph. Template tokens are supported. For string-safe interpolation inside JSON, use %title|json% without surrounding quotes. Product and comparison drafts are best suited for affiliate/non-Woo pages unless your product graph is already handled elsewhere. Invalid JSON is skipped and surfaced in Schema Validator.', 'wp-seo-next'); ?>
        </p>
        <?php SchemaTypeLibrary::renderInlineScript(); ?>
        <?php
    }

    private function renderContentAutofillHelper(\WP_Post $post): void
    {
        /** @var SchemaMappingHelper $helper */
        $helper = $this->container->get('schema_mapping_helper');
        $analysis = $helper->analyzePost((int) $post->ID);
        $available = isset($analysis['available_autofill']) && is_array($analysis['available_autofill']) ? $analysis['available_autofill'] : [];
        if (empty($available)) {
            return;
        }

        $summary = isset($analysis['block_summary']) && is_array($analysis['block_summary']) ? $analysis['block_summary'] : [];
        $blockMapMatches = isset($analysis['block_map_matches']) && is_array($analysis['block_map_matches']) ? $analysis['block_map_matches'] : [];
        $presets = SchemaTypeLibrary::presets();

        echo '<div class="wpsn-schema-block-autofill" style="margin:0 0 12px; padding:12px; border:1px solid #dcdcde; border-radius:4px; background:#fff;">';
        echo '<p style="margin:0 0 8px;"><strong>' . esc_html__('Autofill from content blocks', 'wp-seo-next') . '</strong></p>';
        echo '<p class="description" style="margin:0 0 8px;">' . esc_html__('Detected Gutenberg and classic-editor content can be converted into a schema draft directly. The extended block map now recognizes common FAQ/accordion, HowTo, recipe-card, event, review, service/pricing, and product/comparison block types from popular block plugins, including classic HTML FAQ/accordion, HowTo, recipe, event, review, service/pricing, and product/pricing patterns, richer comparison-table signals such as ranking rows, seller/deal links, feature matrices, card-plus-table merges, classic editor HTML comparison tables, plus vendor-aware parsing for AffiliateX, Lasso, AAWP, and ReviewX blocks, widgets, and shortcodes.', 'wp-seo-next') . '</p>';

        $chips = [];
        if (!empty($summary['faq_pairs'])) {
            $chips[] = sprintf(__('FAQ pairs: %d', 'wp-seo-next'), (int) $summary['faq_pairs']);
        }
        if (!empty($summary['howto_steps'])) {
            $chips[] = sprintf(__('Steps: %d', 'wp-seo-next'), (int) $summary['howto_steps']);
        }
        if (!empty($summary['recipe_ingredients'])) {
            $chips[] = sprintf(__('Ingredients: %d', 'wp-seo-next'), (int) $summary['recipe_ingredients']);
        }
        if (!empty($summary['recipe_instructions'])) {
            $chips[] = sprintf(__('Instructions: %d', 'wp-seo-next'), (int) $summary['recipe_instructions']);
        }
        if (!empty($summary['event_blocks'])) {
            $chips[] = sprintf(__('Event blocks: %d', 'wp-seo-next'), (int) $summary['event_blocks']);
        }
        if (!empty($summary['review_blocks'])) {
            $chips[] = sprintf(__('Review blocks: %d', 'wp-seo-next'), (int) $summary['review_blocks']);
        }
        if (!empty($summary['product_blocks'])) {
            $chips[] = sprintf(__('Product blocks: %d', 'wp-seo-next'), (int) $summary['product_blocks']);
        }
        if (!empty($summary['comparison_products'])) {
            $chips[] = sprintf(__('Comparison products: %d', 'wp-seo-next'), (int) $summary['comparison_products']);
        }
        if (!empty($summary['comparison_ranked_products'])) {
            $chips[] = sprintf(__('Ranked rows: %d', 'wp-seo-next'), (int) $summary['comparison_ranked_products']);
        }
        if (!empty($summary['comparison_offer_products'])) {
            $chips[] = sprintf(__('Offer rows: %d', 'wp-seo-next'), (int) $summary['comparison_offer_products']);
        }
        if (!empty($summary['comparison_seller_products'])) {
            $chips[] = sprintf(__('Seller rows: %d', 'wp-seo-next'), (int) $summary['comparison_seller_products']);
        }
        if (!empty($summary['comparison_link_products'])) {
            $chips[] = sprintf(__('Deal links: %d', 'wp-seo-next'), (int) $summary['comparison_link_products']);
        }
        if (!empty($summary['comparison_rated_products'])) {
            $chips[] = sprintf(__('Rated products: %d', 'wp-seo-next'), (int) $summary['comparison_rated_products']);
        }
        if (!empty($summary['comparison_feature_products'])) {
            $chips[] = sprintf(__('Feature-rich rows: %d', 'wp-seo-next'), (int) $summary['comparison_feature_products']);
        }
        if (!empty($summary['comparison_matrix_blocks'])) {
            $chips[] = sprintf(__('Matrix tables: %d', 'wp-seo-next'), (int) $summary['comparison_matrix_blocks']);
        }
        if (!empty($summary['comparison_generic_html_tables'])) {
            $chips[] = sprintf(__('Classic HTML tables: %d', 'wp-seo-next'), (int) $summary['comparison_generic_html_tables']);
        }
        if (!empty($summary['comparison_vendor_table_layouts'])) {
            $chips[] = sprintf(__('Vendor table layouts: %d', 'wp-seo-next'), (int) $summary['comparison_vendor_table_layouts']);
        }
        if (!empty($summary['comparison_vendor_widget_layouts'])) {
            $chips[] = sprintf(__('Vendor widgets: %d', 'wp-seo-next'), (int) $summary['comparison_vendor_widget_layouts']);
        }
        if (!empty($summary['comparison_vendor_shortcodes'])) {
            $chips[] = sprintf(__('Vendor shortcodes: %d', 'wp-seo-next'), (int) $summary['comparison_vendor_shortcodes']);
        }
        if (!empty($summary['comparison_vendor_labels']) && is_array($summary['comparison_vendor_labels'])) {
            $chips[] = sprintf(__('Affiliate vendors: %s', 'wp-seo-next'), implode(', ', array_slice(array_map('strval', $summary['comparison_vendor_labels']), 0, 4)));
        }
        if (!empty($summary['service_blocks'])) {
            $chips[] = sprintf(__('Service blocks: %d', 'wp-seo-next'), (int) $summary['service_blocks']);
        }
        if (!empty($summary['recognized_blocks'])) {
            $chips[] = sprintf(__('Recognized block types: %d', 'wp-seo-next'), (int) $summary['recognized_blocks']);
        }
        if (!empty($chips)) {
            echo '<p class="description" style="margin:0 0 8px;">' . esc_html(implode(' · ', $chips)) . '</p>';
        }

        if (!empty($blockMapMatches)) {
            $matchLabels = [];
            foreach (array_slice($blockMapMatches, 0, 6) as $match) {
                if (!is_array($match)) {
                    continue;
                }
                $label = isset($match['label']) ? (string) $match['label'] : (string) ($match['block_name'] ?? '');
                $schemaTypes = isset($match['schema_types']) && is_array($match['schema_types']) ? array_map('strval', $match['schema_types']) : [];
                if ($label === '' || empty($schemaTypes)) {
                    continue;
                }
                $matchLabels[] = $label . ' → ' . implode('/', array_map('strtoupper', $schemaTypes));
            }
            if (!empty($matchLabels)) {
                echo '<p class="description" style="margin:0 0 8px;">' . esc_html__('Recognized block map:', 'wp-seo-next') . ' ' . esc_html(implode(' · ', $matchLabels)) . '</p>';
            }
        }

        echo '<div style="display:flex; flex-wrap:wrap; gap:8px;">';
        foreach ($available as $index => $slug) {
            $slug = sanitize_key((string) $slug);
            if ($slug === '' || empty($presets[$slug])) {
                continue;
            }
            $template = $helper->buildMappedTemplate((int) $post->ID, $slug);
            if ($template === '') {
                continue;
            }
            $sourceId = 'wpsn-schema-block-template-' . (int) $post->ID . '-' . $index;
            $label = isset($presets[$slug]['label']) ? (string) $presets[$slug]['label'] : ucfirst($slug);
            echo '<button type="button" class="button wpsn-schema-insert-template" data-target="wpsn_schema_custom_json_ld" data-source-id="' . esc_attr($sourceId) . '">' . esc_html(sprintf(__('Insert %s draft', 'wp-seo-next'), $label)) . '</button>';
            echo '<textarea id="' . esc_attr($sourceId) . '" class="hidden" style="display:none;">' . esc_textarea($template) . '</textarea>';
        }
        echo '</div>';
        echo '</div>';
    }

    private function renderMappingHelper(\WP_Post $post): void
    {
        /** @var SchemaMappingHelper $helper */
        $helper = $this->container->get('schema_mapping_helper');
        $suggestions = $helper->suggestForPost((int) $post->ID, 3);
        if (empty($suggestions)) {
            return;
        }

        $presets = SchemaTypeLibrary::presets();

        echo '<div class="wpsn-schema-mapping-helper" style="margin:0 0 12px; padding:12px; border:1px solid #dcdcde; border-radius:4px; background:#fff;">';
        echo '<p style="margin:0 0 8px;"><strong>' . esc_html__('Mapping helper', 'wp-seo-next') . '</strong></p>';
        echo '<p class="description" style="margin:0 0 8px;">' . esc_html__('Based on the current content and any recognized Gutenberg blocks, these schema starters look like the best fit. “Load mapped draft” inserts a best-effort starter with extracted questions/answers/steps where possible.', 'wp-seo-next') . '</p>';

        foreach ($suggestions as $index => $suggestion) {
            if (!is_array($suggestion)) {
                continue;
            }
            $slug = isset($suggestion['slug']) ? sanitize_key((string) $suggestion['slug']) : '';
            if ($slug === '' || empty($presets[$slug])) {
                continue;
            }
            $label = (string) $presets[$slug]['label'];
            $template = $helper->buildMappedTemplate((int) $post->ID, $slug);
            if ($template === '') {
                $template = (string) ($presets[$slug]['template'] ?? '');
            }
            $sourceId = 'wpsn-schema-template-source-' . (int) $post->ID . '-' . $index;
            $score = isset($suggestion['score']) ? (int) $suggestion['score'] : 0;
            $reasons = isset($suggestion['reasons']) && is_array($suggestion['reasons']) ? $suggestion['reasons'] : [];

            echo '<div style="margin:0 0 10px; padding:10px; border:1px solid #f0f0f1; border-radius:4px; background:#f8f9f9;">';
            echo '<div style="display:flex; justify-content:space-between; gap:12px; align-items:flex-start;">';
            echo '<div>';
            echo '<strong>' . esc_html($label) . '</strong> <span class="description">(' . esc_html(sprintf(__('score %d', 'wp-seo-next'), $score)) . ')</span>';
            if (!empty($reasons)) {
                echo '<p class="description" style="margin:4px 0 0;">' . esc_html(implode(' · ', array_map('strval', $reasons))) . '</p>';
            }
            echo '</div>';
            echo '<div><button type="button" class="button wpsn-schema-insert-template" data-target="wpsn_schema_custom_json_ld" data-source-id="' . esc_attr($sourceId) . '">' . esc_html__('Load mapped draft', 'wp-seo-next') . '</button></div>';
            echo '</div>';
            echo '<textarea id="' . esc_attr($sourceId) . '" class="hidden" style="display:none;">' . esc_textarea($template) . '</textarea>';
            echo '</div>';
        }

        echo '</div>';
    }

    private function renderAiHelper(\WP_Post $post): void
    {
        /** @var SchemaAiGenerator $generator */
        $generator = $this->container->get('schema_ai_generator');
        $availability = $generator->availabilityForPost((int) $post->ID);
        $allowed = !empty($availability['allowed']);
        $reason = isset($availability['reason']) ? (string) $availability['reason'] : '';
        $provider = isset($availability['provider']) ? (string) $availability['provider'] : '';

        echo '<div class="wpsn-schema-ai-helper" style="margin:0 0 12px; padding:12px; border:1px solid #dcdcde; border-radius:4px; background:#fff;">';
        echo '<p style="margin:0 0 8px;"><strong>' . esc_html__('AI schema draft', 'wp-seo-next') . '</strong></p>';
        echo '<p class="description" style="margin:0 0 8px;">' . esc_html__('Generate a custom JSON-LD draft from the current page content and any recognized Gutenberg blocks. The draft is inserted into the textarea below and still needs human review before saving.', 'wp-seo-next') . '</p>';

        echo '<div class="wpsn-schema-ai-controls" data-post-id="' . esc_attr((string) $post->ID) . '" data-target="wpsn_schema_custom_json_ld">';
        echo '<p style="margin:0 0 8px;">';
        echo '<label for="wpsn-schema-ai-preset-' . esc_attr((string) $post->ID) . '" style="margin-right:6px;">' . esc_html__('Preferred type', 'wp-seo-next') . '</label>';
        echo '<select id="wpsn-schema-ai-preset-' . esc_attr((string) $post->ID) . '" class="wpsn-schema-ai-preset">';
        echo '<option value="auto">' . esc_html__('Auto-detect', 'wp-seo-next') . '</option>';
        foreach (SchemaTypeLibrary::presets() as $slug => $preset) {
            echo '<option value="' . esc_attr((string) $slug) . '">' . esc_html((string) ($preset['label'] ?? $slug)) . '</option>';
        }
        echo '</select> ';
        echo '<button type="button" class="button button-secondary wpsn-schema-ai-generate" ' . disabled($allowed, false, false) . '>' . esc_html__('Generate AI draft', 'wp-seo-next') . '</button>';
        echo '</p>';

        if ($allowed) {
            $providerLabel = $provider === 'saas_proxy' ? __('SaaS proxy', 'wp-seo-next') : __('OpenAI', 'wp-seo-next');
            echo '<p class="description" style="margin:0;">' . esc_html(sprintf(__('Provider: %s.', 'wp-seo-next'), $providerLabel)) . '</p>';
        } elseif ($reason !== '') {
            echo '<p class="description" style="margin:0;">' . esc_html($reason) . '</p>';
        }

        echo '<div class="wpsn-schema-ai-status description" style="margin-top:8px;"></div>';
        echo '</div>';
        echo '</div>';
    }

    public function saveMetaBox(int $postId, \WP_Post $post): void
    {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) {
            return;
        }
        if (!isset($_POST['wpsn_schema_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_schema_nonce']), 'wpsn_save_schema')) {
            return;
        }
        if (!current_user_can('edit_post', $postId)) {
            return;
        }

        $incoming = $_POST['wpsn_schema'] ?? [];
        if (!is_array($incoming)) {
            $incoming = [];
        }

        $schemaType = isset($incoming['schema_type']) ? self::sanitizeSchemaType((string) $incoming['schema_type']) : '';
        if ($schemaType === '' || $schemaType === 'default') {
            delete_post_meta($postId, MetaKeys::SCHEMA_TYPE);
        } else {
            update_post_meta($postId, MetaKeys::SCHEMA_TYPE, $schemaType);
        }

        $customJsonLd = isset($incoming['custom_json_ld']) ? SchemaCustomJsonLdResolver::sanitizeRaw((string) $incoming['custom_json_ld']) : '';
        if ($customJsonLd === '') {
            delete_post_meta($postId, MetaKeys::SCHEMA_CUSTOM_JSON_LD);
        } else {
            update_post_meta($postId, MetaKeys::SCHEMA_CUSTOM_JSON_LD, $customJsonLd);
        }

        $this->container->get('events')->dispatch('seo/meta/updated', [$postId, 'post']);
    }

    public function render(): void
    {
        if (is_admin() || is_feed()) {
            return;
        }

        /** @var DocumentResolver $resolver */
        $resolver = $this->container->get('document_resolver');
        $ctx = $resolver->resolve();

        /** @var SeoMetaRepository $repo */
        $repo = $this->container->get('meta_repo');
        $meta = $repo->forContext($ctx);

        // Typically we don't want schema output on noindex pages.
        if (!empty($meta->robots['noindex'])) {
            return;
        }

        /** @var SchemaGraphBuilder $builder */
        $builder = $this->container->get('schema_builder');
        $jsonLd = $builder->build($ctx, $meta);
        if (!is_array($jsonLd)) {
            return;
        }

        $json = wp_json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if (!is_string($json) || $json === '') {
            return;
        }

        echo "\n";

        // Optional debug comment (suppressed in white‑label hide-branding mode).
        if (!Branding::hideBranding($this->settings)) {
            $brand = Branding::brand($this->settings);
            $brand = sanitize_text_field($brand);
            // Prevent comment breakouts (e.g. "-->").
            $brand = str_replace(['--', '>', '<'], '', $brand);
            if ($brand !== '') {
                echo "<!-- " . esc_html($brand) . ": Schema -->\n";
            }
        }
        echo '<script type="application/ld+json">' . $json . '</script>' . "\n";
    }
}
