<?php

namespace WPSN\SEO;

defined('ABSPATH') || exit;

/**
 * Best-effort local schema mapper.
 *
 * Goals:
 * - Suggest the most likely schema starter presets for a post/page.
 * - Build a mapped starter draft with extracted questions/steps/ingredients where possible.
 * - Stay conservative: if information is not visible in content, keep placeholders or omit fields.
 * - Prefer structured Gutenberg content when available (FAQ/accordion/list/heading blocks).
 */
final class SchemaMappingHelper
{
    /**
     * @return array<int,array{slug:string,score:int,reasons:array<int,string>}>
     */
    public function suggestForPost(int $postId, int $limit = 3): array
    {
        $analysis = $this->analyzePost($postId);
        $doc = $analysis['document'];
        if ($doc['post_id'] <= 0) {
            return [];
        }

        $scores = [];
        $reasons = [];
        foreach (['faq', 'howto', 'event', 'recipe', 'review', 'product', 'comparison', 'service'] as $slug) {
            $scores[$slug] = 0;
            $reasons[$slug] = [];
        }

        $title = $this->lower($doc['title']);
        $content = $this->lower($doc['content_plain']);
        $raw = $this->lower($doc['content_raw']);
        $postType = $doc['post_type'];

        $questionCount = $this->countMatches('/[^\n\r.!?]{8,160}\?/u', $doc['content_raw']);
        if ($questionCount >= 2) {
            $scores['faq'] += min(7, $questionCount + 2);
            $reasons['faq'][] = __('There are several question-like lines in the content.', 'wp-seo-next');
        }
        if ($this->containsAny($raw, ['faq', 'frequently asked', 'q:', 'questions and answers'])) {
            $scores['faq'] += 4;
            $reasons['faq'][] = __('The page explicitly looks like an FAQ.', 'wp-seo-next');
        }
        if (!empty($analysis['faq_pairs'])) {
            $scores['faq'] += min(10, count($analysis['faq_pairs']) + 5);
            $reasons['faq'][] = sprintf(
                __('Structured FAQ/accordion content was detected in Gutenberg blocks (%d items).', 'wp-seo-next'),
                count($analysis['faq_pairs'])
            );
        }

        $stepCount = $this->countMatches('/(?:^|\n)\s*(?:step\s+\d+|\d+[\).])\s+/ui', $doc['content_raw']);
        if ($this->containsAny($title . ' ' . $content, ['how to', 'step by step', 'tutorial', 'guide'])) {
            $scores['howto'] += 5;
            $reasons['howto'][] = __('The title/content reads like a how-to or tutorial.', 'wp-seo-next');
        }
        if ($stepCount >= 2) {
            $scores['howto'] += min(6, $stepCount + 1);
            $reasons['howto'][] = __('The content includes multiple step-like lines.', 'wp-seo-next');
        }
        if (!empty($analysis['howto_steps'])) {
            $scores['howto'] += min(10, count($analysis['howto_steps']) + 4);
            $reasons['howto'][] = sprintf(
                __('Ordered or step-like Gutenberg content was detected (%d steps).', 'wp-seo-next'),
                count($analysis['howto_steps'])
            );
        }

        $hasDateLike = (bool) preg_match('/\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}[\.\/-]\d{1,2}[\.\/-]\d{2,4}\b|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec|january|february|march|april|june|july|august|september|october|november|december)\b/ui', $doc['content_raw']);
        $hasTimeLike = (bool) preg_match('/\b\d{1,2}:\d{2}\b|\b(?:am|pm)\b/ui', $doc['content_raw']);
        if ($this->containsAny($title . ' ' . $content, ['event', 'webinar', 'conference', 'workshop', 'meetup', 'seminar', 'summit'])) {
            $scores['event'] += 5;
            $reasons['event'][] = __('The page uses event-like wording.', 'wp-seo-next');
        }
        if ($hasDateLike || $hasTimeLike) {
            $scores['event'] += 3;
            $reasons['event'][] = __('The content appears to contain dates or times.', 'wp-seo-next');
        }
        if ($this->containsAny($content, ['venue', 'location', 'tickets', 'register'])) {
            $scores['event'] += 2;
            $reasons['event'][] = __('The page mentions venue, tickets, or registration.', 'wp-seo-next');
        }

        $ingredientHits = $this->countMatches('/(?:^|\n)\s*[-*•]\s+/u', $doc['content_raw']);
        if ($this->containsAny($title . ' ' . $content, ['recipe', 'ingredients', 'prep time', 'cook time', 'serves'])) {
            $scores['recipe'] += 5;
            $reasons['recipe'][] = __('The page looks like a recipe or cooking guide.', 'wp-seo-next');
        }
        if ($ingredientHits >= 3 && $this->containsAny($content, ['ingredients', 'instructions'])) {
            $scores['recipe'] += 4;
            $reasons['recipe'][] = __('The content includes ingredient/instruction style lists.', 'wp-seo-next');
        }
        if (!empty($analysis['recipe_ingredients']) || !empty($analysis['recipe_instructions'])) {
            $scores['recipe'] += min(10, count($analysis['recipe_ingredients']) + count($analysis['recipe_instructions']) + 2);
            $reasons['recipe'][] = __('Recipe-like Gutenberg sections for ingredients or instructions were detected.', 'wp-seo-next');
        }

        if (!empty($analysis['event_data'])) {
            $scores['event'] += 8;
            $reasons['event'][] = __('Structured event blocks were detected in Gutenberg content.', 'wp-seo-next');
        }

        if ($this->containsAny($title . ' ' . $content, ['review', 'rating', 'verdict', 'pros', 'cons'])) {
            $scores['review'] += 5;
            $reasons['review'][] = __('The page reads like a review.', 'wp-seo-next');
        }
        if ((bool) preg_match('/\b[1-5](?:\.0)?\s*\/\s*5\b/u', $doc['content_raw'])) {
            $scores['review'] += 3;
            $reasons['review'][] = __('The page contains a rating-like value.', 'wp-seo-next');
        }
        if (!empty($analysis['review_data'])) {
            $scores['review'] += 8;
            $reasons['review'][] = __('Structured review or rating blocks were detected.', 'wp-seo-next');
        }
        if (!empty($analysis['product_data'])) {
            $scores['review'] += 3;
            $reasons['review'][] = __('Product cards, WooCommerce product blocks, or comparison blocks were detected, so review schema can point to a concrete item.', 'wp-seo-next');
        }
        if ($postType === 'product') {
            $scores['review'] += 2;
            $reasons['review'][] = __('This content is attached to a product post type, which often pairs with review markup.', 'wp-seo-next');
        }

        $comparisonItems = isset($analysis['comparison_items']) && is_array($analysis['comparison_items']) ? $analysis['comparison_items'] : [];
        if (!empty($analysis['product_data']) && $postType !== 'product') {
            $scores['product'] += 6;
            $reasons['product'][] = __('Product blocks or affiliate product data were detected on a non-product page.', 'wp-seo-next');
        }
        if (!empty($analysis['product_data']) && $this->containsAny($title . ' ' . $content, ['product', 'deal', 'coupon', 'price', 'buy', 'shop', 'best'])) {
            $scores['product'] += 3;
            $reasons['product'][] = __('The page reads like a product landing page or affiliate offer.', 'wp-seo-next');
        }
        if (count($comparisonItems) >= 2) {
            $scores['comparison'] += min(10, count($comparisonItems) + 5);
            $reasons['comparison'][] = sprintf(__('Multiple products were extracted from comparison or listing blocks (%d items).', 'wp-seo-next'), count($comparisonItems));
        }
        if ($this->containsAny($title . ' ' . $content, [' vs ', 'versus', 'comparison', 'compare', 'best ', 'top ']) && (count($comparisonItems) >= 2 || (!empty($analysis['product_data']) && !empty($analysis['block_summary']['product_blocks']) && (int) $analysis['block_summary']['product_blocks'] >= 2))) {
            $scores['comparison'] += 4;
            $reasons['comparison'][] = __('The title/content looks like a product comparison, roundup, or “best of” page.', 'wp-seo-next');
        }

        if ($postType === 'page') {
            $scores['service'] += 2;
            $reasons['service'][] = __('Pages often map well to service schema when they describe an offering.', 'wp-seo-next');
        }
        if ($this->containsAny($title . ' ' . $content, ['service', 'services', 'pricing', 'quote', 'book now', 'request a quote', 'area served'])) {
            $scores['service'] += 5;
            $reasons['service'][] = __('The page describes a service or offer.', 'wp-seo-next');
        }
        if (!empty($analysis['service_data'])) {
            $scores['service'] += 6;
            $reasons['service'][] = __('Structured service or pricing blocks were detected.', 'wp-seo-next');
        }
        if (!empty($analysis['product_data']) && $this->containsAny($title . ' ' . $content, ['pricing', 'plan', 'service', 'package', 'quote'])) {
            $scores['service'] += 2;
            $reasons['service'][] = __('Product/pricing blocks expose offer details that can also support service schema.', 'wp-seo-next');
        }

        $out = [];
        foreach ($scores as $slug => $score) {
            if ($score <= 0) {
                continue;
            }
            $out[] = [
                'slug' => $slug,
                'score' => (int) $score,
                'reasons' => array_values(array_unique($reasons[$slug])),
            ];
        }

        usort($out, static function (array $a, array $b): int {
            if ((int) $a['score'] === (int) $b['score']) {
                return strcmp((string) $a['slug'], (string) $b['slug']);
            }
            return ((int) $b['score']) <=> ((int) $a['score']);
        });

        return array_slice($out, 0, max(1, $limit));
    }

