<?php
if (!defined('ABSPATH')) exit;

class RANKFORGE_Schema {
    private static $instance = null;
    public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; }

    private function __construct() {
        add_action('wp_head', [$this, 'output_schema'], 5);
        add_action('save_post', [$this, 'invalidate_ai_schema_cache'], 10, 1);

        // prevent duplicate Product JSON-LD. On single product pages
        // Rank Forge emits its own (Woo-accurate) Product schema via
        // output_schema/generate. WooCommerce also outputs a built-in
        // Product node by default, so two conflicting Product blocks would
        // appear — Google flags duplicates. Strip Woo's product node and keep
        // ours as the single source of truth. (The filter only fires on
        // product pages, so other Woo structured data is untouched.)
        if (class_exists('WooCommerce')) {
            add_filter('woocommerce_structured_data_product', '__return_empty_array', 99);
        }
    }

    public function output_schema() {
        if (is_admin()) return;
        if (is_singular()) {
            $post_id = get_queried_object_id();

            // Priority 0: Full manual schema JSON override from Schema Builder.
            // When the user pasted complete JSON-LD they own the entire emit,
            // so we don't add Article / auto-FAQ on top — that would conflict
            // with their hand-tuned shape. Breadcrumb still emits afterwards
            // (outside this branch) because it's site-structural.
            $manual_schema_json = get_post_meta($post_id, '_rankforge_manual_schema', true);
            if (!empty($manual_schema_json)) {
                $manual = json_decode($manual_schema_json, true);
                if (is_array($manual) && !empty($manual['@type'])) {
                    echo '<script type="application/ld+json">' . wp_json_encode($manual, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_PRETTY_PRINT) . '</script>' . "\n";
                }
            } else {
                // R3 F4 (2026-04-28): build an additive list of schemas instead
                // of one-priority-wins. Output stack:
                // 1. Auto-detected default (e.g. Article on posts) — always
                // emitted so rich-result eligibility for the post type
                // is preserved even when the user picks a manual type.
                // 2. AI-cached blocks if present.
                // 3. Manual Schema-Builder selection (FAQ / HowTo / Recipe /
                // Event / Review / Product / VideoObject) — added on top,
                // deduped against types already emitted.
                // 4. Auto-FAQ when content has >= 2 Q/A heading pairs —
                // added independently of manual selection, deduped.

                // Pre-R3 behavior was "manual replaces Article" (class-schema.php
                // L31-36, "elseif" branch returned a single schema). KB and
                // user guide both promised "multiple schemas allowed", so the
                // old shape was a bug. See forge/reports/2026-04-28-audit-rankforge.md
                // F4 for context.
                $emitted_types = [];
                $emit = function($schema) use (&$emitted_types) {
                    if (empty($schema) || empty($schema['@type'])) return;
                    $t = $schema['@type'];
                    if (in_array($t, $emitted_types, true)) return; // dedup
                    $emitted_types[] = $t;
                    echo '<script type="application/ld+json">' . wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_PRETTY_PRINT) . '</script>' . "\n";
                };

                $manual_type = get_post_meta($post_id, '_rankforge_schema_type', true);
                // Free/Pro boundary: the manual Schema Builder (type + fields)
                // is FREE, so a manually chosen $manual_type emits on Free.
                // AI schema is a PRO feature end-to-end — both GENERATION and
                // OUTPUT are gated behind 'ai_features' (2026-05-30 audit #11).
                // A Free (or downgraded) site does NOT emit a stored
                // `_rankforge_ai_schema`: AI-generated structured data is Pro
                // value and must not render on the front end without an active
                // license. Manual schema is unaffected.
                $ai_schema_enabled = RANKFORGE_License::instance()->can('ai_features');
                $ai_schema   = $ai_schema_enabled ? get_post_meta($post_id, '_rankforge_ai_schema', true) : [];
                $auto_type   = $this->auto_detect_type($post_id);

                // 1. Auto-detected default (Article on posts, Product on
                // products, WebPage elsewhere). Skip when manual selection
                // overlaps the same type — generate already covers it.
                // Skip Product default if manual type also produces a
                // Product / Article-family node (manual wins).
                if ($manual_type !== $auto_type) {
                    $auto_schema = $this->generate($post_id, $auto_type);
                    $emit($auto_schema);
                }

                // 2. AI-cached blocks (legacy multi-block path).
                if (!empty($ai_schema) && is_array($ai_schema) && !empty($ai_schema['json_ld'])) {
                    foreach ($ai_schema['json_ld'] as $ld_block) {
                        if (!empty($ld_block) && is_array($ld_block)) {
                            $emit($ld_block);
                        }
                    }
                }

                // 3. Manual Schema-Builder selection — added on top of (1),
                // not replacing it. Manual HowTo with empty steps still
                // returns null per fc60364 → emit drops it cleanly.
                if (!empty($manual_type) && $manual_type !== 'auto') {
                    $manual_schema = $this->generate($post_id, $manual_type);
                    $emit($manual_schema);
                }

                // 4. Auto-FAQ — independent of manual selection. Deduped
                // against any FAQPage already emitted (manual=FAQ or AI).
                // Skip when post type defaults to Product (commerce posts
                // rarely have Q/A patterns and FAQPage there is noisy).
                if ($auto_type !== 'Product' && !in_array('FAQPage', $emitted_types, true)) {
                    $faq = $this->generate_faq($post_id, get_post($post_id), true);
                    if (!empty($faq['mainEntity']) && count($faq['mainEntity']) >= 2) {
                        // Allow themes / users to opt-out of auto FAQ detection.
                        $faq = apply_filters('rankforge_auto_faq_schema', $faq, $post_id);
                        if (!empty($faq) && !empty($faq['mainEntity'])) {
                            $emit($faq);
                        }
                    }
                }

                // Allow themes / extensions to filter or extend the final
                // emitted list. Filter receives the array of @type strings
                // already on the page; useful for diagnostics or extra dedup.
                do_action('rankforge_schemas_emitted', $emitted_types, $post_id);
            }
        }

        // Breadcrumbs on all pages
        $breadcrumbs = $this->generate_breadcrumbs();
        if ($breadcrumbs) {
            echo '<script type="application/ld+json">' . wp_json_encode($breadcrumbs, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
        }

        // Organization on homepage — skip if Local SEO already emits a
        // business entity (Organization / LocalBusiness and all its sub-
        // types are all Organization-rooted in schema.org, so two snippets
        // would confuse Google). Local SEO's emitter has phone/address/
        // logo/geo/hours, so it takes precedence.
        if (is_front_page()) {
            $local_already_emits = false;
            if (class_exists('RANKFORGE_Local_SEO')) {
                $ls = RANKFORGE_Local_SEO::get_settings();
                $local_already_emits = !empty($ls['enabled']) && !empty($ls['business_name']);
            }
            if (!$local_already_emits) {
                $org = $this->generate_organization();
                echo '<script type="application/ld+json">' . wp_json_encode($org, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
            }
        }
    }

    public function generate($post_id, $type = 'Article') {
        $post = get_post($post_id);
        if (!$post) return null;

        $base = [
            '@context' => 'https://schema.org',
            'name' => $post->post_title,
            'url' => get_permalink($post_id),
            'datePublished' => get_the_date('c', $post),
            'dateModified' => get_the_modified_date('c', $post),
        ];

        switch ($type) {
            case 'Article':
            case 'BlogPosting':
            case 'NewsArticle':
                // expanded Article schema matching popular generators
                // (Yoast, RankMath). Publisher now includes logo ImageObject,
                // author gets URL, article adds wordCount + articleBody +
                // articleSection + keywords.
                $featured_img_id = get_post_thumbnail_id($post_id);
                $img_url = $featured_img_id ? wp_get_attachment_image_url($featured_img_id, 'full') : '';
                $img_node = null;
                if ($img_url) {
                    $meta = $featured_img_id ? wp_get_attachment_metadata($featured_img_id) : [];
                    $img_node = [
                        '@type'  => 'ImageObject',
                        'url'    => $img_url,
                        'width'  => isset($meta['width']) ? (int)$meta['width'] : null,
                        'height' => isset($meta['height']) ? (int)$meta['height'] : null,
                    ];
                    $img_node = array_filter($img_node, function ($v) { return $v !== null; });
                }

                $author_id = (int) $post->post_author;
                $author_node = [
                    '@type' => 'Person',
                    'name'  => get_the_author_meta('display_name', $author_id),
                ];
                $author_url = get_author_posts_url($author_id);
                if ($author_url) $author_node['url'] = $author_url;

                $text = wp_strip_all_tags($post->post_content);
                $word_count = $text ? str_word_count($text) : 0;

                // Rank Forge primary category (from Yoast-style meta or
                // first category). Schema.org `articleSection` accepts string.
                $section = null;
                if (taxonomy_exists('category')) {
                    $cats = get_the_category($post_id);
                    if ($cats && !empty($cats[0]->name)) $section = $cats[0]->name;
                }
                $tags = wp_get_post_tags($post_id, ['fields' => 'names']);
                $keywords = $tags ? implode(', ', $tags) : null;

                $article = array_merge($base, [
                    '@type'            => $type,
                    'headline'         => mb_substr($post->post_title, 0, 110),
                    'description'      => get_post_meta($post_id, '_rankforge_meta_description', true) ?: wp_trim_words($post->post_content, 25),
                    'author'           => $author_node,
                    'publisher'        => $this->build_publisher_node(),
                    'image'            => $img_node ?: $img_url,
                    'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => get_permalink($post_id)],
                    'wordCount'        => $word_count,
                    'inLanguage'       => get_bloginfo('language'),
                ]);
                if ($section)  $article['articleSection'] = $section;
                if ($keywords) $article['keywords']       = $keywords;
                return array_filter($article, function ($v) { return $v !== null && $v !== ''; });

            case 'Product':
                $brand    = get_option('rankforge_brand_name', '') ?: get_bloginfo('name');
                $featured = get_post_thumbnail_id($post_id);
                $img_url  = $featured ? wp_get_attachment_image_url($featured, 'full') : '';
                // read live WooCommerce data when available so the
                // markup reflects sale price, variable-product price, real
                // stock status and review ratings — not stale raw postmeta.
                $wc_product = function_exists('wc_get_product') ? wc_get_product($post_id) : null;
                if ($wc_product) {
                    $price        = $wc_product->get_price();
                    $sku          = $wc_product->get_sku();
                    $currency     = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : get_option('woocommerce_currency', 'USD');
                    $availability = 'https://schema.org/' . ($wc_product->is_in_stock() ? 'InStock' : 'OutOfStock');
                    $rating_val   = (float) $wc_product->get_average_rating();
                    $rating_count = (int) $wc_product->get_review_count();
                } else {
                    $price        = get_post_meta($post_id, '_price', true) ?: get_post_meta($post_id, '_regular_price', true);
                    $sku          = get_post_meta($post_id, '_sku', true);
                    $currency     = get_option('woocommerce_currency', 'USD');
                    $availability = 'https://schema.org/InStock';
                    $rating_val   = 0.0;
                    $rating_count = 0;
                }
                $prod = array_merge($base, [
                    '@type'       => 'Product',
                    'description' => get_post_meta($post_id, '_rankforge_meta_description', true) ?: wp_trim_words($post->post_content, 25),
                    'image'       => $img_url ?: '',
                    'brand'       => ['@type' => 'Brand', 'name' => $brand],
                    'sku'         => $sku ?: null,
                    'offers'      => ($price !== '' && $price !== null) ? [
                        '@type'         => 'Offer',
                        'url'           => get_permalink($post_id),
                        'price'         => $price,
                        'priceCurrency' => $currency,
                        'availability'  => $availability,
                        'itemCondition' => 'https://schema.org/NewCondition',
                    ] : null,
                ]);
                // Google needs aggregateRating to render review stars. Only emit
                // when at least one review exists (an empty/zero rating node is
                // an invalid-markup warning in Search Console).
                if ($rating_count > 0 && $rating_val > 0) {
                    $prod['aggregateRating'] = [
                        '@type'       => 'AggregateRating',
                        'ratingValue' => $rating_val,
                        'reviewCount' => $rating_count,
                    ];
                }
                return array_filter($prod, function ($v) { return $v !== null && $v !== ''; });

            case 'FAQ':
                return $this->generate_faq($post_id, $post);

            case 'HowTo':
                $steps = $this->extract_howto_steps($post->post_content);
                // Google's HowTo structured data requires `step`. Emitting an
                // empty step array is invalid markup that triggers Search
                // Console errors and won't render rich results. Author
                // selected HowTo but didn't write any [rankforge_step] blocks
                // or H2/H3 sections -> skip the schema entirely (caller drops
                // null cleanly). Article + BreadcrumbList still get emitted
                // by the surrounding flow only when the auto-detect path
                // runs; for manual HowTo we degrade to no schema rather
                // than ship a broken one.
                if (empty($steps)) {
                    return null;
                }
                $howto = array_merge($base, [
                    '@type'       => 'HowTo',
                    'description' => $this->content_excerpt($post->post_content, 50),
                    'step'        => $steps,
                    'image'       => get_the_post_thumbnail_url($post_id, 'full') ?: null,
                ]);
                return array_filter($howto, function ($v) { return $v !== null && $v !== ''; });

            case 'Recipe':
                return array_filter(array_merge($base, [
                    '@type'             => 'Recipe',
                    'description'       => $this->content_excerpt($post->post_content, 40),
                    'image'             => get_the_post_thumbnail_url($post_id, 'full') ?: null,
                    'author'            => ['@type' => 'Person', 'name' => get_the_author_meta('display_name', $post->post_author)],
                    'recipeIngredient'  => get_post_meta($post_id, '_rankforge_recipe_ingredients', true) ?: null,
                    'recipeInstructions'=> $this->extract_howto_steps($post->post_content),
                    'recipeYield'       => get_post_meta($post_id, '_rankforge_recipe_yield', true) ?: null,
                    'totalTime'         => get_post_meta($post_id, '_rankforge_recipe_total_time', true) ?: null,
                    'nutrition'         => null,
                ]), function ($v) { return $v !== null && $v !== ''; });

            case 'Event':
                return array_filter(array_merge($base, [
                    '@type'       => 'Event',
                    'description' => wp_trim_words($post->post_content, 40),
                    'startDate'   => get_post_meta($post_id, '_rankforge_event_start', true) ?: null,
                    'endDate'     => get_post_meta($post_id, '_rankforge_event_end', true)   ?: null,
                    'location'    => ['@type' => 'Place', 'name' => get_post_meta($post_id, '_rankforge_event_location', true) ?: '', 'address' => get_post_meta($post_id, '_rankforge_event_address', true) ?: ''],
                    'image'       => get_the_post_thumbnail_url($post_id, 'full') ?: null,
                    'organizer'   => $this->build_publisher_node(),
                ]), function ($v) { return $v !== null && $v !== ''; });

            case 'Review':
                return array_filter(array_merge($base, [
                    '@type'        => 'Review',
                    'reviewBody'   => wp_trim_words($post->post_content, 50),
                    'author'       => ['@type' => 'Person', 'name' => get_the_author_meta('display_name', $post->post_author)],
                    'itemReviewed' => ['@type' => 'Thing', 'name' => get_post_meta($post_id, '_rankforge_review_item', true) ?: $post->post_title],
                    'reviewRating' => [
                        '@type'       => 'Rating',
                        'ratingValue' => get_post_meta($post_id, '_rankforge_review_rating', true) ?: '5',
                        'bestRating'  => '5',
                        'worstRating' => '1',
                    ],
                    'publisher'    => $this->build_publisher_node(),
                ]), function ($v) { return $v !== null && $v !== ''; });

            case 'VideoObject':
                return array_filter(array_merge($base, [
                    '@type'        => 'VideoObject',
                    'description'  => wp_trim_words($post->post_content, 40),
                    'thumbnailUrl' => get_the_post_thumbnail_url($post_id, 'full') ?: null,
                    'uploadDate'   => get_the_date('c', $post),
                    'contentUrl'   => get_post_meta($post_id, '_rankforge_video_url', true) ?: null,
                    'embedUrl'     => get_post_meta($post_id, '_rankforge_video_embed_url', true) ?: null,
                    'duration'     => get_post_meta($post_id, '_rankforge_video_duration', true) ?: null,
                    'publisher'    => $this->build_publisher_node(),
                ]), function ($v) { return $v !== null && $v !== ''; });

            default:
                return array_merge($base, ['@type' => 'WebPage']);
        }
    }

    /**
 * Publisher/Organization node with logo ImageObject — used by
 * Article, Review, Event and others. Logo falls back through custom
 * logo → site icon so Schema stays valid even without explicit setup.
 */
    private function build_publisher_node(): array {
        $node = [
            '@type' => 'Organization',
            'name'  => get_bloginfo('name'),
            'url'   => home_url('/'),
        ];
        $logo_id = (int) get_theme_mod('custom_logo');
        $logo_url = $logo_id ? wp_get_attachment_image_url($logo_id, 'full') : '';
        if (!$logo_url) {
            $icon_id = (int) get_option('site_icon');
            $logo_url = $icon_id ? wp_get_attachment_image_url($icon_id, 'full') : '';
        }
        if ($logo_url) {
            $logo_meta = $logo_id ? wp_get_attachment_metadata($logo_id) : [];
            $node['logo'] = array_filter([
                '@type'  => 'ImageObject',
                'url'    => $logo_url,
                'width'  => isset($logo_meta['width'])  ? (int)$logo_meta['width']  : null,
                'height' => isset($logo_meta['height']) ? (int)$logo_meta['height'] : null,
            ], function ($v) { return $v !== null; });
        }
        return $node;
    }

    /**
 * Extract HowTo / Recipe steps from post content. Priority:
 * 1. Explicit [rankforge_step title="…"] shortcodes
 * 2. H2/H3 headings as step names (fallback)
 *
 * R2-N1 (2026-04-28): heading fallback now filters out question-form
 * headings (ending in `?` / `？` or starting with question-words like
 * "how/what/why/when/where/who/which"). Author selected HowTo as the
 * schema type but actually wrote FAQ-style content → emitting questions
 * as step names produces invalid HowTo (Google Search Console flags
 * steps that aren't imperative). Better to return and let the
 * caller skip the schema (existing fc60364 fall-through handles that).
 */
    /**
 * Round 3 (2026-04-28): public wrapper so the metabox can preview the
 * extracted steps in the Schema Builder before save. Runs the same
 * extraction logic used at frontend render time, including the
 * round 2 question-form filter — what the author sees here is what
 * Google will see.
 */
    public function preview_howto_steps(string $html): array {
        return $this->extract_howto_steps($html);
    }

    private function extract_howto_steps(string $html): array {
        $steps = [];
        if (preg_match_all('/\[rankforge_step[^\]]*title=["\']([^"\']+)["\'][^\]]*\](.*?)\[\/rankforge_step\]/is', $html, $m)) {
            foreach ($m[1] as $idx => $title) {
                $steps[] = [
                    '@type' => 'HowToStep',
                    'name'  => $title,
                    'text'  => wp_strip_all_tags($m[2][$idx]),
                ];
            }
        }
        if (!$steps && preg_match_all('/<h([23])[^>]*>(.*?)<\/h\1>/is', $html, $hm)) {
            foreach ($hm[2] as $name) {
                $clean = trim(wp_strip_all_tags($name));
                if ($clean === '' || $this->looks_like_question($clean)) {
                    continue;
                }
                $steps[] = ['@type' => 'HowToStep', 'name' => $clean];
            }
        }
        return $steps;
    }

    /**
 * R2-N1 helper. True when a heading looks like a question rather than
 * an imperative step (e.g. "How do I X?", "What is Y?"). Used by
 * extract_howto_steps to avoid building invalid HowTo schema from
 * FAQ-style content. ASCII `?` and full-width `？` both count, plus
 * the common English question-word prefixes.
 */
    private function looks_like_question(string $text): bool {
        $trim = rtrim($text, " \t\n\r\0\x0B.!");
        if ($trim === '') return false;
        $last = mb_substr($trim, -1);
        if ($last === '?' || $last === "\xEF\xBC\x9F") { // `？` U+FF1F
            return true;
        }
        $first = mb_strtolower(strtok($trim, " \t\n\r\0\x0B"));
        $question_words = ['how', 'what', 'why', 'when', 'where', 'who', 'which', 'whose', 'whom'];
        return in_array($first, $question_words, true);
    }

    /**
 * R2-N1 helper. wp_trim_words pipes content through wp_strip_all_tags
 * which collapses block-level boundaries with no separator → "How do I
 * prep?Wash them.180C." Used in HowTo / Recipe `description`. We
 * normalize block tags to spaces first so words stay separated.
 */
    private function content_excerpt(string $html, int $words): string {
        $with_spaces = preg_replace('/<\/(p|h[1-6]|li|ul|ol|div|section|article|blockquote|pre|figure|tr|td|th)>/i', ' ', $html);
        $with_spaces = preg_replace('/<br\s*\/?>/i', ' ', $with_spaces);
        return wp_trim_words($with_spaces, $words);
    }

    /**
 * @param int $post_id
 * @param WP_Post $post
 * @param bool $only_questions When true, only headings whose plain
 * text ends with `?` (or `？` full-width)
 * are treated as questions. Used by the
 * automatic detector so a regular article
 * with H2 sections doesn't get falsely
 * stamped as a FAQPage. When `false`
 * (manual user override → "FAQ"
 * Schema type), every heading + paragraph
 * pair is treated as Q/A.
 */
    private function generate_faq($post_id, $post, $only_questions = false) {
        // Parse H2/H3 + following paragraph as Q&A pairs
        $content = $post->post_content;
        preg_match_all('/<h[23][^>]*>(.*?)<\/h[23]>\s*(.*?)(?=<h[23]|$)/is', $content, $matches);

        $items = [];
        if (!empty($matches[1])) {
            foreach ($matches[1] as $i => $question) {
                $question_text = trim(wp_strip_all_tags($question));
                if ($only_questions) {
                    $last_char = mb_substr($question_text, -1);
                    if ($last_char !== '?' && $last_char !== '？') continue;
                }
                $answer = isset($matches[2][$i]) ? wp_strip_all_tags($matches[2][$i]) : '';
                if ($answer && $question_text !== '') {
                    $items[] = [
                        '@type' => 'Question',
                        'name' => $question_text,
                        'acceptedAnswer' => ['@type' => 'Answer', 'text' => trim($answer)],
                    ];
                }
            }
        }

        return [
            '@context' => 'https://schema.org',
            '@type' => 'FAQPage',
            'mainEntity' => $items,
        ];
    }

    private function generate_breadcrumbs() {
        // delegate to the Breadcrumbs engine so JSON-LD matches the
        // visual output byte-for-byte. Emitted regardless of the `enabled`
        // visual toggle — that's the "silent action" requirement.
        if (is_front_page()) return null;

        if (class_exists('RANKFORGE_Breadcrumbs')) {
            $trail = RANKFORGE_Breadcrumbs::instance()->trail();
            if (count($trail) < 2) return null;

            $items = [];
            foreach ($trail as $i => $crumb) {
                $entry = [
                    '@type'    => 'ListItem',
                    'position' => $i + 1,
                    'name'     => $crumb['name'],
                ];
                if (!empty($crumb['url'])) $entry['item'] = $crumb['url'];
                $items[] = $entry;
            }
            return ['@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => $items];
        }

        // Legacy minimal fallback (should not hit in normal runtime).
        $items = [['@type' => 'ListItem', 'position' => 1, 'name' => get_bloginfo('name'), 'item' => home_url('/')]];
        if (is_singular()) {
            $post = get_queried_object();
            $items[] = ['@type' => 'ListItem', 'position' => 2, 'name' => $post->post_title, 'item' => get_permalink($post)];
        }
        return ['@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => $items];
    }

    private function generate_organization() {
        // Drop empty values so we never ship a hollow property like
        // "description":"" when the site tagline is unset — an empty schema
        // field reads as low-quality to validators and AI knowledge-graph
        // parsers, and on the landings it produced a redundant Organization
        // node alongside the SoftwareApplication publisher.
        return array_filter([
            '@context'    => 'https://schema.org',
            '@type'       => 'Organization',
            'name'        => get_bloginfo('name'),
            'url'         => home_url('/'),
            'description' => get_bloginfo('description'),
        ], static function ($v) {
            return $v !== '' && $v !== null && $v !== false;
        });
    }

    public function auto_detect_type($post_id) {
        $post_type = get_post_type($post_id);
        if ($post_type === 'product') return 'Product';
        if ($post_type === 'post') return 'Article';
        return 'WebPage';
    }

    public static function get_available_types() {
        return ['Article', 'BlogPosting', 'Product', 'FAQ', 'HowTo', 'Recipe', 'Event', 'Review', 'WebPage', 'VideoObject'];
    }

    /**
 * AI-powered schema detection and JSON-LD generation.
 *
 * Sends post content to AI to detect applicable schema types
 * and generate valid JSON-LD markup. Results are cached in post_meta.
 *
 * @param int $post_id The post ID to analyze.
 * @param bool $force Force re-detection, ignoring cache.
 * @return array|null Array with 'detected_types' and 'json_ld', or null on failure.
 */
    public function ai_detect_and_generate( $post_id, $force = false ) {
        $post = get_post( $post_id );
        if ( ! $post ) {
            return null;
        }

        // Check cache first (unless forced)
        if ( ! $force ) {
            $cached = get_post_meta( $post_id, '_rankforge_ai_schema', true );
            if ( ! empty( $cached ) && is_array( $cached ) && ! empty( $cached['detected_types'] ) ) {
                return $cached;
            }
        }

        // Check if AI is available. RANKFORGE_AI_Client lives in the Pro
        // add-on (post-split) — fall back to the deterministic schema
        // builder when Pro is not installed.
        if ( ! class_exists( 'RANKFORGE_AI_Client' ) ) {
            return $this->fallback_detect_and_generate( $post_id, $post );
        }
        $ai = RANKFORGE_AI_Client::instance();
        if ( ! $ai->is_configured() ) {
            return $this->fallback_detect_and_generate( $post_id, $post );
        }

        // Prepare content for AI
        $content = wp_strip_all_tags( $post->post_content );
        $content = mb_substr( $content, 0, 3000 );
        $post_title = get_the_title( $post_id );
        $post_url = get_permalink( $post_id );
        $post_type = get_post_type( $post_id );
        $author_name = get_the_author_meta( 'display_name', $post->post_author );
        $date_published = get_the_date( 'c', $post );
        $date_modified = get_the_modified_date( 'c', $post );
        $image_url = get_the_post_thumbnail_url( $post_id, 'full' ) ?: '';
        $site_name = get_bloginfo( 'name' );

        $system = 'You are a Schema.org and structured data expert. Analyze the provided content and generate valid JSON-LD schema markup.

RULES:
- Detect which schema types apply: Article, FAQ, Product, HowTo, Recipe, Event, Review, BlogPosting
- A post can have MULTIPLE types (e.g., Article + FAQ if it has Q&A sections)
- Extract REAL data from the content — never use placeholders
- Follow schema.org specifications exactly
- Include @context, @type, and all required properties for each type
- For FAQ: extract actual questions and answers from the content
- For HowTo: extract actual steps from the content
- For Recipe: extract ingredients, instructions, prep/cook time if present
- For Event: extract event name, date, location if present
- For Review: extract reviewed item, rating, author if present

Return a JSON object with this exact structure:
{
  "detected_types": ["Article", "FAQ"],
  "json_ld": [
    {
      "@context": "https://schema.org",
      "@type": "Article",
      ...all required properties...
    },
    {
      "@context": "https://schema.org",
      "@type": "FAQPage",
      ...all required properties...
    }
  ]
}

Return ONLY the JSON object. No explanations.';

        $user = "Post title: {$post_title}
Post URL: {$post_url}
Post type: {$post_type}
Author: {$author_name}
Published: {$date_published}
Modified: {$date_modified}
Image: {$image_url}
Site name: {$site_name}

Content:
{$content}

Analyze this content and generate JSON-LD schema markup:";

        $result = $ai->call_public( $system, $user );

        // If AI call failed, fall back to existing logic
        if ( is_wp_error( $result ) ) {
            return $this->fallback_detect_and_generate( $post_id, $post );
        }

        // Parse AI response
        $data = json_decode( $result, true );
        if ( ! is_array( $data ) || ! isset( $data['detected_types'] ) ) {
            // Try extracting JSON from response
            if ( preg_match( '/\{[\s\S]*\}/', $result, $m ) ) {
                $data = json_decode( $m[0], true );
            }
        }

        // Validate parsed data
        if ( ! is_array( $data ) || empty( $data['detected_types'] ) || empty( $data['json_ld'] ) ) {
            return $this->fallback_detect_and_generate( $post_id, $post );
        }

        // Ensure json_ld is an array of schema objects
        if ( ! is_array( $data['json_ld'] ) ) {
            return $this->fallback_detect_and_generate( $post_id, $post );
        }

        // Normalize: if json_ld is a single object (has @type), wrap it
        if ( isset( $data['json_ld']['@type'] ) ) {
            $data['json_ld'] = [ $data['json_ld'] ];
        }

        // Validate each JSON-LD block has @context and @type
        foreach ( $data['json_ld'] as &$block ) {
            if ( ! isset( $block['@context'] ) ) {
                $block['@context'] = 'https://schema.org';
            }
            if ( ! isset( $block['@type'] ) ) {
                continue;
            }
        }
        unset( $block );

        $schema_data = [
            'detected_types' => array_values( $data['detected_types'] ),
            'json_ld'        => array_values( $data['json_ld'] ),
            'generated_at'   => current_time( 'mysql' ),
            'source'         => 'ai',
        ];

        // Cache in post_meta
        update_post_meta( $post_id, '_rankforge_ai_schema', $schema_data );

        return $schema_data;
    }

    /**
 * Fallback schema detection using existing auto_detect_type and FAQ parsing.
 *
 * Used when AI is not configured or returns an error.
 *
 * @param int $post_id The post ID.
 * @param WP_Post $post The post object.
 * @return array|null
 */
    private function fallback_detect_and_generate( $post_id, $post ) {
        $type = $this->auto_detect_type( $post_id );
        $schema = $this->generate( $post_id, $type );

        if ( ! $schema ) {
            return null;
        }

        $detected_types = [ $type ];
        $json_ld = [ $schema ];

        // Also check for FAQ content via H2/H3 parsing
        if ( $type !== 'FAQ' ) {
            $faq = $this->generate_faq( $post_id, $post );
            if ( ! empty( $faq['mainEntity'] ) ) {
                $detected_types[] = 'FAQ';
                $json_ld[] = $faq;
            }
        }

        $schema_data = [
            'detected_types' => $detected_types,
            'json_ld'        => $json_ld,
            'generated_at'   => current_time( 'mysql' ),
            'source'         => 'fallback',
        ];

        // Cache fallback result too
        update_post_meta( $post_id, '_rankforge_ai_schema', $schema_data );

        return $schema_data;
    }

    /**
 * Invalidate AI schema cache when post content changes.
 *
 * Hooked to save_post action.
 *
 * @param int $post_id The post ID.
 */
    public function invalidate_ai_schema_cache( $post_id ) {
        // Skip autosaves and revisions
        if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
            return;
        }
        if ( wp_is_post_revision( $post_id ) ) {
            return;
        }

        delete_post_meta( $post_id, '_rankforge_ai_schema' );
    }
}