    /**
     * @return array{
     *   document: array{post_id:int,post_type:string,title:string,excerpt:string,permalink:string,content_raw:string,content_plain:string,site_name:string,author_name:string,date_published:string},
     *   faq_pairs: array<int,array{question:string,answer:string}>,
     *   faq_questions: array<int,string>,
     *   howto_steps: array<int,string>,
     *   recipe_ingredients: array<int,string>,
     *   recipe_instructions: array<int,string>,
     *   event_data: array<string,mixed>,
     *   review_data: array<string,mixed>,
     *   product_data: array<string,mixed>,
     *   comparison_items: array<int,array<string,mixed>>,
     *   service_data: array<string,mixed>,
     *   block_types: array<int,string>,
     *   block_summary: array<string,int>,
     *   block_map_matches: array<int,array{block_name:string,label:string,schema_types:array<int,string>,count:int}>,
     *   available_autofill: array<int,string>
     * }
     */
    public function analyzePost(int $postId): array
    {
        $doc = $this->buildDocument($postId);
        $empty = [
            'document' => $doc,
            'faq_pairs' => [],
            'faq_questions' => [],
            'howto_steps' => [],
            'recipe_ingredients' => [],
            'recipe_instructions' => [],
            'event_data' => [],
            'review_data' => [],
            'product_data' => [],
            'comparison_items' => [],
            'service_data' => [],
            'block_types' => [],
            'block_summary' => [
                'faq_pairs' => 0,
                'howto_steps' => 0,
                'recipe_ingredients' => 0,
                'recipe_instructions' => 0,
                'event_blocks' => 0,
                'review_blocks' => 0,
                'product_blocks' => 0,
                'comparison_products' => 0,
                'comparison_ranked_products' => 0,
                'comparison_offer_products' => 0,
                'comparison_seller_products' => 0,
                'comparison_link_products' => 0,
                'comparison_rated_products' => 0,
                'comparison_feature_products' => 0,
                'comparison_matrix_blocks' => 0,
                'service_blocks' => 0,
                'recognized_blocks' => 0,
            ],
            'block_map_matches' => [],
            'available_autofill' => [],
        ];

        if ($doc['post_id'] <= 0) {
            return $empty;
        }

        $blocks = $this->parseBlocks($doc['content_raw']);
        if (empty($blocks)) {
            return $empty;
        }

        $flat = $this->flattenBlocks($blocks);
        $blockAnalyzer = new SchemaBlockAnalyzer();
        $blockAnalysis = $blockAnalyzer->analyze($flat, $doc['content_raw']);

        $faqPairs = $this->mergeFaqPairs(
            isset($blockAnalysis['faq_pairs']) && is_array($blockAnalysis['faq_pairs']) ? $blockAnalysis['faq_pairs'] : [],
            $this->extractFaqPairsFromBlocks($flat)
        );
        $howToSteps = $this->uniqueStrings(array_merge(
            isset($blockAnalysis['howto_steps']) && is_array($blockAnalysis['howto_steps']) ? $blockAnalysis['howto_steps'] : [],
            $this->extractHowToStepsFromBlocks($flat)
        ), 12);
        $recipeIngredients = $this->uniqueStrings(array_merge(
            isset($blockAnalysis['recipe_ingredients']) && is_array($blockAnalysis['recipe_ingredients']) ? $blockAnalysis['recipe_ingredients'] : [],
            $this->extractRecipeIngredientsFromBlocks($flat)
        ), 15);
        $recipeInstructions = $this->uniqueStrings(array_merge(
            isset($blockAnalysis['recipe_instructions']) && is_array($blockAnalysis['recipe_instructions']) ? $blockAnalysis['recipe_instructions'] : [],
            $this->extractRecipeInstructionsFromBlocks($flat)
        ), 12);
        $eventData = isset($blockAnalysis['event_data']) && is_array($blockAnalysis['event_data']) ? $blockAnalysis['event_data'] : [];
        $reviewData = isset($blockAnalysis['review_data']) && is_array($blockAnalysis['review_data']) ? $blockAnalysis['review_data'] : [];
        $productData = isset($blockAnalysis['product_data']) && is_array($blockAnalysis['product_data']) ? $blockAnalysis['product_data'] : [];
        $comparisonItems = isset($blockAnalysis['comparison_items']) && is_array($blockAnalysis['comparison_items']) ? $blockAnalysis['comparison_items'] : [];
        $comparisonMeta = isset($blockAnalysis['comparison_meta']) && is_array($blockAnalysis['comparison_meta']) ? $blockAnalysis['comparison_meta'] : [];
        $serviceData = isset($blockAnalysis['service_data']) && is_array($blockAnalysis['service_data']) ? $blockAnalysis['service_data'] : [];
        $blockMapMatches = isset($blockAnalysis['block_map_matches']) && is_array($blockAnalysis['block_map_matches']) ? $blockAnalysis['block_map_matches'] : [];

        $blockTypes = [];
        foreach ($flat as $item) {
            if (!empty($item['blockName'])) {
                $blockTypes[] = (string) $item['blockName'];
            }
        }

        $available = [];
        if (!empty($faqPairs)) {
            $available[] = 'faq';
        }
        if (!empty($howToSteps)) {
            $available[] = 'howto';
        }
        if (!empty($recipeIngredients) || !empty($recipeInstructions)) {
            $available[] = 'recipe';
        }
        if (!empty($eventData) || $this->blockMapHasSchemaType($blockMapMatches, 'event')) {
            $available[] = 'event';
        }
        if (!empty($reviewData) || $this->blockMapHasSchemaType($blockMapMatches, 'review')) {
            $available[] = 'review';
        }
        if (!empty($productData) && $doc['post_type'] !== 'product') {
            $available[] = 'product';
        }
        if (count($comparisonItems) >= 2) {
            $available[] = 'comparison';
        }
        if (!empty($serviceData) || $this->blockMapHasSchemaType($blockMapMatches, 'service')) {
            $available[] = 'service';
        }

        return [
            'document' => $doc,
            'faq_pairs' => $faqPairs,
            'faq_questions' => array_values(array_map(static function (array $pair): string {
                return (string) ($pair['question'] ?? '');
            }, $faqPairs)),
            'howto_steps' => $howToSteps,
            'recipe_ingredients' => $recipeIngredients,
            'recipe_instructions' => $recipeInstructions,
            'event_data' => $eventData,
            'review_data' => $reviewData,
            'product_data' => $productData,
            'comparison_items' => $comparisonItems,
            'comparison_meta' => $comparisonMeta,
            'service_data' => $serviceData,
            'block_types' => array_values(array_unique($blockTypes)),
            'block_summary' => [
                'faq_pairs' => count($faqPairs),
                'howto_steps' => count($howToSteps),
                'recipe_ingredients' => count($recipeIngredients),
                'recipe_instructions' => count($recipeInstructions),
                'event_blocks' => $this->countBlockMapSchemaType($blockMapMatches, 'event'),
                'review_blocks' => $this->countBlockMapSchemaType($blockMapMatches, 'review'),
                'product_blocks' => $this->countBlockMapSchemaType($blockMapMatches, 'product'),
                'comparison_products' => count($comparisonItems),
                'comparison_ranked_products' => isset($comparisonMeta['ranked_items']) ? (int) $comparisonMeta['ranked_items'] : 0,
                'comparison_offer_products' => isset($comparisonMeta['items_with_price']) ? (int) $comparisonMeta['items_with_price'] : 0,
                'comparison_seller_products' => isset($comparisonMeta['items_with_seller']) ? (int) $comparisonMeta['items_with_seller'] : 0,
                'comparison_link_products' => isset($comparisonMeta['items_with_links']) ? (int) $comparisonMeta['items_with_links'] : 0,
                'comparison_rated_products' => isset($comparisonMeta['items_with_rating']) ? (int) $comparisonMeta['items_with_rating'] : 0,
                'comparison_feature_products' => isset($comparisonMeta['items_with_feature_summary']) ? (int) $comparisonMeta['items_with_feature_summary'] : 0,
                'comparison_matrix_blocks' => isset($comparisonMeta['matrix_table_blocks']) ? (int) $comparisonMeta['matrix_table_blocks'] : 0,
                'comparison_generic_html_tables' => isset($comparisonMeta['generic_html_tables']) ? (int) $comparisonMeta['generic_html_tables'] : 0,
                'comparison_vendor_table_layouts' => isset($comparisonMeta['vendor_table_layouts']) ? (int) $comparisonMeta['vendor_table_layouts'] : 0,
                'comparison_vendor_widget_layouts' => isset($comparisonMeta['vendor_widget_layouts']) ? (int) $comparisonMeta['vendor_widget_layouts'] : 0,
                'comparison_vendor_shortcodes' => isset($comparisonMeta['vendor_shortcodes']) ? (int) $comparisonMeta['vendor_shortcodes'] : 0,
                'comparison_vendor_count' => isset($comparisonMeta['vendor_count']) ? (int) $comparisonMeta['vendor_count'] : 0,
                'comparison_vendor_labels' => isset($comparisonMeta['vendors_detected']) && is_array($comparisonMeta['vendors_detected']) ? array_values(array_map('strval', $comparisonMeta['vendors_detected'])) : [],
                'service_blocks' => $this->countBlockMapSchemaType($blockMapMatches, 'service'),
                'recognized_blocks' => count($blockMapMatches),
            ],
            'block_map_matches' => $blockMapMatches,
            'available_autofill' => array_values(array_unique($available)),
        ];
    }

    public function buildMappedTemplate(int $postId, string $presetSlug): string
    {
        $presetSlug = sanitize_key($presetSlug);
        $analysis = $this->analyzePost($postId);
        $doc = $analysis['document'];
        if ($doc['post_id'] <= 0) {
            return '';
        }

        switch ($presetSlug) {
            case 'faq':
                return $this->prettyJson($this->buildFaqNode($doc, $analysis));
            case 'howto':
                return $this->prettyJson($this->buildHowToNode($doc, $analysis));
            case 'event':
                return $this->prettyJson($this->buildEventNode($doc, $analysis));
            case 'recipe':
                return $this->prettyJson($this->buildRecipeNode($doc, $analysis));
            case 'review':
                return $this->prettyJson($this->buildReviewNode($doc, $analysis));
            case 'product':
                return $this->prettyJson($this->buildProductNode($doc, $analysis));
            case 'comparison':
                return $this->prettyJson($this->buildComparisonNode($doc, $analysis));
            case 'service':
                return $this->prettyJson($this->buildServiceNode($doc, $analysis));
        }

        $presets = SchemaTypeLibrary::presets();
        return isset($presets[$presetSlug]['template']) ? (string) $presets[$presetSlug]['template'] : '';
    }

    /**
     * @return array{post_id:int,post_type:string,title:string,excerpt:string,permalink:string,content_raw:string,content_plain:string,site_name:string,author_name:string,date_published:string}
     */
    private function buildDocument(int $postId): array
    {
        $post = get_post($postId);
        if (!$post instanceof \WP_Post) {
            return [
                'post_id' => 0,
                'post_type' => '',
                'title' => '',
                'excerpt' => '',
                'permalink' => '',
                'content_raw' => '',
                'content_plain' => '',
                'site_name' => '',
                'author_name' => '',
                'date_published' => '',
            ];
        }

        $contentRaw = isset($post->post_content) && is_string($post->post_content) ? (string) $post->post_content : '';
        $contentRaw = str_replace(["\r\n", "\r"], "\n", $contentRaw);

        $excerpt = '';
        if (isset($post->post_excerpt) && is_string($post->post_excerpt) && trim($post->post_excerpt) !== '') {
            $excerpt = trim((string) $post->post_excerpt);
        } else {
            $excerpt = trim((string) get_the_excerpt($postId));
        }

        $authorName = '';
        if (isset($post->post_author) && (int) $post->post_author > 0) {
            $authorName = (string) get_the_author_meta('display_name', (int) $post->post_author);
        }

        $title = trim((string) get_the_title($postId));
        $permalink = (string) get_permalink($post);
        $postType = (string) get_post_type($postId);

        return [
            'post_id' => $postId,
            'post_type' => $postType,
            'title' => $title,
            'excerpt' => $excerpt,
            'permalink' => $permalink,
            'content_raw' => $contentRaw,
            'content_plain' => $this->plainText($contentRaw),
            'site_name' => (string) get_bloginfo('name'),
            'author_name' => $authorName,
            'date_published' => (string) get_the_date('c', $post),
        ];
    }

    /**
     * @param array{post_id:int,title:string,permalink:string,excerpt:string,content_raw:string,content_plain:string} $doc
     * @param array<string,mixed> $analysis
     * @return array<string,mixed>
     */
    private function buildFaqNode(array $doc, array $analysis): array
    {
        $pairs = [];
        if (!empty($analysis['faq_pairs']) && is_array($analysis['faq_pairs'])) {
            $pairs = $analysis['faq_pairs'];
        }

        if (empty($pairs)) {
            $questions = $this->extractFaqQuestions($doc['content_raw'], $doc['content_plain']);
            foreach ($questions as $question) {
                $pairs[] = [
                    'question' => $question,
                    'answer' => __('Add the answer here.', 'wp-seo-next'),
                ];
            }
        }

        if (empty($pairs)) {
            $pairs = [
                [
                    'question' => __('Add your first question here', 'wp-seo-next'),
                    'answer' => __('Add the answer here.', 'wp-seo-next'),
                ],
                [
                    'question' => __('Add your second question here', 'wp-seo-next'),
                    'answer' => __('Add the answer here.', 'wp-seo-next'),
                ],
            ];
        }

        $entities = [];
        foreach (array_slice($pairs, 0, 6) as $pair) {
            $question = isset($pair['question']) ? trim((string) $pair['question']) : '';
            $answer = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
            if ($question === '') {
                continue;
            }
            if ($answer === '') {
                $answer = __('Add the answer here.', 'wp-seo-next');
            }
            $entities[] = [
                '@type' => 'Question',
                'name' => $question,
                'acceptedAnswer' => [
                    '@type' => 'Answer',
                    'text' => $answer,
                ],
            ];
        }

        return [
            '@type' => 'FAQPage',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#faq' : '',
            'mainEntity' => $entities,
        ];
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string,content_raw:string} $doc
     * @param array<string,mixed> $analysis
     * @return array<string,mixed>
     */
    private function buildHowToNode(array $doc, array $analysis): array
    {
        $steps = [];
        if (!empty($analysis['howto_steps']) && is_array($analysis['howto_steps'])) {
            $steps = $analysis['howto_steps'];
        }
        if (empty($steps)) {
            $steps = $this->extractHowToSteps($doc['content_raw']);
        }
        if (empty($steps)) {
            $steps = [
                __('Describe the first step here.', 'wp-seo-next'),
                __('Describe the second step here.', 'wp-seo-next'),
            ];
        }

        $stepNodes = [];
        foreach (array_slice($steps, 0, 12) as $index => $step) {
            $stepNodes[] = [
                '@type' => 'HowToStep',
                'position' => $index + 1,
                'name' => sprintf(__('Step %d', 'wp-seo-next'), $index + 1),
                'text' => $step,
            ];
        }

        return [
            '@type' => 'HowTo',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#howto' : '',
            'name' => $doc['title'],
            'description' => $this->fallbackText($doc['excerpt'], __('Step-by-step guide.', 'wp-seo-next')),
            'step' => $stepNodes,
        ];
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string,content_plain:string} $doc
     * @return array<string,mixed>
     */
    private function buildEventNode(array $doc, array $analysis = []): array
    {
        $eventData = isset($analysis['event_data']) && is_array($analysis['event_data']) ? $analysis['event_data'] : [];
        $startDate = isset($eventData['start_date']) ? trim((string) $eventData['start_date']) : $this->extractDateTime($doc['content_plain']);
        $endDate = isset($eventData['end_date']) ? trim((string) $eventData['end_date']) : '';
        $location = isset($eventData['location']) ? trim((string) $eventData['location']) : '';
        $attendanceMode = isset($eventData['attendance_mode']) ? trim((string) $eventData['attendance_mode']) : '';

        $node = [
            '@type' => 'Event',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#event' : '',
            'name' => isset($eventData['name']) && trim((string) $eventData['name']) !== '' ? trim((string) $eventData['name']) : $doc['title'],
            'description' => $this->fallbackText(isset($eventData['description']) ? trim((string) $eventData['description']) : $doc['excerpt'], __('Event details.', 'wp-seo-next')),
        ];

        if ($startDate !== '') {
            $node['startDate'] = $startDate;
        }
        if ($endDate !== '') {
            $node['endDate'] = $endDate;
        }
        if ($location !== '') {
            $node['location'] = [
                '@type' => 'Place',
                'name' => $location,
            ];
        }
        if ($attendanceMode !== '') {
            $node['eventAttendanceMode'] = $attendanceMode;
        }

        return $node;
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string,content_raw:string,author_name:string,date_published:string} $doc
     * @param array<string,mixed> $analysis
     * @return array<string,mixed>
     */
    private function buildRecipeNode(array $doc, array $analysis): array
    {
        $ingredients = [];
        if (!empty($analysis['recipe_ingredients']) && is_array($analysis['recipe_ingredients'])) {
            $ingredients = $analysis['recipe_ingredients'];
        }
        $instructions = [];
        if (!empty($analysis['recipe_instructions']) && is_array($analysis['recipe_instructions'])) {
            $instructions = $analysis['recipe_instructions'];
        }

        if (empty($ingredients)) {
            $ingredients = $this->extractRecipeIngredients($doc['content_raw']);
        }
        if (empty($instructions)) {
            $instructions = $this->extractRecipeInstructions($doc['content_raw']);
        }

        if (empty($ingredients)) {
            $ingredients = [
                __('1 ingredient', 'wp-seo-next'),
                __('2 ingredient', 'wp-seo-next'),
                __('3 ingredient', 'wp-seo-next'),
            ];
        }
        if (empty($instructions)) {
            $instructions = [
                __('Describe the first cooking step here.', 'wp-seo-next'),
                __('Describe the second cooking step here.', 'wp-seo-next'),
            ];
        }

        $instructionNodes = [];
        foreach (array_slice($instructions, 0, 8) as $instruction) {
            $instructionNodes[] = [
                '@type' => 'HowToStep',
                'text' => $instruction,
            ];
        }

        $node = [
            '@type' => 'Recipe',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#recipe' : '',
            'name' => $doc['title'],
            'description' => $this->fallbackText($doc['excerpt'], __('Recipe details.', 'wp-seo-next')),
            'recipeIngredient' => array_slice($ingredients, 0, 15),
            'recipeInstructions' => $instructionNodes,
        ];

        if ($doc['author_name'] !== '') {
            $node['author'] = [
                '@type' => 'Person',
                'name' => $doc['author_name'],
            ];
        }
        if ($doc['date_published'] !== '') {
            $node['datePublished'] = $doc['date_published'];
        }

        return $node;
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string,author_name:string,post_type:string} $doc
     * @return array<string,mixed>
     */
    private function buildReviewNode(array $doc, array $analysis = []): array
    {
        $reviewData = isset($analysis['review_data']) && is_array($analysis['review_data']) ? $analysis['review_data'] : [];
        $productData = isset($analysis['product_data']) && is_array($analysis['product_data']) ? $analysis['product_data'] : [];
        $comparisonItems = isset($analysis['comparison_items']) && is_array($analysis['comparison_items']) ? $analysis['comparison_items'] : [];
        $serviceData = isset($analysis['service_data']) && is_array($analysis['service_data']) ? $analysis['service_data'] : [];
        if (empty($productData) && !empty($comparisonItems[0]) && is_array($comparisonItems[0])) {
            $productData = $comparisonItems[0];
        }

        $rating = isset($reviewData['rating']) ? trim((string) $reviewData['rating']) : '5';
        $bestRating = isset($reviewData['best_rating']) ? trim((string) $reviewData['best_rating']) : '5';
        $worstRating = isset($reviewData['worst_rating']) ? trim((string) $reviewData['worst_rating']) : '1';

        $itemType = 'Thing';
        if ($doc['post_type'] === 'product' || !empty($productData)) {
            $itemType = 'Product';
        } elseif (!empty($serviceData)) {
            $itemType = 'Service';
        }

        $itemName = isset($reviewData['item_name']) && trim((string) $reviewData['item_name']) !== ''
            ? trim((string) $reviewData['item_name'])
            : (isset($productData['name']) && trim((string) $productData['name']) !== ''
                ? trim((string) $productData['name'])
                : (isset($serviceData['name']) && trim((string) $serviceData['name']) !== '' ? trim((string) $serviceData['name']) : $doc['title']));

        $itemReviewed = [
            '@type' => $itemType,
            'name' => $itemName,
        ];
        if ($itemType === 'Product') {
            $itemReviewed = $this->buildProductLikeNode($productData, $doc, $doc['post_type'] === 'product' ? '#product' : '#reviewed-product');
            if (empty($itemReviewed['name'])) {
                $itemReviewed['name'] = $itemName;
            }
        } elseif ($itemType === 'Service') {
            $itemReviewed['@id'] = $doc['permalink'] !== '' ? $doc['permalink'] . '#service' : '';
            if (!empty($serviceData['provider'])) {
                $itemReviewed['provider'] = [
                    '@type' => 'Organization',
                    'name' => (string) $serviceData['provider'],
                ];
            }
        }

        $node = [
            '@type' => 'Review',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#review' : '',
            'name' => $doc['title'],
            'reviewBody' => $this->fallbackText(isset($reviewData['review_body']) ? trim((string) $reviewData['review_body']) : $doc['excerpt'], __('Add the visible review text here.', 'wp-seo-next')),
            'itemReviewed' => $itemReviewed,
            'reviewRating' => [
                '@type' => 'Rating',
                'ratingValue' => $rating !== '' ? $rating : '5',
                'bestRating' => $bestRating !== '' ? $bestRating : '5',
                'worstRating' => $worstRating !== '' ? $worstRating : '1',
            ],
        ];

        if ($doc['author_name'] !== '') {
            $node['author'] = [
                '@type' => 'Person',
                'name' => $doc['author_name'],
            ];
        }

        if (!empty($reviewData['reviewer']) && $doc['author_name'] === '') {
            $node['author'] = [
                '@type' => 'Person',
                'name' => (string) $reviewData['reviewer'],
            ];
        }

        if (!empty($reviewData['pros']) && is_array($reviewData['pros'])) {
            $node['positiveNotes'] = $this->buildItemListOfStrings(array_map('strval', $reviewData['pros']));
        }
        if (!empty($reviewData['cons']) && is_array($reviewData['cons'])) {
            $node['negativeNotes'] = $this->buildItemListOfStrings(array_map('strval', $reviewData['cons']));
        }

        return $node;
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string,post_type:string} $doc
     * @return array<string,mixed>
     */
    private function buildProductNode(array $doc, array $analysis = []): array
    {
        $productData = isset($analysis['product_data']) && is_array($analysis['product_data']) ? $analysis['product_data'] : [];
        $comparisonItems = isset($analysis['comparison_items']) && is_array($analysis['comparison_items']) ? $analysis['comparison_items'] : [];
        if (empty($productData) && !empty($comparisonItems[0]) && is_array($comparisonItems[0])) {
            $productData = $comparisonItems[0];
        }

        $node = $this->buildProductLikeNode($productData, $doc, '#affiliate-product');
        if (empty($node['name'])) {
            $node['name'] = $doc['title'];
        }
        if (empty($node['description'])) {
            $node['description'] = $this->fallbackText($doc['excerpt'], __('Product details.', 'wp-seo-next'));
        }

        $reviewData = isset($analysis['review_data']) && is_array($analysis['review_data']) ? $analysis['review_data'] : [];
        if (!empty($reviewData['rating'])) {
            $aggregateRating = [
                '@type' => 'AggregateRating',
                'ratingValue' => (string) $reviewData['rating'],
            ];
            if (!empty($reviewData['best_rating'])) {
                $aggregateRating['bestRating'] = (string) $reviewData['best_rating'];
            }
            if (!empty($reviewData['worst_rating'])) {
                $aggregateRating['worstRating'] = (string) $reviewData['worst_rating'];
            }
            if (!empty($reviewData['review_count'])) {
                $aggregateRating['reviewCount'] = (string) $reviewData['review_count'];
            } elseif (!empty($productData['review_count'])) {
                $aggregateRating['reviewCount'] = (string) $productData['review_count'];
            }
            $node['aggregateRating'] = $aggregateRating;
        }

        return $node;
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string} $doc
     * @return array<string,mixed>
     */
    private function buildComparisonNode(array $doc, array $analysis = []): array
    {
        $comparisonItems = isset($analysis['comparison_items']) && is_array($analysis['comparison_items']) ? $analysis['comparison_items'] : [];
        $comparisonMeta = isset($analysis['comparison_meta']) && is_array($analysis['comparison_meta']) ? $analysis['comparison_meta'] : [];
        if (empty($comparisonItems)) {
            $productData = isset($analysis['product_data']) && is_array($analysis['product_data']) ? $analysis['product_data'] : [];
            if (!empty($productData)) {
                $comparisonItems[] = $productData;
            }
        }

        $comparisonItems = $this->sortComparisonItems($comparisonItems);
        $elements = [];
        $displayPosition = 1;
        foreach (array_slice($comparisonItems, 0, 10) as $index => $item) {
            if (!is_array($item)) {
                continue;
            }
            $listPosition = !empty($item['position']) ? max(1, (int) $item['position']) : $displayPosition;
            $productNode = $this->buildProductLikeNode($item, $doc, '#comparison-product-' . $listPosition);
            if (empty($productNode['name'])) {
                continue;
            }
            $listItem = [
                '@type' => 'ListItem',
                'position' => $listPosition,
                'name' => (string) $productNode['name'],
                'item' => $productNode,
            ];
            if (!empty($productNode['url'])) {
                $listItem['url'] = (string) $productNode['url'];
            }
            if (!empty($item['rank_label'])) {
                $listItem['description'] = (string) $item['rank_label'];
            }
            $elements[] = $listItem;
            $displayPosition++;
        }

        $node = [
            '@type' => 'ItemList',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#comparison' : '',
            'name' => $doc['title'],
            'description' => $this->fallbackText($doc['excerpt'], __('Product comparison.', 'wp-seo-next')),
            'numberOfItems' => count($elements),
            'itemListElement' => $elements,
        ];

        if (!empty($comparisonMeta['item_list_order'])) {
            $node['itemListOrder'] = (string) $comparisonMeta['item_list_order'];
        }

        return $node;
    }

    /**
     * @param array<string,mixed> $productData
     * @param array{title:string,permalink:string,excerpt:string,post_type?:string} $doc
     * @return array<string,mixed>
     */
    private function buildProductLikeNode(array $productData, array $doc, string $idSuffix): array
    {
        $name = isset($productData['name']) && trim((string) $productData['name']) !== '' ? trim((string) $productData['name']) : $doc['title'];
        $url = isset($productData['url']) && trim((string) $productData['url']) !== '' ? trim((string) $productData['url']) : $doc['permalink'];

        $node = [
            '@type' => 'Product',
            '@id' => $url !== '' && strpos($idSuffix, '#') === 0 && $url === $doc['permalink'] ? $url . $idSuffix : ($doc['permalink'] !== '' && strpos($idSuffix, '#') === 0 ? $doc['permalink'] . $idSuffix : ''),
            'name' => $name,
            'description' => isset($productData['description']) && trim((string) $productData['description']) !== '' ? trim((string) $productData['description']) : $this->fallbackText($doc['excerpt'], __('Product details.', 'wp-seo-next')),
            'url' => $url,
        ];

        if (!empty($productData['brand'])) {
            $node['brand'] = [
                '@type' => 'Brand',
                'name' => (string) $productData['brand'],
            ];
        }
        if (!empty($productData['sku'])) {
            $node['sku'] = (string) $productData['sku'];
        }
        if (!empty($productData['image'])) {
            $node['image'] = [(string) $productData['image']];
        }
        if (!empty($productData['rank_label'])) {
            $node['award'] = (string) $productData['rank_label'];
        }
        if (!empty($productData['feature_summary']) && is_array($productData['feature_summary'])) {
            $node['additionalProperty'] = $this->buildAdditionalPropertyNodes(array_map('strval', $productData['feature_summary']));
        }

        $offer = $this->buildOfferNode($productData, $url);
        if (!empty($offer)) {
            $node['offers'] = $offer;
        }

        if (!empty($productData['rating'])) {
            $aggregateRating = [
                '@type' => 'AggregateRating',
                'ratingValue' => (string) $productData['rating'],
            ];
            if (!empty($productData['review_count'])) {
                $aggregateRating['reviewCount'] = (string) $productData['review_count'];
            }
            $node['aggregateRating'] = $aggregateRating;
        }

        return array_filter($node, static function ($value): bool {
            if (is_array($value)) {
                return !empty($value);
            }
            return $value !== '' && $value !== null;
        });
    }

    /**
     * @param array<string,mixed> $productData
     * @return array<string,mixed>
     */
    private function buildOfferNode(array $productData, string $fallbackUrl): array
    {
        $price = isset($productData['price']) ? trim((string) $productData['price']) : '';
        $lowPrice = isset($productData['low_price']) ? trim((string) $productData['low_price']) : '';
        $highPrice = isset($productData['high_price']) ? trim((string) $productData['high_price']) : '';
        $offerCount = isset($productData['offer_count']) ? trim((string) $productData['offer_count']) : '';
        $currency = isset($productData['currency']) ? trim((string) $productData['currency']) : '';
        $url = isset($productData['url']) ? trim((string) $productData['url']) : $fallbackUrl;
        $availability = isset($productData['availability']) ? trim((string) $productData['availability']) : '';
        $seller = isset($productData['seller']) ? trim((string) $productData['seller']) : '';
        $priceValidUntil = isset($productData['price_valid_until']) ? trim((string) $productData['price_valid_until']) : '';
        $condition = isset($productData['condition']) ? trim((string) $productData['condition']) : '';

        if ($price === '' && $lowPrice === '' && $highPrice === '' && $currency === '' && $url === '' && $seller === '') {
            return [];
        }

        $isAggregate = $offerCount !== '' || ($lowPrice !== '' || $highPrice !== '') && ($lowPrice !== $highPrice || $lowPrice !== '');
        $offer = [
            '@type' => $isAggregate ? 'AggregateOffer' : 'Offer',
        ];

        if ($isAggregate) {
            if ($lowPrice === '' && $price !== '') {
                $lowPrice = $price;
            }
            if ($highPrice === '' && $price !== '') {
                $highPrice = $price;
            }
            if ($lowPrice !== '') {
                $offer['lowPrice'] = $lowPrice;
            }
            if ($highPrice !== '') {
                $offer['highPrice'] = $highPrice;
            }
            if ($offerCount !== '') {
                $offer['offerCount'] = $offerCount;
            }
        } elseif ($price !== '') {
            $offer['price'] = $price;
        }

        if ($currency !== '') {
            $offer['priceCurrency'] = $currency;
        }
        if ($url !== '') {
            $offer['url'] = $url;
        }
        if ($availability !== '') {
            $offer['availability'] = str_starts_with($availability, 'http') ? $availability : 'https://schema.org/' . ltrim($availability, '/');
        }
        if ($condition !== '') {
            $offer['itemCondition'] = str_starts_with($condition, 'http') ? $condition : 'https://schema.org/' . ltrim($condition, '/');
        }
        if ($priceValidUntil !== '') {
            $offer['priceValidUntil'] = $priceValidUntil;
        }
        if ($seller !== '') {
            $offer['seller'] = [
                '@type' => 'Organization',
                'name' => $seller,
            ];
        }

        return $offer;
    }

    /**
     * @param array<int,string> $items
     * @return array<string,mixed>
     */
    private function buildItemListOfStrings(array $items): array
    {
        $list = [];
        foreach (array_slice(array_values(array_filter(array_map('trim', $items))), 0, 8) as $index => $item) {
            if ($item === '') {
                continue;
            }
            $list[] = [
                '@type' => 'ListItem',
                'position' => $index + 1,
                'name' => $item,
            ];
        }

        return [
            '@type' => 'ItemList',
            'itemListElement' => $list,
        ];
    }


    /**
     * @param array<int,string> $values
     * @return array<int,array<string,mixed>>
     */
    private function buildAdditionalPropertyNodes(array $values): array
    {
        $properties = [];
        foreach (array_slice(array_values(array_filter(array_map('trim', $values))), 0, 6) as $index => $value) {
            if ($value === '') {
                continue;
            }
            $properties[] = [
                '@type' => 'PropertyValue',
                'name' => sprintf(__('Comparison highlight %d', 'wp-seo-next'), $index + 1),
                'value' => $value,
            ];
        }
        return $properties;
    }

    /**
     * @param array<int,array<string,mixed>> $items
     * @return array<int,array<string,mixed>>
     */
    private function sortComparisonItems(array $items): array
    {
        $indexed = [];
        foreach (array_values($items) as $index => $item) {
            if (!is_array($item)) {
                continue;
            }
            $item['__input_order'] = $index;
            $indexed[] = $item;
        }

        usort($indexed, static function (array $a, array $b): int {
            $aPos = isset($a['position']) && trim((string) $a['position']) !== '' ? (int) $a['position'] : PHP_INT_MAX;
            $bPos = isset($b['position']) && trim((string) $b['position']) !== '' ? (int) $b['position'] : PHP_INT_MAX;
            if ($aPos === $bPos) {
                return ((int) ($a['__input_order'] ?? 0)) <=> ((int) ($b['__input_order'] ?? 0));
            }
            return $aPos <=> $bPos;
        });

        foreach ($indexed as &$item) {
            unset($item['__input_order']);
        }
        unset($item);

        return array_values($indexed);
    }

    /**
     * @param array{title:string,permalink:string,excerpt:string,site_name:string} $doc
     * @return array<string,mixed>
     */
    private function buildServiceNode(array $doc, array $analysis = []): array
    {
        $serviceData = isset($analysis['service_data']) && is_array($analysis['service_data']) ? $analysis['service_data'] : [];
        $productData = isset($analysis['product_data']) && is_array($analysis['product_data']) ? $analysis['product_data'] : [];
        $node = [
            '@type' => 'Service',
            '@id' => $doc['permalink'] !== '' ? $doc['permalink'] . '#service' : '',
            'name' => isset($serviceData['name']) && trim((string) $serviceData['name']) !== '' ? trim((string) $serviceData['name']) : $doc['title'],
            'description' => $this->fallbackText(isset($serviceData['description']) ? trim((string) $serviceData['description']) : $doc['excerpt'], __('Service details.', 'wp-seo-next')),
            'url' => $doc['permalink'],
            'provider' => [
                '@type' => 'Organization',
                'name' => $this->fallbackText(isset($serviceData['provider']) ? trim((string) $serviceData['provider']) : $doc['site_name'], __('Your business name', 'wp-seo-next')),
            ],
        ];

        $price = isset($serviceData['price']) ? trim((string) $serviceData['price']) : '';
        if ($price === '' && isset($productData['price'])) {
            $price = trim((string) $productData['price']);
        }
        $currency = isset($serviceData['currency']) ? trim((string) $serviceData['currency']) : '';
        if ($currency === '' && isset($productData['currency'])) {
            $currency = trim((string) $productData['currency']);
        }
        if ($price !== '') {
            $offer = [
                '@type' => 'Offer',
                'price' => $price,
            ];
            if ($currency !== '') {
                $offer['priceCurrency'] = $currency;
            }
            if (!empty($productData['url'])) {
                $offer['url'] = (string) $productData['url'];
            } elseif ($doc['permalink'] !== '') {
                $offer['url'] = $doc['permalink'];
            }
            $node['offers'] = $offer;
        }

        if (!empty($serviceData['area_served']) && is_array($serviceData['area_served'])) {
            $node['areaServed'] = array_values(array_map(static function (string $value): array {
                return [
                    '@type' => 'Place',
                    'name' => $value,
                ];
            }, array_slice(array_filter(array_map('strval', $serviceData['area_served'])), 0, 8)));
        }

        return $node;
    }


    /**
     * @param array<int,array{question:string,answer:string}> ...$groups
     * @return array<int,array{question:string,answer:string}>
     */
    private function mergeFaqPairs(array ...$groups): array
    {
        $merged = [];
        $seen = [];
        foreach ($groups as $group) {
            foreach ($group as $pair) {
                $question = isset($pair['question']) ? trim((string) $pair['question']) : '';
                $answer = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
                if ($question === '' || $answer === '') {
                    continue;
                }
                $key = $this->lower($question . '|' . $answer);
                if (isset($seen[$key])) {
                    continue;
                }
                $seen[$key] = true;
                $merged[] = ['question' => $question, 'answer' => $answer];
                if (count($merged) >= 10) {
                    break 2;
                }
            }
        }
        return $merged;
    }

    /**
     * @param array<int,array{block_name:string,label:string,schema_types:array<int,string>,count:int}> $matches
     */
    private function blockMapHasSchemaType(array $matches, string $slug): bool
    {
        foreach ($matches as $match) {
            if (!is_array($match) || empty($match['schema_types']) || !is_array($match['schema_types'])) {
                continue;
            }
            if (in_array($slug, array_map('strval', $match['schema_types']), true)) {
                return true;
            }
        }
        return false;
    }

    /**
     * @param array<int,array{block_name:string,label:string,schema_types:array<int,string>,count:int}> $matches
     */
    private function countBlockMapSchemaType(array $matches, string $slug): int
    {
        $count = 0;
        foreach ($matches as $match) {
            if (!is_array($match) || empty($match['schema_types']) || !is_array($match['schema_types'])) {
                continue;
            }
            if (in_array($slug, array_map('strval', $match['schema_types']), true)) {
                $count += isset($match['count']) ? (int) $match['count'] : 1;
            }
        }
        return $count;
    }

    private function fallbackText(string $text, string $fallback): string
    {
        $text = trim($text);
        return $text !== '' ? $text : $fallback;
    }

    /**
     * @return array<int,string>
     */
    private function extractFaqQuestions(string $raw, string $plain): array
    {
        $questions = [];

        if (preg_match_all('/(?:^|\n)\s*(?:Q[:\-]\s*)?([^\n\r?]{8,180}\?)/u', $raw, $matches)) {
            foreach ((array) ($matches[1] ?? []) as $question) {
                $question = $this->cleanLine((string) $question);
                if ($question !== '') {
                    $questions[] = $question;
                }
            }
        }

        if (empty($questions) && preg_match_all('/([^.!?]{8,180}\?)/u', $plain, $matches)) {
            foreach ((array) ($matches[1] ?? []) as $question) {
                $question = $this->cleanLine((string) $question);
                if ($question !== '') {
                    $questions[] = $question;
                }
            }
        }

        return $this->uniqueStrings($questions, 5);
    }

    /**
     * @return array<int,string>
     */
    private function extractHowToSteps(string $raw): array
    {
        $steps = [];
        if (preg_match_all('/(?:^|\n)\s*(?:step\s+\d+|\d+[\).])\s+(.+)/ui', $raw, $matches)) {
            foreach ((array) ($matches[1] ?? []) as $step) {
                $step = $this->cleanLine((string) $step);
                if ($step !== '') {
                    $steps[] = $step;
                }
            }
        }

        return $this->uniqueStrings($steps, 8);
    }

    /**
     * @return array<int,string>
     */
    private function extractRecipeIngredients(string $raw): array
    {
        return $this->extractSectionList($raw, ['ingredients'], ['instructions', 'method', 'steps', 'directions']);
    }

    /**
     * @return array<int,string>
     */
    private function extractRecipeInstructions(string $raw): array
    {
        $steps = $this->extractSectionList($raw, ['instructions', 'method', 'directions'], ['notes', 'nutrition']);
        if (!empty($steps)) {
            return $steps;
        }

        return $this->extractHowToSteps($raw);
    }

    /**
     * @param array<int,string> $headings
     * @param array<int,string> $stopHeadings
     * @return array<int,string>
     */
    private function extractSectionList(string $raw, array $headings, array $stopHeadings): array
    {
        $lines = preg_split('/\n+/', $raw) ?: [];
        $collect = false;
        $items = [];

        foreach ($lines as $line) {
            $line = trim((string) $line);
            if ($line === '') {
                if ($collect && !empty($items)) {
                    break;
                }
                continue;
            }

            $normalized = $this->lower($this->cleanLine($line));
            foreach ($headings as $heading) {
                if ($normalized === $heading || $normalized === $heading . ':') {
                    $collect = true;
                    continue 2;
                }
            }
            foreach ($stopHeadings as $heading) {
                if ($collect && ($normalized === $heading || $normalized === $heading . ':')) {
                    $collect = false;
                    break 2;
                }
            }

            if (!$collect) {
                continue;
            }

            if (preg_match('/^(?:[-*•]|\d+[\).])\s*(.+)$/u', $line, $m)) {
                $item = $this->cleanLine((string) ($m[1] ?? ''));
                if ($item !== '') {
                    $items[] = $item;
                }
                continue;
            }

            $maybe = $this->cleanLine($line);
            if ($maybe !== '') {
                $items[] = $maybe;
            }
        }

        return $this->uniqueStrings($items, 15);
    }

    private function extractDateTime(string $text): string
    {
        if (preg_match('/\b(20\d{2}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)?)\b/', $text, $m)) {
            return trim((string) ($m[1] ?? ''));
        }
        return '';
    }

    /**
     * @param array<mixed> $blocks
     * @return array<int,array<string,mixed>>
     */
    private function parseBlocks(string $raw): array
    {
        $raw = trim($raw);
        if ($raw === '') {
            return [];
        }

        if (!function_exists('parse_blocks')) {
            return $this->fallbackClassicContentBlocks($raw);
        }

        $parsed = parse_blocks($raw);
        if (is_array($parsed) && !empty($parsed)) {
            return $parsed;
        }

        return $this->fallbackClassicContentBlocks($raw);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function fallbackClassicContentBlocks(string $raw): array
    {
        $raw = trim($raw);
        if ($raw === '') {
            return [];
        }

        return [[
            'blockName' => 'core/freeform',
            'attrs' => [],
            'innerHTML' => $raw,
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $blocks
     * @return array<int,array<string,mixed>>
     */
    private function flattenBlocks(array $blocks): array
    {
        $flat = [];
        foreach ($blocks as $block) {
            if (!is_array($block)) {
                continue;
            }
            $flat[] = $block;
            if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
                $flat = array_merge($flat, $this->flattenBlocks($block['innerBlocks']));
            }
        }
        return $flat;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,array{question:string,answer:string}>
     */
    private function extractFaqPairsFromBlocks(array $flat): array
    {
        $pairs = [];

        foreach ($flat as $block) {
            $name = isset($block['blockName']) ? sanitize_key((string) $block['blockName']) : '';
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $innerHtml = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';

            if ($name === 'core/details') {
                $question = $this->cleanLine((string) ($attrs['summary'] ?? $attrs['title'] ?? ''));
                $answer = $this->cleanLine($this->blockText($block));
                if ($question !== '' && $answer !== '') {
                    $pairs[] = ['question' => $question, 'answer' => $answer];
                }
            }

            if ($name !== '' && ($this->containsText($name, 'faq') || $this->containsText($name, 'accordion'))) {
                foreach (['questions', 'items', 'faqs', 'rows', 'entries'] as $itemsKey) {
                    $items = $attrs[$itemsKey] ?? null;
                    if (!is_array($items)) {
                        continue;
                    }
                    foreach ($items as $item) {
                        if (!is_array($item)) {
                            continue;
                        }
                        $question = $this->firstNonEmpty($item, ['question', 'title', 'q', 'name', 'label']);
                        $answer = $this->firstNonEmpty($item, ['answer', 'content', 'a', 'text', 'description']);
                        $question = $this->cleanLine($question);
                        $answer = $this->cleanLine($this->plainText($answer));
                        if ($question !== '' && $answer !== '') {
                            $pairs[] = ['question' => $question, 'answer' => $answer];
                        }
                    }
                }

                foreach ($this->extractFaqPairsFromHtml($innerHtml) as $pair) {
                    $pairs[] = $pair;
                }
            }
        }

        // Heading+paragraph heuristic for plain Gutenberg content.
        $count = count($flat);
        for ($i = 0; $i < $count; $i++) {
            $current = $flat[$i];
            $name = isset($current['blockName']) ? (string) $current['blockName'] : '';
            if ($name !== 'core/heading') {
                continue;
            }
            $question = $this->cleanLine($this->blockText($current));
            if ($question === '' || substr($question, -1) !== '?') {
                continue;
            }
            for ($j = $i + 1; $j < $count; $j++) {
                $next = $flat[$j];
                $nextName = isset($next['blockName']) ? (string) $next['blockName'] : '';
                if ($nextName === 'core/heading') {
                    break;
                }
                $answer = $this->cleanLine($this->blockText($next));
                if ($answer === '') {
                    continue;
                }
                $pairs[] = ['question' => $question, 'answer' => $answer];
                break;
            }
        }

        $normalized = [];
        $seen = [];
        foreach ($pairs as $pair) {
            $question = isset($pair['question']) ? trim((string) $pair['question']) : '';
            $answer = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
            if ($question === '' || $answer === '') {
                continue;
            }
            $key = $this->lower($question . '|' . $answer);
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $normalized[] = ['question' => $question, 'answer' => $answer];
            if (count($normalized) >= 8) {
                break;
            }
        }

        return $normalized;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,string>
     */
    private function extractHowToStepsFromBlocks(array $flat): array
    {
        $steps = [];

        foreach ($flat as $block) {
            $name = isset($block['blockName']) ? (string) $block['blockName'] : '';
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $innerHtml = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';
            if ($name === 'core/list' && !empty($attrs['ordered'])) {
                foreach ($this->parseListItemsFromHtml($innerHtml) as $item) {
                    $steps[] = $item;
                }
            }
            if ($name !== '' && ($this->containsText($name, 'howto') || $this->containsText($name, 'step'))) {
                foreach (['steps', 'items'] as $itemsKey) {
                    $items = $attrs[$itemsKey] ?? null;
                    if (!is_array($items)) {
                        continue;
                    }
                    foreach ($items as $item) {
                        if (is_array($item)) {
                            $text = $this->firstNonEmpty($item, ['text', 'content', 'description', 'name', 'title']);
                        } else {
                            $text = (string) $item;
                        }
                        $text = $this->cleanLine($this->plainText($text));
                        if ($text !== '') {
                            $steps[] = $text;
                        }
                    }
                }
            }
        }

        return $this->uniqueStrings($steps, 12);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,string>
     */
    private function extractRecipeIngredientsFromBlocks(array $flat): array
    {
        return $this->extractSectionItemsFromBlocks($flat, ['ingredients'], ['instructions', 'method', 'directions', 'notes', 'nutrition']);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,string>
     */
    private function extractRecipeInstructionsFromBlocks(array $flat): array
    {
        $items = $this->extractSectionItemsFromBlocks($flat, ['instructions', 'method', 'directions'], ['notes', 'nutrition', 'ingredients']);
        if (!empty($items)) {
            return $items;
        }

        return $this->extractHowToStepsFromBlocks($flat);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @param array<int,string> $sectionHeadings
     * @param array<int,string> $stopHeadings
     * @return array<int,string>
     */
    private function extractSectionItemsFromBlocks(array $flat, array $sectionHeadings, array $stopHeadings): array
    {
        $collect = false;
        $items = [];

        foreach ($flat as $block) {
            $name = isset($block['blockName']) ? (string) $block['blockName'] : '';
            $text = $this->cleanLine($this->blockText($block));
            $normalized = $this->lower($text);

            if ($name === 'core/heading') {
                if (in_array($normalized, $sectionHeadings, true) || in_array(rtrim($normalized, ':'), $sectionHeadings, true)) {
                    $collect = true;
                    continue;
                }
                if ($collect && (in_array($normalized, $stopHeadings, true) || in_array(rtrim($normalized, ':'), $stopHeadings, true))) {
                    break;
                }
            }

            if (!$collect) {
                continue;
            }

            if ($name === 'core/list') {
                foreach ($this->parseListItemsFromHtml((string) ($block['innerHTML'] ?? '')) as $item) {
                    $items[] = $item;
                }
                continue;
            }

            if ($name === 'core/paragraph' || $name === 'core/details') {
                $maybe = $this->cleanLine($text);
                if ($maybe !== '') {
                    $items[] = $maybe;
                }
            }
        }

        return $this->uniqueStrings($items, 15);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,array{question:string,answer:string}>
     */
    private function extractFaqPairsFromHtml(string $html): array
    {
        $pairs = [];
        $html = trim($html);
        if ($html === '') {
            return [];
        }

        if (preg_match_all('/<h[2-6][^>]*>(.*?)<\/h[2-6]>\s*(?:<div[^>]*>)?\s*<p[^>]*>(.*?)<\/p>/is', $html, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $row) {
                $question = $this->cleanLine((string) ($row[1] ?? ''));
                $answer = $this->cleanLine((string) ($row[2] ?? ''));
                if ($question !== '' && $answer !== '') {
                    $pairs[] = ['question' => $question, 'answer' => $answer];
                }
            }
        }

        if (empty($pairs) && preg_match_all('/<summary[^>]*>(.*?)<\/summary>\s*(.*?)\s*(?:<\/details>|$)/is', $html, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $row) {
                $question = $this->cleanLine((string) ($row[1] ?? ''));
                $answer = $this->cleanLine($this->plainText((string) ($row[2] ?? '')));
                if ($question !== '' && $answer !== '') {
                    $pairs[] = ['question' => $question, 'answer' => $answer];
                }
            }
        }

        return $pairs;
    }

    /**
     * @return array<int,string>
     */
    private function parseListItemsFromHtml(string $html): array
    {
        $items = [];
        if ($html === '') {
            return [];
        }
        if (preg_match_all('/<li[^>]*>(.*?)<\/li>/is', $html, $matches)) {
            foreach ((array) ($matches[1] ?? []) as $item) {
                $item = $this->cleanLine((string) $item);
                if ($item !== '') {
                    $items[] = $item;
                }
            }
        }
        return $this->uniqueStrings($items, 20);
    }

    /**
     * @param array<string,mixed> $block
     */
    private function blockText(array $block): string
    {
        $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
        foreach (['content', 'summary', 'title', 'text'] as $key) {
            if (!empty($attrs[$key]) && is_scalar($attrs[$key])) {
                return $this->plainText((string) $attrs[$key]);
            }
        }

        $html = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';
        if ($html !== '') {
            return $this->plainText($html);
        }

        $inner = isset($block['innerContent']) && is_array($block['innerContent']) ? $block['innerContent'] : [];
        if (!empty($inner)) {
            return $this->plainText(implode("\n", array_map('strval', $inner)));
        }

        return '';
    }

    /**
     * @param array<string,mixed> $row
     * @param array<int,string> $keys
     */
    private function firstNonEmpty(array $row, array $keys): string
    {
        foreach ($keys as $key) {
            if (!isset($row[$key])) {
                continue;
            }
            if (is_scalar($row[$key])) {
                $value = trim((string) $row[$key]);
                if ($value !== '') {
                    return $value;
                }
            }
        }
        return '';
    }

    /**
     * @param array<int,string> $values
     * @return array<int,string>
     */
    private function uniqueStrings(array $values, int $limit): array
    {
        $out = [];
        $seen = [];
        foreach ($values as $value) {
            $value = trim($value);
            if ($value === '') {
                continue;
            }
            $key = $this->lower($value);
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $out[] = $value;
            if (count($out) >= $limit) {
                break;
            }
        }
        return $out;
    }

    private function cleanLine(string $value): string
    {
        $value = wp_strip_all_tags($value);
        $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
        return trim($value, " \t\n\r\0\x0B-•*");
    }

    private function plainText(string $value): string
    {
        $value = str_replace(['</p>', '</li>', '<br>', '<br/>', '<br />'], "\n", $value);
        $value = wp_strip_all_tags($value);
        $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $value = preg_replace('/[\t ]+/u', ' ', $value) ?? $value;
        $value = preg_replace('/\n{3,}/u', "\n\n", $value) ?? $value;
        return trim($value);
    }

    private function prettyJson(array $node): string
    {
        $json = wp_json_encode($node, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        return is_string($json) ? $json : '';
    }

    private function lower(string $value): string
    {
        if (function_exists('mb_strtolower')) {
            return (string) mb_strtolower($value, 'UTF-8');
        }
        return strtolower($value);
    }

    private function containsText(string $haystack, string $needle): bool
    {
        if (function_exists('mb_strpos')) {
            return mb_strpos($haystack, $needle, 0, 'UTF-8') !== false;
        }
        return strpos($haystack, $needle) !== false;
    }

    /**
     * @param array<int,string> $needles
     */
    private function containsAny(string $haystack, array $needles): bool
    {
        foreach ($needles as $needle) {
            if ($needle !== '' && $this->containsText($haystack, $needle)) {
                return true;
            }
        }
        return false;
    }

    private function countMatches(string $pattern, string $subject): int
    {
        $count = preg_match_all($pattern, $subject, $matches);
        return is_int($count) && $count > 0 ? $count : 0;
    }
}
