<?php

namespace WPSN\SEO;

defined('ABSPATH') || exit;

/**
 * Expanded Gutenberg block-aware schema analyzer.
 *
 * Keeps all extraction local and conservative:
 * - known FAQ / accordion blocks
 * - known HowTo / step blocks
 * - known recipe-card blocks
 * - known event / review / service-like blocks
 * - product / comparison / pricing ecosystems that can enrich review/service drafts
 */
final class SchemaBlockAnalyzer
{
    /**
     * @var array<string,array{label:string,schema:array<int,string>}>
     */
    private const BLOCK_MAP = [
        'core/details' => ['label' => 'Details / accordion', 'schema' => ['faq']],
        'yoast/faq-block' => ['label' => 'Yoast FAQ', 'schema' => ['faq']],
        'rank-math/faq-block' => ['label' => 'Rank Math FAQ', 'schema' => ['faq']],
        'rank-math/faq' => ['label' => 'Rank Math FAQ', 'schema' => ['faq']],
        'uagb/faq' => ['label' => 'Spectra / UAGB FAQ', 'schema' => ['faq']],
        'uagb/content-toggle' => ['label' => 'Spectra / UAGB Content Toggle', 'schema' => ['faq']],
        'kadence/accordion' => ['label' => 'Kadence Accordion', 'schema' => ['faq']],
        'kadence/pane' => ['label' => 'Kadence Accordion Pane', 'schema' => ['faq']],
        'essential-blocks/accordion' => ['label' => 'Essential Blocks Accordion', 'schema' => ['faq']],
        'stackable/accordion' => ['label' => 'Stackable Accordion', 'schema' => ['faq']],
        'otter/accordion' => ['label' => 'Otter Accordion', 'schema' => ['faq']],
        'greenshift-blocks/accordion' => ['label' => 'Greenshift Accordion', 'schema' => ['faq']],
        'jetpack/accordion' => ['label' => 'Jetpack Accordion', 'schema' => ['faq']],
        'jetpack/accordion-item' => ['label' => 'Jetpack Accordion Item', 'schema' => ['faq']],
        'coblocks/accordion' => ['label' => 'CoBlocks Accordion', 'schema' => ['faq']],
        'coblocks/accordion-item' => ['label' => 'CoBlocks Accordion Item', 'schema' => ['faq']],
        'ultimate-blocks/content-toggle' => ['label' => 'Ultimate Blocks Content Toggle', 'schema' => ['faq']],
        'affiliatex/faq' => ['label' => 'AffiliateX FAQ', 'schema' => ['faq']],

        'yoast/how-to-block' => ['label' => 'Yoast HowTo', 'schema' => ['howto']],
        'rank-math/how-to' => ['label' => 'Rank Math HowTo', 'schema' => ['howto']],
        'rank-math/howto' => ['label' => 'Rank Math HowTo', 'schema' => ['howto']],
        'uagb/how-to' => ['label' => 'Spectra / UAGB HowTo', 'schema' => ['howto']],
        'essential-blocks/how-to' => ['label' => 'Essential Blocks HowTo', 'schema' => ['howto']],

        'bootstrapped-ventures/wp-recipe-maker/recipe' => ['label' => 'WP Recipe Maker', 'schema' => ['recipe']],
        'recipe-card-blocks/recipe-card' => ['label' => 'Recipe Card Blocks', 'schema' => ['recipe']],
        'wpzoom/recipe-card' => ['label' => 'WPZOOM Recipe Card', 'schema' => ['recipe']],
        'wpzoom/recipe-card-block' => ['label' => 'WPZOOM Recipe Card', 'schema' => ['recipe']],
        'delicious-recipes/block-recipe-card' => ['label' => 'Delicious Recipes', 'schema' => ['recipe']],
        'mediavine-recipe-card/block' => ['label' => 'Mediavine Recipe Card', 'schema' => ['recipe']],
        'yumprint-recipe-card/block' => ['label' => 'YumPrint Recipe Card', 'schema' => ['recipe']],

        'the-events-calendar/event-details' => ['label' => 'The Events Calendar', 'schema' => ['event']],
        'the-events-calendar/event-datetime' => ['label' => 'The Events Calendar date/time', 'schema' => ['event']],
        'eventbrite/event' => ['label' => 'Eventbrite', 'schema' => ['event']],

        'rank-math/review' => ['label' => 'Rank Math Review', 'schema' => ['review']],
        'wp-product-review/review' => ['label' => 'WP Product Review', 'schema' => ['review']],
        'site-reviews/review' => ['label' => 'Site Reviews', 'schema' => ['review']],
        'ultimate-blocks/review' => ['label' => 'Ultimate Blocks Review', 'schema' => ['review']],
        'ultimate-blocks/review-box' => ['label' => 'Ultimate Blocks Review Box', 'schema' => ['review']],
        'affiliatex/ab-review' => ['label' => 'AffiliateX Review', 'schema' => ['review', 'product']],
        'affiliatex/ab-product-comparison' => ['label' => 'AffiliateX Product Comparison', 'schema' => ['review', 'product']],
        'affiliatex/ab-comparison-table' => ['label' => 'AffiliateX Comparison Table', 'schema' => ['review', 'product']],
        'affiliatex/ab-versus-line' => ['label' => 'AffiliateX Versus Line', 'schema' => ['review', 'product']],
        'affiliatex/ab-versus' => ['label' => 'AffiliateX Versus', 'schema' => ['review', 'product']],
        'affiliatex/ab-single-product' => ['label' => 'AffiliateX Single Product', 'schema' => ['product']],
        'affiliatex/ab-product-box' => ['label' => 'AffiliateX Product Box', 'schema' => ['product']],
        'affiliatex/ab-product-table' => ['label' => 'AffiliateX Product Table', 'schema' => ['product']],
        'wp-review/review' => ['label' => 'WP Review', 'schema' => ['review']],
        'wp-review-pro/review' => ['label' => 'WP Review Pro', 'schema' => ['review']],
        'reviewx/review' => ['label' => 'ReviewX Review', 'schema' => ['review']],
        'greenshift-blocks/product-comparison' => ['label' => 'Greenshift Product Comparison', 'schema' => ['review', 'product']],
        'woocommerce/all-reviews' => ['label' => 'WooCommerce All Reviews', 'schema' => ['review', 'product']],
        'woocommerce/reviews-by-product' => ['label' => 'WooCommerce Reviews by Product', 'schema' => ['review', 'product']],

        'woocommerce/product-title' => ['label' => 'WooCommerce Product Title', 'schema' => ['product']],
        'woocommerce/product-price' => ['label' => 'WooCommerce Product Price', 'schema' => ['product']],
        'woocommerce/product-summary' => ['label' => 'WooCommerce Product Summary', 'schema' => ['product']],
        'woocommerce/product-details' => ['label' => 'WooCommerce Product Details', 'schema' => ['product']],
        'woocommerce/product-image' => ['label' => 'WooCommerce Product Image', 'schema' => ['product']],
        'woocommerce/product-button' => ['label' => 'WooCommerce Product Button', 'schema' => ['product']],
        'woocommerce/product-stock-indicator' => ['label' => 'WooCommerce Stock Indicator', 'schema' => ['product']],
        'woocommerce/add-to-cart-form' => ['label' => 'WooCommerce Add to Cart', 'schema' => ['product']],
        'woocommerce/single-product' => ['label' => 'WooCommerce Single Product', 'schema' => ['product']],
        'woocommerce/product-collection' => ['label' => 'WooCommerce Product Collection', 'schema' => ['product']],
        'lasso/product-display' => ['label' => 'Lasso Product Display', 'schema' => ['product']],
        'lasso/list' => ['label' => 'Lasso Product List', 'schema' => ['product']],
        'lasso/table' => ['label' => 'Lasso Table', 'schema' => ['product']],
        'lasso/comparison-table' => ['label' => 'Lasso Comparison Table', 'schema' => ['product']],
        'aawp/box' => ['label' => 'AAWP Product Box', 'schema' => ['product']],
        'aawp/table' => ['label' => 'AAWP Product Table', 'schema' => ['product']],
        'aawp/comparison-table' => ['label' => 'AAWP Comparison Table', 'schema' => ['product']],
        'reviewx/product-comparison' => ['label' => 'ReviewX Product Comparison', 'schema' => ['review', 'product']],

        'affiliatex/shortcode' => ['label' => 'AffiliateX shortcode', 'schema' => ['review', 'product']],
        'affiliatex/widget-layout' => ['label' => 'AffiliateX widget layout', 'schema' => ['review', 'product']],
        'lasso/shortcode' => ['label' => 'Lasso shortcode', 'schema' => ['product']],
        'lasso/widget-layout' => ['label' => 'Lasso widget layout', 'schema' => ['product']],
        'aawp/shortcode' => ['label' => 'AAWP shortcode', 'schema' => ['product']],
        'aawp/widget-layout' => ['label' => 'AAWP widget layout', 'schema' => ['product']],
        'reviewx/shortcode' => ['label' => 'ReviewX shortcode', 'schema' => ['review', 'product']],
        'reviewx/widget-layout' => ['label' => 'ReviewX widget layout', 'schema' => ['review', 'product']],
        'html/comparison-table' => ['label' => 'Classic HTML comparison table', 'schema' => ['product']],
        'html/faq' => ['label' => 'Classic HTML FAQ / accordion', 'schema' => ['faq']],
        'html/howto' => ['label' => 'Classic HTML HowTo', 'schema' => ['howto']],
        'html/recipe' => ['label' => 'Classic HTML Recipe', 'schema' => ['recipe']],
        'html/event' => ['label' => 'Classic HTML Event', 'schema' => ['event']],
        'html/review' => ['label' => 'Classic HTML Review', 'schema' => ['review']],
        'html/service' => ['label' => 'Classic HTML Service / pricing', 'schema' => ['service']],
        'html/product' => ['label' => 'Classic HTML Product / pricing', 'schema' => ['product']],

        'uagb/info-box' => ['label' => 'Spectra / UAGB Info Box', 'schema' => ['service']],
        'kadence/info-box' => ['label' => 'Kadence Info Box', 'schema' => ['service']],
        'stackable/pricing-box' => ['label' => 'Stackable Pricing Box', 'schema' => ['service']],
        'stackable/feature' => ['label' => 'Stackable Feature', 'schema' => ['service']],
    ];

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array{
     *   faq_pairs: array<int,array{question:string,answer: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>>,
     *   comparison_meta: array<string,mixed>,
     *   service_data: array<string,mixed>,
     *   block_map_matches: array<int,array{block_name:string,label:string,schema_types:array<int,string>,count:int}>
     * }
     */
    public function analyze(array $flat, string $rawContent = ''): array
    {
        $flat = $this->augmentWithVendorPseudoBlocks($flat, $rawContent);
        $comparisonItems = $this->extractComparisonItems($flat);

        return [
            'faq_pairs' => $this->extractFaqPairs($flat),
            'howto_steps' => $this->extractHowToSteps($flat),
            'recipe_ingredients' => $this->extractRecipeIngredients($flat),
            'recipe_instructions' => $this->extractRecipeInstructions($flat),
            'event_data' => $this->extractEventData($flat),
            'review_data' => $this->extractReviewData($flat),
            'product_data' => $this->extractProductData($flat),
            'comparison_items' => $comparisonItems,
            'comparison_meta' => $this->buildComparisonMeta($flat, $comparisonItems),
            'service_data' => $this->extractServiceData($flat),
            'block_map_matches' => $this->collectBlockMapMatches($flat),
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,array<string,mixed>>
     */
    private function augmentWithVendorPseudoBlocks(array $flat, string $rawContent): array
    {
        $augmented = $flat;

        foreach ($this->extractVendorWidgetLayoutBlocks($flat) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractVendorShortcodeBlocks($rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlComparisonBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlFaqBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlHowToBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlRecipeBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlEventBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlReviewBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlServiceBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        foreach ($this->extractGenericHtmlProductBlocks($flat, $rawContent) as $block) {
            if (is_array($block)) {
                $augmented[] = $block;
            }
        }

        return $augmented;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['<details', '<summary', '<dt', 'accordion', 'faq', 'toggle'])) {
            return [];
        }

        if ($this->hasDedicatedFaqSource($flat)) {
            return [];
        }

        $pairs = $this->extractFaqPairsFromHtml($rawContent);
        if (empty($pairs)) {
            return [];
        }

        return [[
            'blockName' => 'html/faq',
            'attrs' => [
                'questions' => $pairs,
                '_html_source' => 'raw_content',
            ],
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedFaqSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('faq', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['<ol', '<ul', 'how to', 'step', 'instructions', 'directions', 'method', 'tutorial'])) {
            return [];
        }

        if ($this->hasDedicatedHowToSource($flat)) {
            return [];
        }

        if (!$this->hasDedicatedRecipeSource($flat)) {
            $recipeData = $this->extractRecipeDataFromHtml($rawContent);
            if (!empty($recipeData['ingredients']) && count($recipeData['instructions']) >= 2) {
                return [];
            }
        }

        $steps = $this->extractHowToStepsFromHtml($rawContent);
        if (count($steps) < 2) {
            return [];
        }

        return [[
            'blockName' => 'html/howto',
            'attrs' => [
                'steps' => $steps,
                '_html_source' => 'raw_content',
            ],
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedHowToSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('howto', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['ingredient', 'ingredients', 'instructions', 'directions', 'method', 'recipe'])) {
            return [];
        }

        if ($this->hasDedicatedRecipeSource($flat)) {
            return [];
        }

        $recipeData = $this->extractRecipeDataFromHtml($rawContent);
        $ingredients = isset($recipeData['ingredients']) && is_array($recipeData['ingredients']) ? $recipeData['ingredients'] : [];
        $instructions = isset($recipeData['instructions']) && is_array($recipeData['instructions']) ? $recipeData['instructions'] : [];
        if (empty($ingredients) && count($instructions) < 2) {
            return [];
        }

        return [[
            'blockName' => 'html/recipe',
            'attrs' => [
                'ingredients' => $ingredients,
                'instructions' => $instructions,
                '_html_source' => 'raw_content',
            ],
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedRecipeSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('recipe', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['<time', 'event', 'webinar', 'conference', 'workshop', 'meetup', 'seminar', 'register', 'ticket', 'venue', 'location'])) {
            return [];
        }

        if ($this->hasDedicatedEventSource($flat)) {
            return [];
        }

        $eventData = $this->extractEventDataFromHtml($rawContent);
        $signalCount = 0;
        foreach (['name', 'start_date', 'location', 'description'] as $key) {
            if (!empty($eventData[$key])) {
                $signalCount++;
            }
        }

        if ($signalCount < 2 && empty($eventData['attendance_mode'])) {
            return [];
        }

        return [[
            'blockName' => 'html/event',
            'attrs' => array_merge($eventData, [
                '_html_source' => 'raw_content',
            ]),
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedEventSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('event', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function extractEventDataFromHtml(string $html): array
    {
        $data = [];

        $name = $this->extractFirstHtmlTextByClassNeedles($html, ['event-title', 'event-name', 'event-heading', 'entry-title', 'tribe-event-title', 'headline', 'summary'], ['h1', 'h2', 'h3', 'div', 'span']);
        if ($name === '') {
            $name = $this->extractFirstHeadingTextFromHtml($html);
        }
        if ($name !== '') {
            $data['name'] = $name;
        }

        $timeValues = $this->extractTimeValuesFromHtml($html);
        if (!empty($timeValues[0])) {
            $data['start_date'] = $timeValues[0];
        }
        if (!empty($timeValues[1])) {
            $data['end_date'] = $timeValues[1];
        }

        if (empty($data['start_date'])) {
            $start = $this->extractFirstDateValueByClassNeedles($html, ['event-start', 'start-date', 'date-start', 'event-date', 'event-datetime', 'start-time', 'date-time']);
            if ($start !== '') {
                $data['start_date'] = $start;
            }
        }
        if (empty($data['end_date'])) {
            $end = $this->extractFirstDateValueByClassNeedles($html, ['event-end', 'end-date', 'date-end', 'event-end-date', 'end-time']);
            if ($end !== '') {
                $data['end_date'] = $end;
            }
        }

        $location = $this->extractFirstHtmlTextByClassNeedles($html, ['event-location', 'location', 'venue', 'venue-name', 'event-venue', 'address'], ['address', 'div', 'span', 'p']);
        if ($location === '' && preg_match('/<address[^>]*>(.*?)<\/address>/is', $html, $match)) {
            $location = $this->cleanLine($this->plainText((string) ($match[1] ?? '')));
        }
        if ($location !== '') {
            $data['location'] = $location;
        }

        $modeText = $this->plainText($html);
        $modeClassText = $this->extractFirstHtmlTextByClassNeedles($html, ['attendance-mode', 'event-mode', 'mode', 'virtual', 'in-person'], ['div', 'span', 'p', 'strong']);
        $attendanceMode = $this->normalizeEventAttendanceMode(trim($modeText . ' ' . $modeClassText));
        if ($attendanceMode !== '') {
            $data['attendance_mode'] = $attendanceMode;
        }

        $description = $this->extractFirstHtmlTextByClassNeedles($html, ['event-description', 'event-summary', 'entry-summary', 'description', 'excerpt'], ['div', 'section', 'p']);
        if ($description === '') {
            $description = $this->extractFirstParagraphTextFromHtml($html);
        }
        if ($description !== '' && isset($data['location']) && $description === $data['location']) {
            $description = '';
        }
        if ($description !== '') {
            $data['description'] = $description;
        }

        return $data;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['review', 'rating', 'score', 'pros', 'cons', 'verdict', 'stars'])) {
            return [];
        }

        if ($this->hasDedicatedReviewSource($flat)) {
            return [];
        }

        $reviewData = $this->extractReviewDataFromHtml($rawContent);
        $signalCount = 0;
        foreach (['item_name', 'rating', 'review_body', 'reviewer'] as $key) {
            if (!empty($reviewData[$key])) {
                $signalCount++;
            }
        }
        if (!empty($reviewData['pros']) || !empty($reviewData['cons'])) {
            $signalCount++;
        }

        if ($signalCount < 2) {
            return [];
        }

        return [[
            'blockName' => 'html/review',
            'attrs' => array_merge($reviewData, [
                '_html_source' => 'raw_content',
            ]),
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedReviewSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('review', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function extractReviewDataFromHtml(string $html): array
    {
        $data = [];

        $itemName = $this->extractFirstHtmlTextByClassNeedles($html, ['review-title', 'review-heading', 'review-headline', 'product-title', 'item-name', 'product-name', 'entry-title', 'headline'], ['h1', 'h2', 'h3', 'div', 'span']);
        if ($itemName === '') {
            $itemName = $this->extractFirstHeadingTextFromHtml($html);
        }
        if ($itemName !== '') {
            $data['item_name'] = $itemName;
        }

        $ratingData = $this->extractReviewRatingFromHtml($html);
        foreach (['rating', 'best_rating', 'worst_rating'] as $key) {
            if (!empty($ratingData[$key])) {
                $data[$key] = $ratingData[$key];
            }
        }

        $reviewer = $this->extractFirstHtmlTextByClassNeedles($html, ['reviewer', 'author', 'byline', 'review-author', 'reviewer-name'], ['div', 'span', 'p', 'strong']);
        if ($reviewer === '') {
            $plain = $this->plainText($html);
            if (preg_match('/(?:^|\n)\s*by\s+([^\n\r|]{2,80})/iu', $plain, $match)) {
                $reviewer = $this->cleanLine((string) ($match[1] ?? ''));
            }
        }
        if ($reviewer !== '') {
            if (preg_match('/^by\s+/iu', $reviewer)) {
                $reviewer = preg_replace('/^by\s+/iu', '', $reviewer) ?? $reviewer;
            }
            $data['reviewer'] = trim($reviewer);
        }

        $reviewBody = $this->extractFirstHtmlTextByClassNeedles($html, ['review-summary', 'review-body', 'review-content', 'review-text', 'verdict', 'summary', 'description', 'excerpt'], ['div', 'section', 'p']);
        if ($reviewBody === '') {
            $reviewBody = $this->extractFirstParagraphTextFromHtml($html);
        }
        if ($reviewBody !== '') {
            $data['review_body'] = $reviewBody;
        }

        $pros = array_merge(
            $this->extractRecipeSectionItemsFromHtml($html, ['pros', 'advantages', 'what we like', 'positives'], 8, false),
            $this->extractRecipeClassItemsFromHtml($html, ['pros', 'advantages', 'positives', 'review-pros', 'review-advantages'], 8)
        );
        $pros = $this->uniqueStrings($pros, 8);
        if (!empty($pros)) {
            $data['pros'] = $pros;
        }

        $cons = array_merge(
            $this->extractRecipeSectionItemsFromHtml($html, ['cons', 'disadvantages', 'what to consider', 'negatives'], 8, false),
            $this->extractRecipeClassItemsFromHtml($html, ['cons', 'disadvantages', 'negatives', 'review-cons', 'review-disadvantages'], 8)
        );
        $cons = $this->uniqueStrings($cons, 8);
        if (!empty($cons)) {
            $data['cons'] = $cons;
        }

        return $data;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['service', 'services', 'pricing', 'plan', 'plans', 'package', 'packages', 'quote', 'book now', 'request a quote', 'free estimate', 'areas served', 'service area', 'coverage'])) {
            return [];
        }

        if ($this->hasDedicatedServiceSource($flat)) {
            return [];
        }

        $serviceData = $this->extractServiceDataFromHtml($rawContent);
        $signalCount = 0;
        foreach (['serviceType', 'description', 'provider', 'price', 'currency'] as $key) {
            if (!empty($serviceData[$key])) {
                $signalCount++;
            }
        }
        if (!empty($serviceData['areaServed'])) {
            $signalCount++;
        }

        if ($signalCount < 2) {
            return [];
        }

        return [[
            'blockName' => 'html/service',
            'attrs' => array_merge($serviceData, [
                '_html_source' => 'raw_content',
            ]),
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedServiceSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('service', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function extractServiceDataFromHtml(string $html): array
    {
        $data = [];

        $name = $this->extractFirstHtmlTextByClassNeedles($html, ['service-title', 'service-name', 'pricing-title', 'plan-title', 'package-title', 'offer-title', 'entry-title', 'headline'], ['h1', 'h2', 'h3', 'div', 'span']);
        if ($name === '') {
            $name = $this->extractFirstHeadingTextFromHtml($html);
        }
        if ($name !== '') {
            $data['serviceType'] = $name;
            $data['name'] = $name;
        }

        $description = $this->extractFirstHtmlTextByClassNeedles($html, ['service-description', 'service-summary', 'pricing-description', 'plan-description', 'package-description', 'offer-summary', 'entry-summary', 'description', 'excerpt'], ['div', 'section', 'p']);
        if ($description === '') {
            $description = $this->extractFirstParagraphTextFromHtml($html);
        }
        if ($description !== '') {
            $data['description'] = $description;
        }

        $provider = $this->extractFirstHtmlTextByClassNeedles($html, ['provider', 'business-name', 'company-name', 'organization-name', 'brand-name', 'site-title'], ['div', 'span', 'p', 'strong']);
        if ($provider === '') {
            $plain = $this->plainText($html);
            if (preg_match('/(?:^|\n)\s*(?:by|from)\s+([^\n\r|]{2,120})/iu', $plain, $match)) {
                $provider = $this->cleanLine((string) ($match[1] ?? ''));
            }
        }
        if ($provider !== '') {
            if (preg_match('/^by\s+/iu', $provider)) {
                $provider = preg_replace('/^by\s+/iu', '', $provider) ?? $provider;
            }
            $data['provider'] = trim($provider);
        }

        $pricing = $this->extractPriceCurrencyFromHtml($html, ['service-price', 'service-pricing', 'pricing-price', 'plan-price', 'package-price', 'offer-price', 'price', 'amount', 'starting-price', 'from-price']);
        if (!empty($pricing['price'])) {
            $data['price'] = (string) $pricing['price'];
        }
        if (!empty($pricing['currency'])) {
            $data['currency'] = (string) $pricing['currency'];
        }

        $areas = array_merge(
            $this->extractRecipeSectionItemsFromHtml($html, ['areas served', 'area served', 'service area', 'service areas', 'locations', 'coverage', 'serving'], 10, false),
            $this->extractRecipeClassItemsFromHtml($html, ['service-area', 'service-areas', 'area-served', 'areas-served', 'coverage', 'locations', 'location-item'], 10)
        );
        if (empty($areas)) {
            $plain = $this->plainText($html);
            if (preg_match('/(?:areas served|service areas?|locations|coverage|serving)\s*[:\-]\s*([^\n\r\.]{3,220})/iu', $plain, $match)) {
                $areas = preg_split('/\s*,\s*|\s*\|\s*|\s*\/\s*/u', (string) ($match[1] ?? '')) ?: [];
            }
        }
        $areas = $this->uniqueStrings(array_map('strval', $areas), 10);
        if (!empty($areas)) {
            $data['areaServed'] = $areas;
        }

        return $data;
    }

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

        $rawLower = $this->lower($rawContent);
        if (!$this->containsAny($rawLower, ['product', 'pricing', 'plan', 'package', 'subscription', 'buy', 'checkout', 'order', 'deal', 'coupon', 'sku', 'asin', 'mpn', '$', '€', '£'])) {
            return [];
        }

        if ($this->hasDedicatedProductSource($flat)) {
            return [];
        }

        $productData = $this->extractProductDataFromHtml($rawContent);
        $signalCount = 0;
        foreach (['name', 'description', 'brand', 'sku', 'price', 'currency', 'url'] as $key) {
            if (!empty($productData[$key])) {
                $signalCount++;
            }
        }
        if (!empty($productData['feature_summary'])) {
            $signalCount++;
        }

        if ($signalCount < 2 || (empty($productData['price']) && empty($productData['url']) && empty($productData['sku']))) {
            return [];
        }

        return [[
            'blockName' => 'html/product',
            'attrs' => array_merge($productData, [
                '_html_source' => 'raw_content',
            ]),
            'innerHTML' => '',
            'innerBlocks' => [],
        ]];
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedProductSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/paragraph', 'core/group'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (in_array('product', $this->schemaTypesForBlock($name, $attrs), true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function extractProductDataFromHtml(string $html): array
    {
        $data = [];

        $name = $this->extractFirstHtmlTextByClassNeedles($html, ['product-title', 'product-name', 'pricing-title', 'plan-title', 'package-title', 'offer-title', 'entry-title', 'headline'], ['h1', 'h2', 'h3', 'div', 'span']);
        if ($name === '') {
            $name = $this->extractFirstHeadingTextFromHtml($html);
        }
        if ($name !== '') {
            $data['name'] = $name;
        }

        $description = $this->extractFirstHtmlTextByClassNeedles($html, ['product-description', 'product-summary', 'pricing-description', 'plan-description', 'package-description', 'offer-summary', 'entry-summary', 'description', 'excerpt'], ['div', 'section', 'p']);
        if ($description === '') {
            $description = $this->extractFirstParagraphTextFromHtml($html);
        }
        if ($description !== '') {
            $data['description'] = $description;
        }

        $brand = $this->extractFirstHtmlTextByClassNeedles($html, ['brand', 'manufacturer', 'vendor', 'provider', 'company', 'business', 'merchant'], ['div', 'span', 'p', 'strong']);
        if ($brand !== '') {
            $data['brand'] = $brand;
        }

        $plain = $this->plainText($html);
        if (preg_match('/\b(?:SKU|ASIN|MPN)\s*[:#]?\s*([A-Z0-9._\-]{3,40})\b/u', $plain, $match)) {
            $data['sku'] = trim((string) ($match[1] ?? ''));
        }

        $pricing = $this->extractPriceCurrencyFromHtml($html, ['product-price', 'pricing-price', 'plan-price', 'package-price', 'offer-price', 'price', 'amount', 'sale-price', 'deal-price', 'from-price']);
        if (!empty($pricing['price'])) {
            $data['price'] = (string) $pricing['price'];
        }
        if (!empty($pricing['currency'])) {
            $data['currency'] = (string) $pricing['currency'];
        }

        $url = $this->extractPreferredHrefFromHtml($html, ['buy now', 'buy', 'get started', 'start trial', 'start free', 'view pricing', 'pricing', 'sign up', 'signup', 'checkout', 'purchase', 'order now', 'get deal', 'shop now'], ['product-button', 'product-link', 'pricing-button', 'pricing-link', 'cta', 'button', 'buy', 'checkout', 'pricing', 'plan', 'package', 'deal', 'affiliate']);
        if ($url !== '') {
            $data['url'] = $url;
        }

        $features = array_merge(
            $this->extractRecipeSectionItemsFromHtml($html, ['features', 'benefits', 'highlights', 'what you get', 'includes'], 8, false),
            $this->extractRecipeClassItemsFromHtml($html, ['feature', 'features', 'benefit', 'benefits', 'highlight', 'highlights'], 8)
        );
        $features = $this->uniqueStrings(array_map('strval', $features), 8);
        if (!empty($features)) {
            $data['feature_summary'] = $features;
        }

        return $data;
    }

    /**
     * @return array{ingredients:array<int,string>,instructions:array<int,string>}
     */
    private function extractRecipeDataFromHtml(string $html): array
    {
        $ingredients = $this->extractRecipeSectionItemsFromHtml($html, ['ingredients', 'ingredient'], 20, false);
        if (empty($ingredients)) {
            $ingredients = $this->extractRecipeClassItemsFromHtml($html, ['ingredient', 'ingredients-item', 'recipe-ingredient', 'wprm-recipe-ingredient'], 20);
        }

        $instructions = $this->extractRecipeSectionItemsFromHtml($html, ['instructions', 'directions', 'method', 'steps', 'preparation'], 14, true);
        if (count($instructions) < 2) {
            $instructions = array_merge(
                $instructions,
                $this->extractRecipeClassItemsFromHtml($html, ['instruction', 'direction', 'method', 'recipe-instruction', 'wprm-recipe-instruction', 'prep-step'], 14)
            );
        }
        if (count($instructions) < 2) {
            $instructions = array_merge($instructions, $this->extractHowToStepsFromHtml($html));
        }

        return [
            'ingredients' => $this->uniqueStrings($ingredients, 20),
            'instructions' => $this->uniqueStrings($instructions, 14),
        ];
    }

    /**
     * @param array<int,string> $headingNeedles
     * @return array<int,string>
     */
    private function extractRecipeSectionItemsFromHtml(string $html, array $headingNeedles, int $limit = 20, bool $preferOrdered = false): array
    {
        $items = [];
        if (!preg_match_all('/<h([2-6])[^>]*>(.*?)<\/h\1>(.*?)(?=<h[1-6]\b|$)/is', $html, $matches, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($matches as $section) {
            $heading = $this->lower($this->cleanLine($this->plainText((string) ($section[2] ?? ''))));
            if ($heading === '' || !$this->containsAny($heading, $headingNeedles)) {
                continue;
            }

            $body = (string) ($section[3] ?? '');
            if ($body === '') {
                continue;
            }

            $sectionItems = [];

            if ($preferOrdered && preg_match_all('/<ol\b[^>]*>(.*?)<\/ol>/is', $body, $lists, PREG_SET_ORDER)) {
                foreach ($lists as $listMatch) {
                    foreach ($this->parseListItemsFromHtml((string) ($listMatch[0] ?? '')) as $item) {
                        $sectionItems[] = $item;
                    }
                }
            }

            if (!$preferOrdered || empty($sectionItems)) {
                if (preg_match_all('/<(?:ul|ol)\b[^>]*>(.*?)<\/(?:ul|ol)>/is', $body, $lists, PREG_SET_ORDER)) {
                    foreach ($lists as $listMatch) {
                        foreach ($this->parseListItemsFromHtml((string) ($listMatch[0] ?? '')) as $item) {
                            $sectionItems[] = $item;
                        }
                    }
                }
            }

            if (empty($sectionItems) && preg_match_all('/<(?:p|div|li)[^>]*>(.*?)<\/(?:p|div|li)>/is', $body, $lines, PREG_SET_ORDER)) {
                foreach ($lines as $lineMatch) {
                    $text = $this->cleanLine($this->plainText((string) ($lineMatch[1] ?? '')));
                    if ($text !== '') {
                        $sectionItems[] = $text;
                    }
                }
            }

            foreach ($sectionItems as $item) {
                $items[] = $item;
            }

            if (count($items) >= $limit) {
                break;
            }
        }

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

    /**
     * @param array<int,string> $classNeedles
     * @return array<int,string>
     */
    private function extractRecipeClassItemsFromHtml(string $html, array $classNeedles, int $limit = 20): array
    {
        $items = [];
        $needlePattern = implode('|', array_map(static function (string $needle): string {
            return preg_quote($needle, '/');
        }, $classNeedles));
        if ($needlePattern === '') {
            return [];
        }

        $pattern = '/<(?:li|div|p|span)[^>]*class=["\'][^"\']*(?:' . $needlePattern . ')[^"\']*["\'][^>]*>(.*?)<\/(?:li|div|p|span)>/is';
        if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($matches as $match) {
            $text = $this->cleanLine($this->plainText((string) ($match[1] ?? '')));
            if ($text === '') {
                continue;
            }
            $items[] = $text;
            if (count($items) >= $limit) {
                break;
            }
        }

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

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,array<string,mixed>>
     */
    private function extractGenericHtmlComparisonBlocks(array $flat, string $rawContent): array
    {
        $rawContent = trim($rawContent);
        if ($rawContent === '' || strpos($this->lower($rawContent), '<table') === false) {
            return [];
        }

        if ($this->hasDedicatedComparisonSource($flat)) {
            return [];
        }

        if (!preg_match_all('/<table\b[^>]*>.*?<\/table>/is', $rawContent, $tables, PREG_SET_ORDER)) {
            return [];
        }

        $blocks = [];
        foreach ($tables as $tableMatch) {
            $tableHtml = (string) ($tableMatch[0] ?? '');
            if ($tableHtml === '') {
                continue;
            }

            $linkSeeds = $this->extractVendorHtmlProductSeeds($tableHtml);
            foreach ($this->extractHtmlTableLayouts($tableHtml) as $layout) {
                if (!is_array($layout) || !$this->genericHtmlLayoutLooksLikeComparison($layout, $linkSeeds)) {
                    continue;
                }

                $rowSeeds = $this->extractGenericHtmlRowProducts($layout);
                $seeds = $this->mergeGenericHtmlSeeds($rowSeeds, $linkSeeds);

                $blocks[] = [
                    'blockName' => 'html/comparison-table',
                    'attrs' => [
                        'headers' => isset($layout['headers']) && is_array($layout['headers']) ? $layout['headers'] : [],
                        'rows' => isset($layout['rows']) && is_array($layout['rows']) ? $layout['rows'] : [],
                        'products' => $seeds,
                        'products_data' => $seeds,
                        '_html_source' => 'raw_content',
                    ],
                    'innerHTML' => '',
                    'innerBlocks' => [],
                ];
            }
        }

        return $blocks;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     */
    private function hasDedicatedComparisonSource(array $flat): bool
    {
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '' || in_array($name, ['core/freeform', 'core/html', 'core/table'], true)) {
                continue;
            }

            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $types = $this->schemaTypesForBlock($name, $attrs);
            if (in_array('product', $types, true) || in_array('review', $types, true)) {
                return true;
            }

            $haystack = $name . ' ' . $this->lower(implode(' ', array_keys($attrs)));
            if ($this->containsAny($haystack, ['comparison', 'product', 'review', 'pricing'])) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array{headers?:array<int,string>,rows?:array<int,array<string,mixed>>} $layout
     * @return array<int,array<string,mixed>>
     */
    private function extractGenericHtmlRowProducts(array $layout): array
    {
        $headers = isset($layout['headers']) && is_array($layout['headers']) ? array_values(array_map('strval', $layout['headers'])) : [];
        $rows = isset($layout['rows']) && is_array($layout['rows']) ? $layout['rows'] : [];
        $items = [];

        foreach ($rows as $index => $row) {
            if (!is_array($row)) {
                continue;
            }

            $candidate = $this->normalizeComparisonTableRow($row, $headers, $index + 1);
            if (!empty($candidate)) {
                $items[] = $candidate;
            }
        }

        return $items;
    }

    /**
     * @param array<int,array<string,mixed>> $rowSeeds
     * @param array<int,array<string,mixed>> $linkSeeds
     * @return array<int,array<string,mixed>>
     */
    private function mergeGenericHtmlSeeds(array $rowSeeds, array $linkSeeds): array
    {
        if (empty($rowSeeds)) {
            return $linkSeeds;
        }
        if (empty($linkSeeds)) {
            return $rowSeeds;
        }

        foreach ($rowSeeds as $index => $seed) {
            if (!isset($linkSeeds[$index]) || !is_array($linkSeeds[$index])) {
                continue;
            }
            $rowSeeds[$index] = $this->mergeComparisonItemData($seed, $linkSeeds[$index]);
        }

        return $rowSeeds;
    }

    /**
     * @param array{headers?:array<int,string>,rows?:array<int,array<string,mixed>>} $layout
     * @param array<int,array<string,mixed>> $seeds
     */
    private function genericHtmlLayoutLooksLikeComparison(array $layout, array $seeds): bool
    {
        if (count($seeds) >= 2) {
            return true;
        }

        $headers = isset($layout['headers']) && is_array($layout['headers']) ? array_map('strval', $layout['headers']) : [];
        $rows = isset($layout['rows']) && is_array($layout['rows']) ? $layout['rows'] : [];

        $signals = implode(' ', $headers);
        foreach (array_slice($rows, 0, 5) as $row) {
            if (!is_array($row)) {
                continue;
            }
            $cells = isset($row['cells']) && is_array($row['cells']) ? $row['cells'] : [];
            foreach (array_slice($cells, 0, 3) as $cell) {
                $signals .= ' ' . $this->scalarCellText($cell);
            }
        }

        return $this->containsAny($this->lower($signals), [
            'product', 'tool', 'service', 'price', 'pricing', 'seller', 'merchant', 'deal', 'visit', 'buy', 'rating', 'score', 'best for', 'winner', 'rank', 'pros', 'cons', 'review', 'verdict',
        ]);
    }

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

        $blocks = [];
        if (!preg_match_all('/\[([a-z0-9_-]+)([^\]]*)\](?:([\s\S]*?)\[\/\1\])?/i', $rawContent, $matches, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($matches as $match) {
            $tag = isset($match[1]) ? $this->lower((string) $match[1]) : '';
            if (!$this->isVendorShortcodeTag($tag)) {
                continue;
            }

            $attrs = $this->parseShortcodeAttributesString((string) ($match[2] ?? ''));
            $content = $this->cleanLine((string) ($match[3] ?? ''));
            if ($content !== '' && empty($attrs['content'])) {
                $attrs['content'] = $content;
            }

            $block = $this->buildVendorShortcodeBlock($tag, $attrs);
            if ($block !== null) {
                $blocks[] = $block;
            }
        }

        return $blocks;
    }

    private function isVendorShortcodeTag(string $tag): bool
    {
        if ($tag === '') {
            return false;
        }

        return $tag === 'lasso'
            || $tag === 'aawp'
            || $tag === 'amazon'
            || str_starts_with($tag, 'affiliatex')
            || str_starts_with($tag, 'reviewx');
    }

    /**
     * @param array<string,string> $attrs
     * @return array<string,mixed>|null
     */
    private function buildVendorShortcodeBlock(string $tag, array $attrs): ?array
    {
        $vendor = '';
        if ($tag === 'lasso') {
            $vendor = 'lasso';
        } elseif ($tag === 'aawp' || $tag === 'amazon') {
            $vendor = 'aawp';
        } elseif (str_starts_with($tag, 'affiliatex')) {
            $vendor = 'affiliatex';
        } elseif (str_starts_with($tag, 'reviewx')) {
            $vendor = 'reviewx';
        }
        if ($vendor === '') {
            return null;
        }

        $seeds = $this->shortcodeProductSeeds($vendor, $tag, $attrs);
        $haystack = $vendor . ' ' . $tag . ' ' . $this->lower(implode(' ', array_keys($attrs))) . ' ' . $this->lower(implode(' ', array_map('strval', $attrs)));
        $isComparison = $this->containsAny($haystack, ['comparison', 'compare', 'versus', 'table', 'roundup', 'best', 'top']) || count($seeds) > 1;

        $blockAttrs = [
            '_shortcode_vendor' => $vendor,
            '_shortcode_tag' => $tag,
            'products' => $seeds,
            'products_data' => $seeds,
        ];

        if (!empty($attrs['content'])) {
            $blockAttrs['content'] = (string) $attrs['content'];
        }
        if ($isComparison) {
            $blockAttrs['comparison_shortcode'] = '1';
        }

        return [
            'blockName' => $vendor . '/shortcode',
            'attrs' => $blockAttrs,
            'innerHTML' => '',
            'innerBlocks' => [],
        ];
    }

    /**
     * @param array<string,string> $attrs
     * @return array<int,array<string,mixed>>
     */
    private function shortcodeProductSeeds(string $vendor, string $tag, array $attrs): array
    {
        $items = [];

        foreach ($this->extractVendorSpecificShortcodeItems($vendor, $tag, $attrs) as $index => $item) {
            if (!is_array($item)) {
                continue;
            }
            $candidate = $this->normalizeProductCandidate($item);
            if (empty($candidate)) {
                continue;
            }
            if (empty($candidate['position'])) {
                $candidate['position'] = (string) ($index + 1);
            }
            $items[] = $candidate;
        }

        $tokens = [];
        foreach (['ids', 'refs', 'products', 'items', 'boxes', 'table', 'comparison', 'comparison_products', 'product_ids', 'product_refs', 'id', 'ref', 'product', 'box', 'asin', 'asins', 'asin_list', 'bestseller', 'bestsellers', 'name', 'title', 'product_name', 'product_title'] as $key) {
            if (empty($attrs[$key])) {
                continue;
            }
            foreach ($this->splitShortcodeList((string) $attrs[$key]) as $token) {
                $token = trim((string) $token);
                if ($token !== '') {
                    $tokens[] = $token;
                }
            }
        }

        $direct = [];
        foreach (['name', 'title', 'product_name', 'product_title', 'label'] as $key) {
            if (!empty($attrs[$key])) {
                $direct['name'] = $this->cleanLine((string) $attrs[$key]);
                break;
            }
        }
        foreach (['url', 'link', 'href', 'affiliate_url', 'button_url'] as $key) {
            if (!empty($attrs[$key]) && filter_var((string) $attrs[$key], FILTER_VALIDATE_URL)) {
                $direct['url'] = trim((string) $attrs[$key]);
                break;
            }
        }
        foreach (['price', 'amount'] as $key) {
            if (!empty($attrs[$key])) {
                $direct['price'] = $this->normalizePriceString((string) $attrs[$key]);
                break;
            }
        }
        foreach (['currency', 'price_currency'] as $key) {
            if (!empty($attrs[$key])) {
                $direct['currency'] = $this->normalizeCurrencyCode((string) $attrs[$key]);
                break;
            }
        }
        foreach (['seller', 'merchant', 'store'] as $key) {
            if (!empty($attrs[$key])) {
                $direct['seller'] = $this->cleanLine((string) $attrs[$key]);
                break;
            }
        }
        foreach (['rating', 'score'] as $key) {
            if (!empty($attrs[$key])) {
                $direct['rating'] = $this->normalizeNumberString((string) $attrs[$key]);
                break;
            }
        }

        $genericItems = [];
        foreach ($tokens as $index => $token) {
            $candidate = [];
            if (filter_var($token, FILTER_VALIDATE_URL)) {
                $candidate['url'] = trim($token);
                $candidate['name'] = $this->humanizeShortcodeToken((string) parse_url($token, PHP_URL_PATH));
            } else {
                $candidate['name'] = $this->humanizeShortcodeToken($token);
                if (preg_match('/^[A-Z0-9]{8,14}$/', strtoupper($token))) {
                    $candidate['sku'] = strtoupper($token);
                }
            }
            if (!empty($candidate['name']) || !empty($candidate['url']) || !empty($candidate['sku'])) {
                $candidate['position'] = (string) ($index + 1);
                $genericItems[] = $candidate;
            }
        }

        if (empty($items)) {
            $items = $genericItems;
        } else {
            foreach ($genericItems as $index => $candidate) {
                if (isset($items[$index]) && is_array($items[$index])) {
                    $items[$index] = $this->mergeComparisonItemData($items[$index], $candidate);
                } else {
                    $items[$index] = $candidate;
                }
            }
            ksort($items);
            $items = array_values($items);
        }

        if (empty($items) && !empty($direct)) {
            $items[] = $direct;
        } elseif (!empty($direct)) {
            $items[0] = $this->mergeComparisonItemData($items[0] ?? [], $direct);
        }

        foreach ($items as $index => $item) {
            if (!isset($item['position'])) {
                $items[$index]['position'] = (string) ($index + 1);
            }
        }

        return array_values(array_filter(array_map(function (array $item): array {
            if (empty($item['name']) && empty($item['url']) && empty($item['price']) && empty($item['sku'])) {
                return [];
            }
            return $item;
        }, $items)));
    }

    /**
     * @param array<string,string> $attrs
     * @return array<int,array<string,mixed>>
     */
    private function extractVendorSpecificShortcodeItems(string $vendor, string $tag, array $attrs): array
    {
        $items = [];

        $structuredKeys = ['products', 'items', 'comparison', 'comparison_products', 'comparison_table', 'comparison_items', 'product_data', 'products_data', 'table', 'rows', 'entries'];
        if ($vendor === 'lasso') {
            $structuredKeys = array_merge($structuredKeys, ['display_products', 'product_displays', 'lasso_items']);
        } elseif ($vendor === 'aawp') {
            $structuredKeys = array_merge($structuredKeys, ['box', 'boxes', 'asin', 'asins', 'asin_list', 'bestseller', 'bestsellers']);
        } elseif ($vendor === 'affiliatex') {
            $structuredKeys = array_merge($structuredKeys, ['product_boxes', 'product_table', 'top_picks']);
        } elseif ($vendor === 'reviewx') {
            $structuredKeys = array_merge($structuredKeys, ['review_products', 'comparison_products']);
        }

        foreach (array_values(array_unique($structuredKeys)) as $key) {
            if (empty($attrs[$key])) {
                continue;
            }
            foreach ($this->parseStructuredShortcodeItemsFromValue((string) $attrs[$key]) as $item) {
                if (is_array($item)) {
                    $items[] = $item;
                }
            }
        }

        $columnAttrMap = [
            'name' => ['names', 'titles', 'product_names', 'product_titles', 'labels'],
            'url' => ['urls', 'links', 'hrefs', 'affiliate_urls', 'button_urls', 'deal_urls'],
            'price' => ['prices', 'amounts'],
            'currency' => ['currencies', 'price_currencies'],
            'seller' => ['sellers', 'merchants', 'stores'],
            'rating' => ['ratings', 'scores'],
            'rank_label' => ['best_for', 'best_fors', 'rank_labels', 'winner_labels', 'badges'],
            'brand' => ['brands'],
            'sku' => ['skus', 'asins', 'asin_codes', 'mpns'],
        ];

        if ($vendor === 'aawp') {
            $columnAttrMap['sku'] = array_merge($columnAttrMap['sku'], ['asin', 'box', 'table', 'items', 'bestseller']);
        }
        if ($vendor === 'lasso') {
            $columnAttrMap['name'] = array_merge($columnAttrMap['name'], ['products']);
        }
        if ($vendor === 'affiliatex' || $vendor === 'reviewx') {
            $columnAttrMap['name'] = array_merge($columnAttrMap['name'], ['products', 'comparison_products']);
        }

        $columns = [];
        $max = 0;
        foreach ($columnAttrMap as $field => $keys) {
            foreach ($keys as $key) {
                if (empty($attrs[$key])) {
                    continue;
                }
                $values = $this->splitShortcodeList((string) $attrs[$key]);
                if (empty($values)) {
                    continue;
                }
                $normalized = [];
                foreach ($values as $value) {
                    $normalizedValue = $this->normalizeShortcodeFieldValue($field, $value);
                    if ($normalizedValue !== '') {
                        $normalized[] = $normalizedValue;
                    }
                }
                if (!empty($normalized)) {
                    $columns[$field] = $normalized;
                    $max = max($max, count($normalized));
                    break;
                }
            }
        }

        if ($max > 0) {
            for ($i = 0; $i < $max; $i++) {
                $candidate = [];
                foreach ($columns as $field => $values) {
                    if (!isset($values[$i]) || $values[$i] === '') {
                        continue;
                    }
                    $candidate[$field] = $values[$i];
                }
                if (!empty($candidate)) {
                    $items[] = $candidate;
                }
            }
        }

        if (empty($items) && $vendor === 'aawp') {
            $skuValues = [];
            foreach (['box', 'asin', 'table', 'items', 'bestseller'] as $key) {
                if (empty($attrs[$key])) {
                    continue;
                }
                foreach ($this->splitShortcodeList((string) $attrs[$key]) as $value) {
                    $value = trim((string) $value);
                    if ($value !== '') {
                        $skuValues[] = strtoupper($value);
                    }
                }
            }
            foreach (array_values(array_unique($skuValues)) as $sku) {
                $items[] = ['sku' => $sku, 'name' => $this->humanizeShortcodeToken($sku)];
            }
        }

        $normalizedItems = [];
        foreach ($items as $index => $item) {
            if (!is_array($item)) {
                continue;
            }
            $candidate = [];
            foreach ($item as $field => $value) {
                $normalizedValue = $this->normalizeShortcodeFieldValue((string) $field, $value);
                if ($normalizedValue !== '') {
                    $candidate[(string) $field] = $normalizedValue;
                }
            }
            if (!empty($candidate)) {
                if (empty($candidate['position'])) {
                    $candidate['position'] = (string) ($index + 1);
                }
                $normalizedItems[] = $candidate;
            }
        }

        return $normalizedItems;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function parseStructuredShortcodeItemsFromValue(string $value): array
    {
        $value = trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        if ($value === '') {
            return [];
        }

        $json = $this->decodeShortcodeJsonValue($value);
        if (is_array($json)) {
            if (isset($json[0]) && is_array($json[0])) {
                $items = [];
                foreach ($json as $item) {
                    if (is_array($item)) {
                        $items[] = $item;
                    }
                }
                return $items;
            }
            if ($this->looksLikeAssociativeArray($json)) {
                return [$json];
            }
        }

        $items = [];
        foreach (preg_split('/(?:\s*;\s*|
|
|
)+/', $value) ?: [] as $entry) {
            $entry = trim((string) $entry);
            if ($entry === '') {
                continue;
            }
            $candidate = $this->parseShortcodeItemEntry($entry);
            if (!empty($candidate)) {
                $items[] = $candidate;
            }
        }

        return $items;
    }

    /**
     * @return array<string,mixed>|array<int,mixed>|null
     */
    private function decodeShortcodeJsonValue(string $value): ?array
    {
        $decoded = json_decode($value, true);
        if (is_array($decoded)) {
            return $decoded;
        }
        if (str_contains($value, '&quot;')) {
            $decoded = json_decode(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'), true);
            if (is_array($decoded)) {
                return $decoded;
            }
        }
        return null;
    }

    /**
     * @return array<string,mixed>
     */
    private function parseShortcodeItemEntry(string $entry): array
    {
        $candidate = [];
        $segments = preg_split('/\s*(?:\||,)\s*/', $entry) ?: [$entry];
        foreach ($segments as $segment) {
            $segment = trim((string) $segment);
            if ($segment === '') {
                continue;
            }

            if (strpos($segment, '=') !== false) {
                [$rawKey, $rawValue] = array_pad(explode('=', $segment, 2), 2, '');
                $field = $this->shortcodeFieldAliasToCanonical($rawKey);
                if ($field === '') {
                    continue;
                }
                $value = $this->normalizeShortcodeFieldValue($field, $rawValue);
                if ($value !== '') {
                    $candidate[$field] = $value;
                }
                continue;
            }

            if (filter_var($segment, FILTER_VALIDATE_URL)) {
                $candidate['url'] = trim($segment);
                continue;
            }
            if ($this->normalizePriceString($segment) !== '') {
                $candidate['price'] = $this->normalizePriceString($segment);
                continue;
            }
            $currency = $this->normalizeCurrencyCode($segment);
            if ($currency !== '') {
                $candidate['currency'] = $currency;
                continue;
            }
            if ($this->normalizeNumberString($segment) !== '') {
                $candidate['rating'] = $this->normalizeNumberString($segment);
                continue;
            }
            if (!isset($candidate['name'])) {
                $candidate['name'] = $this->humanizeShortcodeToken($segment);
            } elseif (!isset($candidate['seller'])) {
                $candidate['seller'] = $this->cleanLine($segment);
            } else {
                $candidate['rank_label'] = $this->cleanLine($segment);
            }
        }

        return $candidate;
    }

    /**
     * @return array<int,string>
     */
    private function splitShortcodeList(string $value): array
    {
        $value = trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        if ($value === '') {
            return [];
        }

        if (($value[0] ?? '') === '[' || ($value[0] ?? '') === '{') {
            $decoded = $this->decodeShortcodeJsonValue($value);
            if (is_array($decoded)) {
                $out = [];
                foreach ($decoded as $item) {
                    if (is_scalar($item)) {
                        $out[] = trim((string) $item);
                    }
                }
                if (!empty($out)) {
                    return array_values(array_filter($out, static fn ($item) => $item !== ''));
                }
            }
        }

        $delimiter = (strpos($value, ';') !== false || strpos($value, "\n") !== false || strpos($value, "\r") !== false)
            ? '/(?:\s*;\s*|\r\n|\n|\r)+/'
            : '/\s*,\s*/';
        $parts = preg_split($delimiter, $value) ?: [$value];
        $out = [];
        foreach ($parts as $part) {
            $part = trim((string) $part, " \t\n\r\0\x0B\"'");
            if ($part !== '') {
                $out[] = $part;
            }
        }
        return array_values(array_filter($out, static fn ($item) => $item !== ''));
    }

    private function shortcodeFieldAliasToCanonical(string $field): string
    {
        $field = sanitize_key((string) $field);
        $map = [
            'name' => 'name',
            'title' => 'name',
            'label' => 'name',
            'product' => 'name',
            'product_name' => 'name',
            'product_title' => 'name',
            'url' => 'url',
            'link' => 'url',
            'href' => 'url',
            'affiliate_url' => 'url',
            'button_url' => 'url',
            'deal_url' => 'url',
            'price' => 'price',
            'amount' => 'price',
            'currency' => 'currency',
            'price_currency' => 'currency',
            'seller' => 'seller',
            'merchant' => 'seller',
            'store' => 'seller',
            'rating' => 'rating',
            'score' => 'rating',
            'best_for' => 'rank_label',
            'badge' => 'rank_label',
            'winner' => 'rank_label',
            'rank_label' => 'rank_label',
            'brand' => 'brand',
            'sku' => 'sku',
            'asin' => 'sku',
            'mpn' => 'sku',
        ];
        return $map[$field] ?? '';
    }

    /**
     * @param mixed $value
     */
    private function normalizeShortcodeFieldValue(string $field, $value): string
    {
        $field = $this->shortcodeFieldAliasToCanonical($field) ?: sanitize_key($field);
        if (is_array($value)) {
            if ($field === 'url') {
                return $this->extractCellUrl($value);
            }
            $text = $this->cleanLine($this->plainText(wp_json_encode($value)));
            return $text;
        }

        $value = trim((string) $value);
        if ($value === '') {
            return '';
        }

        if ($field === 'name') {
            return $this->humanizeShortcodeToken($value);
        }
        if ($field === 'url') {
            return filter_var($value, FILTER_VALIDATE_URL) ? trim($value) : '';
        }
        if ($field === 'price') {
            return $this->normalizePriceString($value);
        }
        if ($field === 'currency') {
            return $this->normalizeCurrencyCode($value);
        }
        if ($field === 'rating') {
            return $this->normalizeNumberString($value);
        }
        if ($field === 'sku') {
            return strtoupper(trim($value));
        }

        return $this->cleanLine($value);
    }

    /**
     * @param array<mixed> $value
     */
    private function looksLikeAssociativeArray(array $value): bool
    {
        return array_keys($value) !== range(0, count($value) - 1);
    }

    private function humanizeShortcodeToken(string $token): string
    {
        $token = trim($token);
        if ($token === '') {
            return '';
        }

        $token = trim($token, " \t\n\r\0\x0B\"'");
        if ($token === '') {
            return '';
        }

        if (preg_match('/^[A-Z0-9]{8,14}$/', strtoupper($token))) {
            return 'ASIN ' . strtoupper($token);
        }

        $token = str_replace(['_', '-', '/'], ' ', $token);
        $token = preg_replace('/\s+/u', ' ', $token) ?? $token;
        $token = trim($token);
        if ($token === '') {
            return '';
        }

        return ucwords($token);
    }

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

        if (preg_match_all("/([a-zA-Z0-9_-]+)\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s\]]+))/u", $raw, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $match) {
                $key = sanitize_key((string) ($match[1] ?? ''));
                if ($key === '') {
                    continue;
                }
                $value = (string) (($match[2] ?? '') !== '' ? $match[2] : (($match[3] ?? '') !== '' ? $match[3] : ($match[4] ?? '')));
                $attrs[$key] = trim($value);
            }
        }

        return $attrs;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,array<string,mixed>>
     */
    private function extractVendorWidgetLayoutBlocks(array $flat): array
    {
        $blocks = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $blockName = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $html = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';
            $vendor = $this->detectVendorFromHtml($blockName, $html);
            if ($vendor === '' || trim($html) === '') {
                continue;
            }

            $tableLayouts = $this->extractHtmlTableLayouts($html);
            $seeds = $this->extractVendorHtmlProductSeeds($html);
            if (empty($tableLayouts) && empty($seeds)) {
                continue;
            }

            if (empty($tableLayouts)) {
                $blocks[] = [
                    'blockName' => $vendor . '/widget-layout',
                    'attrs' => [
                        'products' => $seeds,
                        'products_data' => $seeds,
                        '_widget_source_block' => $blockName,
                    ],
                    'innerHTML' => '',
                    'innerBlocks' => [],
                ];
                continue;
            }

            foreach ($tableLayouts as $layout) {
                if (!is_array($layout)) {
                    continue;
                }
                $blocks[] = [
                    'blockName' => $vendor . '/widget-layout',
                    'attrs' => [
                        'headers' => isset($layout['headers']) && is_array($layout['headers']) ? $layout['headers'] : [],
                        'rows' => isset($layout['rows']) && is_array($layout['rows']) ? $layout['rows'] : [],
                        'products' => $seeds,
                        'products_data' => $seeds,
                        '_widget_source_block' => $blockName,
                    ],
                    'innerHTML' => '',
                    'innerBlocks' => [],
                ];
            }
        }

        return $blocks;
    }

    private function detectVendorFromHtml(string $blockName, string $html): string
    {
        $vendor = strtolower((string) strtok($blockName, '/'));
        if (in_array($vendor, ['affiliatex', 'lasso', 'aawp', 'reviewx'], true)) {
            return $vendor;
        }

        $haystack = $this->lower($html);
        foreach (['affiliatex', 'lasso', 'aawp', 'reviewx'] as $prefix) {
            if (strpos($haystack, $prefix) !== false) {
                return $prefix;
            }
        }

        return '';
    }

    /**
     * @return array<int,array{headers:array<int,string>,rows:array<int,array<string,mixed>>}>
     */
    private function extractHtmlTableLayouts(string $html): array
    {
        $layouts = [];
        if (!preg_match_all('/<table\b[^>]*>(.*?)<\/table>/is', $html, $tables, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($tables as $tableMatch) {
            $tableHtml = (string) ($tableMatch[1] ?? '');
            if (!preg_match_all('/<tr\b[^>]*>(.*?)<\/tr>/is', $tableHtml, $rows, PREG_SET_ORDER) || count($rows) < 2) {
                continue;
            }

            $headers = [];
            $normalizedRows = [];
            foreach ($rows as $rowIndex => $rowMatch) {
                $rowHtml = (string) ($rowMatch[1] ?? '');
                if (!preg_match_all('/<t([hd])\b[^>]*>(.*?)<\/t[hd]>/is', $rowHtml, $cells, PREG_SET_ORDER)) {
                    continue;
                }

                $rowCells = [];
                $rowHasHeader = false;
                foreach ($cells as $cellMatch) {
                    $cellType = strtolower((string) ($cellMatch[1] ?? 'd'));
                    $cellHtml = (string) ($cellMatch[2] ?? '');
                    $text = $this->cleanLine($this->plainText($cellHtml));
                    $url = $this->extractUrlFromHtml($cellHtml);
                    $rowCells[] = $url !== '' ? ['text' => $text, 'url' => $url] : $text;
                    if ($cellType === 'h') {
                        $rowHasHeader = true;
                    }
                }

                if ($rowIndex === 0) {
                    foreach ($rowCells as $cell) {
                        $headers[] = $this->scalarCellText($cell);
                    }
                    if ($rowHasHeader) {
                        continue;
                    }
                }

                if ($rowIndex > 0 && count($rowCells) >= 2) {
                    $normalizedRows[] = ['cells' => $rowCells];
                }
            }

            if (count($headers) >= 2 && !empty($normalizedRows)) {
                $layouts[] = [
                    'headers' => $this->uniqueStrings(array_map('strval', $headers), 20),
                    'rows' => $normalizedRows,
                ];
            }
        }

        return $layouts;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function extractVendorHtmlProductSeeds(string $html): array
    {
        $items = [];
        $seen = [];
        if (!preg_match_all('/<a\b[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', $html, $matches, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($matches as $match) {
            $url = trim((string) ($match[1] ?? ''));
            $text = $this->cleanLine($this->plainText((string) ($match[2] ?? '')));
            if ($url === '' || $text === '') {
                continue;
            }
            if ($this->containsAny($this->lower($text), ['buy now', 'buy', 'visit', 'get deal', 'read more', 'learn more', 'shop now'])) {
                continue;
            }

            $key = $this->lower($text . '|' . $url);
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $items[] = [
                'name' => $text,
                'url' => $url,
            ];
            if (count($items) >= 12) {
                break;
            }
        }

        return $items;
    }

    private function extractUrlFromHtml(string $html): string
    {
        if (preg_match('/href=["\']([^"\']+)["\']/i', $html, $match)) {
            return trim((string) ($match[1] ?? ''));
        }
        return '';
    }

    /**
     * @param array<int,string> $classNeedles
     * @return array{price:string,currency:string}
     */
    private function extractPriceCurrencyFromHtml(string $html, array $classNeedles = []): array
    {
        $candidates = [];
        if (!empty($classNeedles)) {
            $priceText = $this->extractFirstHtmlTextByClassNeedles($html, $classNeedles, ['div', 'span', 'p', 'strong', 'li', 'a']);
            if ($priceText !== '') {
                $candidates[] = $priceText;
            }
        }

        $plain = $this->plainText($html);
        if (preg_match('/((?:USD|EUR|GBP|CAD|AUD|INR|US\$|\$|€|£)\s*\d+(?:[.,]\d{1,2})?)/iu', $plain, $match)) {
            $candidates[] = (string) ($match[1] ?? '');
        }
        if (preg_match('/(\d+(?:[.,]\d{1,2})?\s*(?:USD|EUR|GBP|CAD|AUD|INR))/iu', $plain, $match)) {
            $candidates[] = (string) ($match[1] ?? '');
        }

        foreach ($candidates as $candidate) {
            $candidate = trim((string) $candidate);
            if ($candidate === '') {
                continue;
            }

            $price = $this->normalizePriceString($candidate);
            $currency = '';
            if (preg_match('/(?:USD|EUR|GBP|CAD|AUD|INR|US\$|\$|€|£)/iu', $candidate, $currencyMatch)) {
                $currency = $this->normalizeCurrencyCode((string) ($currencyMatch[0] ?? ''));
            }

            if ($price !== '') {
                return [
                    'price' => $price,
                    'currency' => $currency,
                ];
            }
        }

        return [
            'price' => '',
            'currency' => '',
        ];
    }

    /**
     * @param array<int,string> $textNeedles
     * @param array<int,string> $classNeedles
     */
    private function extractPreferredHrefFromHtml(string $html, array $textNeedles = [], array $classNeedles = []): string
    {
        $fallback = '';
        if (!preg_match_all('/<a\b([^>]*)href=["\']([^"\']+)["\']([^>]*)>(.*?)<\/a>/is', $html, $matches, PREG_SET_ORDER)) {
            return '';
        }

        foreach ($matches as $match) {
            $beforeAttrs = isset($match[1]) ? (string) $match[1] : '';
            $url = trim((string) ($match[2] ?? ''));
            $afterAttrs = isset($match[3]) ? (string) $match[3] : '';
            $linkText = $this->cleanLine($this->plainText((string) ($match[4] ?? '')));
            if ($url === '') {
                continue;
            }
            if ($fallback === '') {
                $fallback = $url;
            }

            $haystack = $this->lower($beforeAttrs . ' ' . $afterAttrs . ' ' . $linkText);
            if (!empty($classNeedles) && $this->containsAny($haystack, $classNeedles)) {
                return $url;
            }
            if (!empty($textNeedles) && $this->containsAny($this->lower($linkText), $textNeedles)) {
                return $url;
            }
        }

        return $fallback !== '' ? $fallback : $this->extractUrlFromHtml($html);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,array{block_name:string,label:string,schema_types:array<int,string>,count:int}>
     */
    private function collectBlockMapMatches(array $flat): array
    {
        $summary = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            if ($name === '') {
                continue;
            }
            $types = $this->schemaTypesForBlock($name, isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : []);
            if (empty($types)) {
                continue;
            }
            if (!isset($summary[$name])) {
                $summary[$name] = [
                    'block_name' => $name,
                    'label' => $this->labelForBlock($name),
                    'schema_types' => $types,
                    'count' => 0,
                ];
            }
            $summary[$name]['count']++;
        }

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

        return array_values($summary);
    }

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

        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $html = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';

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

            if (!in_array('faq', $this->schemaTypesForBlock($name, $attrs), true)) {
                continue;
            }

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

            $question = $this->cleanLine($this->firstNonEmpty($attrs, ['question', 'title', 'label', 'heading']));
            $answer = $this->cleanLine($this->plainText($this->firstNonEmpty($attrs, ['answer', 'content', 'description', 'text', 'body'])));
            if ($question !== '' && $answer !== '') {
                $pairs[] = ['question' => $question, 'answer' => $answer];
            }

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

        return $this->normalizeFaqPairs($pairs, 10);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,string>
     */
    private function extractHowToSteps(array $flat): array
    {
        $steps = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $html = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';

            foreach (['steps', 'items', 'instructions', 'directions', 'recipeInstructions'] as $itemsKey) {
                $items = $attrs[$itemsKey] ?? null;
                foreach ($this->flattenTextItems($items, ['text', 'content', 'description', 'name', 'title']) as $text) {
                    $steps[] = $text;
                }
            }

            if ($name === 'core/list' && !empty($attrs['ordered'])) {
                foreach ($this->parseListItemsFromHtml($html) as $item) {
                    $steps[] = $item;
                }
            }

            if (in_array('howto', $this->schemaTypesForBlock($name, $attrs), true) && $html !== '') {
                foreach ($this->parseListItemsFromHtml($html) as $item) {
                    $steps[] = $item;
                }
            }
        }

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

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,string>
     */
    private function extractRecipeIngredients(array $flat): array
    {
        $items = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];

            if (!in_array('recipe', $this->schemaTypesForBlock($name, $attrs), true)) {
                continue;
            }

            foreach (['ingredients', 'recipeIngredient', 'ingredient', 'ingredientLines'] as $key) {
                foreach ($this->flattenTextItems($attrs[$key] ?? null, ['text', 'name', 'ingredient', 'content']) as $text) {
                    $items[] = $text;
                }
            }
        }
        return $this->uniqueStrings($items, 20);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<int,string>
     */
    private function extractRecipeInstructions(array $flat): array
    {
        $items = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $html = isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';

            if (!in_array('recipe', $this->schemaTypesForBlock($name, $attrs), true)) {
                continue;
            }

            foreach (['instructions', 'recipeInstructions', 'directions', 'steps', 'method'] as $key) {
                foreach ($this->flattenTextItems($attrs[$key] ?? null, ['text', 'name', 'content', 'description']) as $text) {
                    $items[] = $text;
                }
            }
            foreach ($this->parseListItemsFromHtml($html) as $item) {
                $items[] = $item;
            }
        }
        return $this->uniqueStrings($items, 12);
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<string,mixed>
     */
    private function extractEventData(array $flat): array
    {
        $data = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (!in_array('event', $this->schemaTypesForBlock($name, $attrs), true)) {
                continue;
            }

            if (!isset($data['name'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['event_name', 'name', 'title', 'heading', 'label']));
                if ($value !== '') {
                    $data['name'] = $value;
                }
            }
            if (!isset($data['start_date'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['startDate', 'start_date', 'date', 'date_start', 'event_date', 'eventDate']));
                if ($value !== '') {
                    $data['start_date'] = $value;
                }
            }
            if (!isset($data['end_date'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['endDate', 'end_date', 'date_end', 'event_end', 'eventEndDate']));
                if ($value !== '') {
                    $data['end_date'] = $value;
                }
            }
            if (!isset($data['location'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['location', 'locationName', 'venue', 'venue_name', 'address']));
                if ($value !== '') {
                    $data['location'] = $value;
                }
            }
            if (!isset($data['attendance_mode'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['eventAttendanceMode', 'attendanceMode', 'attendance_mode', 'mode']));
                if ($value !== '') {
                    $data['attendance_mode'] = $value;
                }
            }
            if (!isset($data['description'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['description', 'content', 'text', 'body']));
                if ($value !== '') {
                    $data['description'] = $value;
                }
            }
        }
        return $data;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<string,mixed>
     */
    private function extractReviewData(array $flat): array
    {
        $data = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (!in_array('review', $this->schemaTypesForBlock($name, $attrs), true)) {
                continue;
            }

            $sources = [$attrs];
            foreach (['review', 'product', 'itemReviewed', 'item', 'rating', 'aggregateRating'] as $sourceKey) {
                if (isset($attrs[$sourceKey]) && is_array($attrs[$sourceKey])) {
                    $sources[] = $attrs[$sourceKey];
                }
            }

            if (!isset($data['item_name'])) {
                $value = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['itemReviewed', 'subject', 'product_name', 'product', 'service', 'thing', 'item_name', 'name', 'title']));
                if ($value !== '') {
                    $data['item_name'] = $value;
                }
            }
            if (!isset($data['rating'])) {
                $value = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['ratingValue', 'rating', 'score', 'stars']));
                if ($value !== '') {
                    $data['rating'] = $value;
                }
            }
            if (!isset($data['best_rating'])) {
                $value = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['bestRating', 'maxRating', 'best_rating']));
                if ($value !== '') {
                    $data['best_rating'] = $value;
                }
            }
            if (!isset($data['worst_rating'])) {
                $value = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['worstRating', 'minRating', 'worst_rating']));
                if ($value !== '') {
                    $data['worst_rating'] = $value;
                }
            }
            if (!isset($data['review_body'])) {
                $value = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['reviewBody', 'review_body', 'summary', 'content', 'description', 'text', 'body']));
                if ($value !== '') {
                    $data['review_body'] = $value;
                }
            }
            if (!isset($data['reviewer'])) {
                $value = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['reviewer', 'author', 'reviewAuthor']));
                if ($value !== '') {
                    $data['reviewer'] = $value;
                }
            }
            if (!isset($data['pros'])) {
                $pros = $this->flattenTextItems($attrs['pros'] ?? ($attrs['advantages'] ?? ($attrs['positives'] ?? null)), ['text', 'label', 'title', 'content']);
                if (!empty($pros)) {
                    $data['pros'] = $pros;
                }
            }
            if (!isset($data['cons'])) {
                $cons = $this->flattenTextItems($attrs['cons'] ?? ($attrs['disadvantages'] ?? ($attrs['negatives'] ?? null)), ['text', 'label', 'title', 'content']);
                if (!empty($cons)) {
                    $data['cons'] = $cons;
                }
            }
        }
        return $data;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<string,mixed>
     */
    private function extractProductData(array $flat): array
    {
        $data = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            $types = $this->schemaTypesForBlock($name, $attrs);
            if (!in_array('product', $types, true) && !in_array('review', $types, true)) {
                continue;
            }

            $candidate = $this->normalizeProductCandidate($attrs, $block);
            if (empty($candidate)) {
                continue;
            }

            foreach ($candidate as $key => $value) {
                if (!isset($data[$key]) || $data[$key] === '') {
                    $data[$key] = $value;
                }
            }
        }

        return $data;
    }

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

        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }

            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if ($name === '' || empty($attrs)) {
                continue;
            }

            $types = $this->schemaTypesForBlock($name, $attrs);
            $haystack = $name . ' ' . $this->lower(implode(' ', array_keys($attrs)));
            $looksLikeComparison = $this->containsAny($haystack, ['comparison', 'compare', 'versus', 'toplist', 'top-list', 'best', 'table', 'roundup'])
                || isset($attrs['products'])
                || isset($attrs['items'])
                || isset($attrs['productList'])
                || isset($attrs['product_list'])
                || isset($attrs['comparisonTable'])
                || isset($attrs['comparison_table'])
                || isset($attrs['table'])
                || isset($attrs['rows']);

            if (!$looksLikeComparison && !in_array('product', $types, true)) {
                continue;
            }

            $headers = $this->comparisonHeadersFromAttrs($attrs);
            foreach ($this->extractVendorSpecificComparisonTableItems($name, $attrs) as $candidate) {
                if (!is_array($candidate) || empty($candidate)) {
                    continue;
                }
                $this->mergeComparisonCandidate($items, $lookup, $candidate, $name);
                if (count($items) >= 12) {
                    break;
                }
            }
            if (count($items) >= 12) {
                break;
            }

            foreach ($this->extractVendorSpecificComparisonItems($name, $attrs) as $candidate) {
                if (!is_array($candidate) || empty($candidate)) {
                    continue;
                }
                $this->mergeComparisonCandidate($items, $lookup, $candidate, $name);
                if (count($items) >= 12) {
                    break;
                }
            }
            if (count($items) >= 12) {
                break;
            }

            $candidateLists = [];

            foreach (['items', 'products', 'productList', 'product_list', 'comparison', 'comparisonTable', 'comparison_table', 'rows', 'cards', 'columns', 'slides', 'list', 'entries', 'tableRows', 'body', 'tbody', 'data'] as $key) {
                if (!empty($attrs[$key]) && is_array($attrs[$key])) {
                    $candidateLists[] = $attrs[$key];
                }
            }

            foreach (['table', 'comparisonTable', 'comparison_table'] as $key) {
                if (!empty($attrs[$key]) && is_array($attrs[$key])) {
                    $tableRows = $this->extractTableRowCandidates($attrs[$key], $headers);
                    if (!empty($tableRows)) {
                        $candidateLists[] = $tableRows;
                    }
                }
            }

            $directTableRows = $this->extractTableRowCandidates($attrs, $headers);
            if (!empty($directTableRows)) {
                $candidateLists[] = $directTableRows;
            }

            foreach ($candidateLists as $list) {
                if ($this->rowSetLooksLikeComparisonMatrix($list)) {
                    continue;
                }
                foreach ($list as $rowIndex => $item) {
                    if (!is_array($item)) {
                        continue;
                    }
                    $candidate = $this->normalizeProductCandidate($item, $block);
                    $tableCandidate = $this->normalizeComparisonTableRow($item, $headers, $rowIndex + 1);
                    if (empty($candidate)) {
                        $candidate = $tableCandidate;
                    } elseif (!empty($tableCandidate)) {
                        $candidate = $this->mergeComparisonItemData($candidate, $tableCandidate);
                    }
                    if (empty($candidate)) {
                        continue;
                    }

                    $this->mergeComparisonCandidate($items, $lookup, $candidate, $name);
                    if (count($items) >= 12) {
                        break 3;
                    }
                }
            }

            foreach ($this->extractComparisonMatrixItems($attrs, $headers, $name) as $candidate) {
                if (!is_array($candidate) || empty($candidate)) {
                    continue;
                }
                $this->mergeComparisonCandidate($items, $lookup, $candidate, $name);
                if (count($items) >= 12) {
                    break 2;
                }
            }

            foreach ($this->extractComparisonCardItems($attrs, $name) as $candidate) {
                if (!is_array($candidate) || empty($candidate)) {
                    continue;
                }
                $this->mergeComparisonCandidate($items, $lookup, $candidate, $name);
                if (count($items) >= 12) {
                    break 2;
                }
            }
        }

        return array_slice(array_values($items), 0, 12);
    }

    /**
     * @param array<int,array<string,mixed>> $items
     * @param array<string,int> $lookup
     * @param array<string,mixed> $candidate
     */
    private function mergeComparisonCandidate(array &$items, array &$lookup, array $candidate, string $sourceBlock): void
    {
        $candidate['source_block'] = $sourceBlock;
        $keys = $this->comparisonLookupKeys($candidate, $sourceBlock);

        foreach ($keys as $key) {
            if (!isset($lookup[$key])) {
                continue;
            }

            $index = (int) $lookup[$key];
            if (!isset($items[$index]) || !is_array($items[$index])) {
                continue;
            }

            $items[$index] = $this->mergeComparisonItemData($items[$index], $candidate);
            foreach ($this->comparisonLookupKeys($items[$index], $sourceBlock) as $mergedKey) {
                $lookup[$mergedKey] = $index;
            }
            return;
        }

        $items[] = $candidate;
        $index = count($items) - 1;
        foreach ($keys as $key) {
            $lookup[$key] = $index;
        }
    }

    /**
     * @param array<string,mixed> $base
     * @param array<string,mixed> $incoming
     * @return array<string,mixed>
     */
    private function mergeComparisonItemData(array $base, array $incoming): array
    {
        foreach ($incoming as $key => $value) {
            if (!array_key_exists($key, $base) || $base[$key] === '' || $base[$key] === null || $base[$key] === []) {
                $base[$key] = $value;
                continue;
            }

            if (!is_array($base[$key]) || !is_array($value)) {
                continue;
            }

            $merged = [];
            foreach (array_merge($base[$key], $value) as $item) {
                if (!is_scalar($item)) {
                    continue;
                }
                $text = $this->cleanLine((string) $item);
                if ($text !== '') {
                    $merged[] = $text;
                }
            }
            $base[$key] = $this->uniqueStrings($merged, 10);
        }

        if (!empty($incoming['position']) && empty($base['position'])) {
            $base['position'] = $incoming['position'];
        }

        return $base;
    }

    /**
     * @param array<string,mixed> $candidate
     * @return array<int,string>
     */
    private function comparisonLookupKeys(array $candidate, string $sourceBlock): array
    {
        $keys = [];

        $signature = $this->comparisonItemSignature($candidate);
        if ($signature !== '') {
            $keys[] = 'sig:' . $signature;
        }

        $name = isset($candidate['name']) ? $this->lower(trim((string) $candidate['name'])) : '';
        if ($name !== '') {
            $keys[] = 'name:' . $name;
        }

        $url = isset($candidate['url']) ? $this->lower(trim((string) $candidate['url'])) : '';
        if ($url !== '') {
            $keys[] = 'url:' . $url;
        }

        $position = isset($candidate['position']) ? trim((string) $candidate['position']) : '';
        if ($position !== '') {
            $keys[] = 'pos:' . $this->lower($sourceBlock) . ':' . $position;
        }

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

    /**
     * @param array<string,mixed> $attrs
     * @param array<int,string> $headers
     * @return array<int,array<string,mixed>>
     */
    private function extractComparisonMatrixItems(array $attrs, array $headers, string $blockName): array
    {
        $rows = $this->extractTableRowCandidates($attrs, $headers);
        if (count($headers) < 3 || count($rows) < 2) {
            return [];
        }

        $firstHeader = isset($headers[0]) ? $this->lower($headers[0]) : '';
        $looksLikeMatrix = $this->containsAny($firstHeader, ['feature', 'criteria', 'spec', 'metric', 'attribute', 'details'])
            || $this->rowSetLooksLikeComparisonMatrix($rows);
        if (!$looksLikeMatrix) {
            return [];
        }

        $items = [];
        foreach (array_slice($headers, 1) as $columnIndex => $headerLabel) {
            $name = $this->cleanLine($headerLabel);
            if ($name === '') {
                continue;
            }

            $candidate = [
                'name' => $name,
                'position' => (string) ($columnIndex + 1),
                'source_block' => $blockName,
            ];

            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }

                $cells = $this->extractOrderedCells($row);
                if (count($cells) <= ($columnIndex + 1)) {
                    continue;
                }

                $rowLabel = $this->scalarCellText($cells[0] ?? '');
                $field = $this->headerLabelToField($rowLabel);
                $cell = $cells[$columnIndex + 1];
                $cellText = $this->scalarCellText($cell);
                $cellUrl = $this->extractCellUrl($cell);

                if ($field === 'name' && $cellText !== '') {
                    $candidate['name'] = $cellText;
                    continue;
                }
                if ($field === 'url' && $cellUrl !== '') {
                    $candidate['url'] = $cellUrl;
                    continue;
                }
                if ($field !== '' && $cellText !== '') {
                    if ($field === 'feature') {
                        $candidate['feature_summary'][] = $rowLabel !== '' ? ($rowLabel . ': ' . $cellText) : $cellText;
                    } else {
                        $candidate[$field] = $cellText;
                    }
                    continue;
                }
                if ($rowLabel !== '' && $cellText !== '' && !$this->containsAny($this->lower($rowLabel), ['product', 'name', 'title'])) {
                    $candidate['feature_summary'][] = $rowLabel . ': ' . $cellText;
                }
            }

            if (!empty($candidate['feature_summary']) && is_array($candidate['feature_summary'])) {
                $candidate['feature_summary'] = $this->uniqueStrings(array_map('strval', $candidate['feature_summary']), 8);
            }

            $candidate = $this->normalizeProductCandidate($candidate);
            if (!empty($candidate)) {
                $items[] = $candidate;
            }
        }

        return $items;
    }

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,array<string,mixed>>
     */
    private function extractComparisonCardItems(array $attrs, string $blockName): array
    {
        $items = [];
        foreach (['columns', 'cards', 'slides'] as $key) {
            if (empty($attrs[$key]) || !is_array($attrs[$key])) {
                continue;
            }

            foreach ($attrs[$key] as $index => $column) {
                if (!is_array($column)) {
                    continue;
                }

                $candidate = $this->normalizeProductCandidate($column);
                if (empty($candidate) && isset($column['cells']) && is_array($column['cells'])) {
                    $candidate = $this->normalizeComparisonTableRow($column, [], $index + 1);
                }
                if (empty($candidate)) {
                    continue;
                }
                if (empty($candidate['position'])) {
                    $candidate['position'] = (string) ($index + 1);
                }
                $candidate['source_block'] = $blockName;
                $items[] = $candidate;
            }
        }

        return $items;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function rowSetLooksLikeComparisonMatrix(array $rows): bool
    {
        $signals = 0;
        foreach (array_slice($rows, 0, 5) as $row) {
            if (!is_array($row)) {
                continue;
            }

            $cells = $this->extractOrderedCells($row);
            if (count($cells) < 3) {
                continue;
            }

            $label = $this->lower($this->scalarCellText($cells[0] ?? ''));
            if ($this->containsAny($label, ['price', 'seller', 'link', 'deal', 'rating', 'rank', 'best for', 'brand', 'sku'])) {
                $signals++;
            }
        }

        return $signals >= 2;
    }

    /**
     * @param array<string,mixed> $row
     * @return array<int,mixed>
     */
    private function extractOrderedCells(array $row): array
    {
        $rowLabel = $this->cleanLine($this->firstNonEmpty($row, ['label', 'title', 'name', 'heading', 'header', 'key', 'feature', 'spec', 'attribute']));
        foreach (['cells', 'columns', 'values', 'data', 'items'] as $key) {
            if (empty($row[$key]) || !is_array($row[$key])) {
                continue;
            }

            $cells = array_values($row[$key]);
            if ($rowLabel !== '' && in_array($key, ['values', 'data', 'items'], true)) {
                array_unshift($cells, $rowLabel);
            }
            return $cells;
        }

        $cells = [];
        foreach ($row as $key => $value) {
            if (is_int($key)) {
                $cells[] = $value;
            }
        }

        return $cells;
    }

    /**
     * @param mixed $cell
     */
    private function extractCellUrl($cell): string
    {
        if (is_array($cell)) {
            $url = trim($this->firstNonEmpty($cell, ['url', 'href', 'link', 'buttonUrl', 'productUrl', 'affiliateUrl', 'affiliate_url']));
            if ($url !== '') {
                return $url;
            }
            foreach ($cell as $value) {
                if (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
                    return trim($value);
                }
            }
        }
        if (is_string($cell) && filter_var($cell, FILTER_VALIDATE_URL)) {
            return trim($cell);
        }

        return '';
    }

    /**
     * @param array<string,mixed> $item
     * @param array<string,mixed>|null $block
     * @return array<string,mixed>
     */
    private function normalizeProductCandidate(array $item, ?array $block = null): array
    {
        $sources = [$item];
        foreach (['product', 'itemReviewed', 'item', 'offer', 'offers', 'pricing', 'price', 'meta', 'data', 'cta', 'button', 'link', 'deal', 'details', 'table', 'merchant', 'seller_info', 'shop'] as $sourceKey) {
            if (isset($item[$sourceKey]) && is_array($item[$sourceKey])) {
                $sources[] = $item[$sourceKey];
            }
        }
        if (is_array($block) && isset($block['attrs']) && is_array($block['attrs'])) {
            $sources[] = $block['attrs'];
        }

        $candidate = [];
        $candidate['name'] = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['product_name', 'productName', 'item_name', 'itemName', 'productTitle', 'product_title', 'title', 'heading', 'headingText', 'header', 'label', 'name', 'boxTitle', 'offerTitle', 'itemTitle', 'content']));
        $candidate['description'] = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['description', 'shortDescription', 'short_description', 'summary', 'snippet', 'subTitle', 'subtitle', 'text', 'body', 'content', 'tagline']));
        $candidate['brand'] = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['brand', 'brandName', 'manufacturer', 'provider', 'company', 'vendorName']));
        $candidate['sku'] = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['sku', 'productSku', 'itemSku', 'asin', 'ASIN', 'mpn', 'productId', 'productID']));
        $candidate['price'] = $this->normalizePriceString($this->firstNonEmptyFromSources($sources, ['price', 'sale_price', 'regular_price', 'amount', 'productPrice', 'salePrice', 'regularPrice', 'currentPrice', 'displayPrice', 'formattedPrice', 'finalPrice', 'offerPrice', 'discountedPrice', 'dealPrice', 'amountText', 'startingPrice']));
        $candidate['low_price'] = $this->normalizePriceString($this->firstNonEmptyFromSources($sources, ['lowPrice', 'minPrice', 'fromPrice', 'priceFrom', 'min_price']));
        $candidate['high_price'] = $this->normalizePriceString($this->firstNonEmptyFromSources($sources, ['highPrice', 'maxPrice', 'toPrice', 'priceTo', 'max_price']));
        $candidate['offer_count'] = $this->normalizeNumberString($this->firstNonEmptyFromSources($sources, ['offerCount', 'offersCount', 'merchantCount', 'sellerCount', 'vendorCount']));
        $candidate['currency'] = $this->normalizeCurrencyCode($this->cleanLine($this->firstNonEmptyFromSources($sources, ['currency', 'priceCurrency', 'currencyCode', 'currencySymbol'])));
        $candidate['url'] = trim($this->firstNonEmptyFromSources($sources, ['url', 'permalink', 'productUrl', 'product_url', 'productLink', 'productURL', 'link', 'href', 'buttonUrl', 'buttonURL', 'buttonLink', 'affiliate_url', 'affiliateUrl', 'detailPageURL', 'dealUrl', 'targetUrl', 'ctaUrl', 'shopUrl', 'externalUrl', 'buyUrl']));
        if ($candidate['url'] === '') {
            foreach ($sources as $source) {
                $candidate['url'] = $this->extractCellUrl($source);
                if ($candidate['url'] !== '') {
                    break;
                }
            }
        }
        $candidate['image'] = trim($this->firstNonEmptyFromSources($sources, ['image', 'image_url', 'imageUrl', 'featuredImage', 'featured_image', 'thumbnail', 'thumbnail_url', 'thumb']));
        $candidate['availability'] = trim($this->firstNonEmptyFromSources($sources, ['availability', 'availabilityText', 'stockStatus', 'stock_status', 'inStock']));
        $candidate['seller'] = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['seller', 'merchant', 'merchantName', 'sellerName', 'shop', 'store', 'storeName', 'retailer', 'shopName']));
        $candidate['price_valid_until'] = trim($this->firstNonEmptyFromSources($sources, ['priceValidUntil', 'validUntil', 'offerValidUntil', 'expiryDate', 'expiresAt', 'dealEnds']));
        $candidate['condition'] = trim($this->firstNonEmptyFromSources($sources, ['itemCondition', 'item_condition', 'condition']));
        $candidate['position'] = $this->normalizePositionValue($this->firstNonEmptyFromSources($sources, ['position', 'listPosition', 'rank', 'ranking', 'order', 'index', 'place']));
        $candidate['rank_label'] = $this->cleanLine($this->firstNonEmptyFromSources($sources, ['rank_label', 'badge', 'badgeText', 'award', 'awardText', 'ribbon', 'ribbonText', 'winnerLabel', 'bestFor', 'verdict', 'tag', 'tagText', 'recommendation']));
        $candidate['rating'] = $this->normalizeNumberString($this->firstNonEmptyFromSources($sources, ['rating', 'ratingValue', 'avgRating', 'averageRating', 'score', 'scoreValue', 'stars', 'starRating']));
        $candidate['review_count'] = $this->normalizeNumberString($this->firstNonEmptyFromSources($sources, ['reviewCount', 'ratingCount', 'ratings_count', 'totalReviews', 'totalRatings']));

        $prosSource = $item['pros'] ?? ($item['advantages'] ?? ($item['positives'] ?? ($item['prosList'] ?? ($item['pros_list'] ?? ((isset($item['pros_cons']) && is_array($item['pros_cons'])) ? ($item['pros_cons']['pros'] ?? null) : null)))));
        $consSource = $item['cons'] ?? ($item['disadvantages'] ?? ($item['negatives'] ?? ($item['consList'] ?? ($item['cons_list'] ?? ((isset($item['pros_cons']) && is_array($item['pros_cons'])) ? ($item['pros_cons']['cons'] ?? null) : null)))));
        $candidate['pros'] = $this->flattenTextItems($prosSource, ['text', 'label', 'title', 'content', 'name']);
        $candidate['cons'] = $this->flattenTextItems($consSource, ['text', 'label', 'title', 'content', 'name']);

        $features = [];
        foreach (['features', 'feature_list', 'highlights', 'bullets', 'benefits', 'summary_points', 'specifications', 'specs', 'attributes', 'details', 'comparison_points', 'key_features', 'facts'] as $featureKey) {
            if (!isset($item[$featureKey])) {
                continue;
            }
            foreach ($this->flattenTextItems($item[$featureKey], ['text', 'label', 'title', 'content', 'name', 'value']) as $feature) {
                $features[] = $feature;
            }
        }
        if (!empty($features)) {
            $candidate['feature_summary'] = $this->uniqueStrings($features, 10);
        }

        $candidate = $this->enrichProductCandidateFromLabeledCollections($candidate, $item);

        if ($candidate['position'] === '' && $candidate['rank_label'] !== '') {
            $candidate['position'] = $this->normalizePositionValue($candidate['rank_label']);
        }

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

        if (empty($candidate['name']) && empty($candidate['url']) && empty($candidate['price']) && empty($candidate['low_price']) && empty($candidate['high_price'])) {
            return [];
        }

        return $candidate;
    }

    /**
     * @param array<string,mixed> $candidate
     * @param array<string,mixed> $source
     * @return array<string,mixed>
     */
    private function enrichProductCandidateFromLabeledCollections(array $candidate, array $source): array
    {
        $collections = [];
        foreach (['features', 'feature_list', 'rows', 'specifications', 'specs', 'attributes', 'details', 'facts', 'comparison', 'tableRows', 'table_rows', 'table', 'comparisonTable', 'comparison_table', 'meta_fields'] as $key) {
            if (!isset($source[$key]) || !is_array($source[$key])) {
                continue;
            }
            $collections[] = $source[$key];
        }

        if (empty($collections)) {
            return $candidate;
        }

        $featureSummary = isset($candidate['feature_summary']) && is_array($candidate['feature_summary']) ? array_map('strval', $candidate['feature_summary']) : [];
        foreach ($collections as $collection) {
            foreach ($this->extractLabeledValuePairs($collection) as $pair) {
                if (!is_array($pair)) {
                    continue;
                }
                $label = isset($pair['label']) ? $this->cleanLine((string) $pair['label']) : '';
                $value = isset($pair['value']) ? $this->cleanLine((string) $pair['value']) : '';
                $url = isset($pair['url']) ? trim((string) $pair['url']) : '';
                if ($label === '' && $value === '' && $url === '') {
                    continue;
                }

                $field = $this->headerLabelToField($label);
                if ($field === 'position' && empty($candidate['position'])) {
                    $candidate['position'] = $this->normalizePositionValue($value);
                    continue;
                }
                if ($field === 'price' && empty($candidate['price'])) {
                    $candidate['price'] = $this->normalizePriceString($value);
                    continue;
                }
                if ($field === 'currency' && empty($candidate['currency'])) {
                    $candidate['currency'] = $this->normalizeCurrencyCode($value);
                    continue;
                }
                if ($field === 'url' && empty($candidate['url'])) {
                    $candidate['url'] = $url !== '' ? $url : (filter_var($value, FILTER_VALIDATE_URL) ? trim($value) : '');
                    continue;
                }
                if ($field === 'rating' && empty($candidate['rating'])) {
                    $candidate['rating'] = $this->normalizeNumberString($value);
                    continue;
                }
                if ($field === 'rank_label' && empty($candidate['rank_label'])) {
                    $candidate['rank_label'] = $this->cleanLine($value);
                    continue;
                }
                if (in_array($field, ['seller', 'brand', 'sku', 'availability'], true) && empty($candidate[$field])) {
                    $candidate[$field] = $this->cleanLine($value);
                    continue;
                }
                if ($field === 'feature' && $value !== '') {
                    $featureSummary[] = $label !== '' ? ($label . ': ' . $value) : $value;
                    continue;
                }
                if ($value !== '' && $label !== '' && !$this->containsAny($this->lower($label), ['product', 'name', 'title'])) {
                    $featureSummary[] = $label . ': ' . $value;
                }
            }
        }

        if (!empty($featureSummary)) {
            $candidate['feature_summary'] = $this->uniqueStrings($featureSummary, 10);
        }

        return $candidate;
    }

    /**
     * @param mixed $value
     * @return array<int,array{label:string,value:string,url:string}>
     */
    private function extractLabeledValuePairs($value, int $depth = 0): array
    {
        if (!is_array($value) || $depth > 3) {
            return [];
        }

        $pairs = [];
        $label = $this->cleanLine($this->firstNonEmpty($value, ['label', 'title', 'name', 'heading', 'header', 'key', 'feature', 'spec', 'attribute']));
        $valueText = $this->cleanLine($this->plainText($this->firstNonEmpty($value, ['value', 'text', 'content', 'description', 'summary', 'detail', 'details', 'result', 'answer'])));
        $valueUrl = $this->extractCellUrl($value);
        $hasDirectPair = false;
        if ($label !== '' && ($valueText !== '' || $valueUrl !== '')) {
            $pairs[] = [
                'label' => $label,
                'value' => $valueText,
                'url' => $valueUrl,
            ];
            $hasDirectPair = true;
        }

        $cells = $this->extractOrderedCells($value);
        $rowLike = isset($value['cells']) || isset($value['columns']) || isset($value['values']) || isset($value['data']) || isset($value['items']);
        if (!$rowLike && count($cells) >= 2) {
            $nestedLabelValueRows = 0;
            foreach ($cells as $cell) {
                if (is_array($cell)) {
                    $nestedLabel = $this->cleanLine($this->firstNonEmpty($cell, ['label', 'title', 'name', 'key']));
                    $nestedValue = $this->cleanLine($this->firstNonEmpty($cell, ['value', 'text', 'content', 'description', 'summary']));
                    if ($nestedLabel !== '' && ($nestedValue !== '' || $this->extractCellUrl($cell) !== '')) {
                        $nestedLabelValueRows++;
                    }
                }
            }
            $rowLike = $nestedLabelValueRows === 0;
        }
        if ($rowLike && count($cells) >= 2) {
            $cellLabel = $this->scalarCellText($cells[0]);
            $cellValues = [];
            $cellUrl = '';
            foreach (array_slice($cells, 1) as $cell) {
                $text = $this->scalarCellText($cell);
                if ($text !== '') {
                    $cellValues[] = $text;
                }
                if ($cellUrl === '') {
                    $cellUrl = $this->extractCellUrl($cell);
                }
            }
            $cellValue = implode(' · ', $this->uniqueStrings($cellValues, 4));
            if ($cellLabel !== '' && ($cellValue !== '' || $cellUrl !== '')) {
                $pairs[] = [
                    'label' => $cellLabel,
                    'value' => $cellValue,
                    'url' => $cellUrl,
                ];
            }
        }

        $stringKeyCount = 0;
        foreach (array_keys($value) as $key) {
            if (!is_int($key)) {
                $stringKeyCount++;
            }
        }
        if (!$hasDirectPair && $stringKeyCount >= 2) {
            foreach ($value as $key => $item) {
                if (is_int($key)) {
                    continue;
                }
                $labelKey = $this->cleanLine((string) $key);
                if ($labelKey === '' || in_array($labelKey, ['rows', 'items', 'products', 'table', 'cells', 'columns', 'data', 'attributes', 'details', 'label', 'value', 'text', 'content', 'description', 'summary', 'url', 'href', 'name', 'title'], true)) {
                    continue;
                }
                $text = $this->scalarCellText($item);
                $url = $this->extractCellUrl($item);
                if ($text === '' && $url === '') {
                    continue;
                }
                $pairs[] = [
                    'label' => $labelKey,
                    'value' => $text,
                    'url' => $url,
                ];
            }
        }

        foreach ($value as $item) {
            if (!is_array($item)) {
                continue;
            }
            foreach ($this->extractLabeledValuePairs($item, $depth + 1) as $pair) {
                $pairs[] = $pair;
            }
        }

        return $pairs;
    }

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,array<string,mixed>>
     */
    private function extractVendorSpecificComparisonItems(string $blockName, array $attrs): array
    {
        $vendor = $this->blockVendorLabel($blockName);
        if (!in_array($vendor, ['AffiliateX', 'Lasso', 'AAWP', 'ReviewX'], true)) {
            return [];
        }

        $items = [];
        foreach ($this->vendorSpecificItemCollections($blockName, $attrs) as $collection) {
            foreach ($collection as $index => $item) {
                if (!is_array($item)) {
                    continue;
                }
                $candidate = $this->normalizeProductCandidate($item, ['attrs' => $attrs]);
                $candidate = $this->enrichProductCandidateFromLabeledCollections($candidate, $item);
                if (empty($candidate)) {
                    continue;
                }
                if (empty($candidate['position'])) {
                    $candidate['position'] = (string) ($index + 1);
                }
                $candidate['source_block'] = $blockName;
                $items[] = $candidate;
                if (count($items) >= 12) {
                    return $items;
                }
            }
        }

        return $items;
    }

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,array<int,array<string,mixed>>>
     */
    private function vendorSpecificItemCollections(string $blockName, array $attrs): array
    {
        $keys = ['items', 'products', 'cards', 'columns', 'slides'];

        if (str_starts_with($blockName, 'affiliatex/')) {
            $keys = array_merge($keys, ['productBoxes', 'product_boxes', 'productTable', 'product_table', 'comparisonProducts', 'comparison_products', 'productsData', 'products_data', 'topPicks', 'top_picks']);
        } elseif (str_starts_with($blockName, 'lasso/')) {
            $keys = array_merge($keys, ['displayProducts', 'display_products', 'tableItems', 'table_items', 'productDisplays', 'product_displays', 'productsData', 'products_data']);
        } elseif (str_starts_with($blockName, 'aawp/')) {
            $keys = array_merge($keys, ['boxes', 'productsData', 'products_data', 'tableItems', 'table_items']);
        } elseif (str_starts_with($blockName, 'reviewx/')) {
            $keys = array_merge($keys, ['comparisonProducts', 'comparison_products', 'reviewProducts', 'review_products', 'productsData', 'products_data']);
        }

        $collections = [];
        foreach (array_values(array_unique($keys)) as $key) {
            if (empty($attrs[$key]) || !is_array($attrs[$key])) {
                continue;
            }
            $rows = [];
            foreach ($attrs[$key] as $item) {
                if (is_array($item)) {
                    $rows[] = $item;
                }
            }
            if (!empty($rows)) {
                $collections[] = $rows;
            }
        }

        return $collections;
    }


    /**
     * @param array<string,mixed> $attrs
     * @return array<int,array<string,mixed>>
     */
    private function extractVendorSpecificComparisonTableItems(string $blockName, array $attrs): array
    {
        $vendor = $this->blockVendorLabel($blockName);
        if (!in_array($vendor, ['AffiliateX', 'Lasso', 'AAWP', 'ReviewX'], true)) {
            return [];
        }

        $items = [];
        $seedItems = $this->vendorSpecificProductSeeds($blockName, $attrs);
        foreach ($this->vendorSpecificTableLayouts($blockName, $attrs) as $layout) {
            if (!is_array($layout)) {
                continue;
            }

            $headers = isset($layout['headers']) && is_array($layout['headers']) ? array_values(array_map('strval', $layout['headers'])) : [];
            $rows = isset($layout['rows']) && is_array($layout['rows']) ? $layout['rows'] : [];
            if (count($headers) < 2 || empty($rows)) {
                continue;
            }

            if ($this->headersLookLikeProductColumns($headers) || $this->rowSetLooksLikeComparisonMatrix($rows)) {
                foreach ($this->extractComparisonMatrixItemsFromRows($rows, $headers, $blockName, $seedItems) as $candidate) {
                    if (!empty($candidate)) {
                        $items[] = $candidate;
                    }
                    if (count($items) >= 12) {
                        return $items;
                    }
                }
                continue;
            }

            foreach ($rows as $rowIndex => $row) {
                if (!is_array($row)) {
                    continue;
                }

                $candidate = $this->normalizeComparisonTableRow($row, $headers, $rowIndex + 1);
                if (!empty($seedItems[$rowIndex]) && is_array($seedItems[$rowIndex])) {
                    $candidate = $this->mergeComparisonItemData($seedItems[$rowIndex], $candidate);
                }
                if (empty($candidate)) {
                    continue;
                }
                $candidate['source_block'] = $blockName;
                $items[] = $candidate;
                if (count($items) >= 12) {
                    return $items;
                }
            }
        }

        return $items;
    }

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,array{headers:array<int,string>,rows:array<int,array<string,mixed>>}>
     */
    private function vendorSpecificTableLayouts(string $blockName, array $attrs): array
    {
        $headerKeys = ['headers', 'tableHeaders', 'table_headers', 'tableHeader', 'table_header', 'headerRow', 'header_row', 'head', 'tableHead', 'table_head', 'thead'];
        $rowKeys = ['rows', 'tableRows', 'table_rows', 'tableBody', 'table_body', 'bodyRows', 'body_rows', 'rowData', 'row_data', 'comparisonRows', 'comparison_rows', 'criteria', 'criteriaRows', 'criteria_rows', 'tableData', 'table_data', 'entries', 'body', 'tbody'];

        if (str_starts_with($blockName, 'affiliatex/')) {
            $headerKeys = array_merge($headerKeys, ['comparisonHeaders', 'comparison_headers', 'tableColumns']);
            $rowKeys = array_merge($rowKeys, ['comparisonTable', 'comparison_table']);
        } elseif (str_starts_with($blockName, 'lasso/')) {
            $headerKeys = array_merge($headerKeys, ['columns']);
            $rowKeys = array_merge($rowKeys, ['comparisonData', 'comparison_data']);
        } elseif (str_starts_with($blockName, 'aawp/')) {
            $headerKeys = array_merge($headerKeys, ['productsHeader', 'products_header']);
            $rowKeys = array_merge($rowKeys, ['table', 'comparisonTable', 'comparison_table']);
        } elseif (str_starts_with($blockName, 'reviewx/')) {
            $headerKeys = array_merge($headerKeys, ['comparisonColumns', 'comparison_columns']);
            $rowKeys = array_merge($rowKeys, ['comparisonTable', 'comparison_table', 'criteriaData']);
        }

        $layouts = [];
        foreach ($rowKeys as $rowKey) {
            if (empty($attrs[$rowKey]) || !is_array($attrs[$rowKey])) {
                continue;
            }

            $container = $attrs[$rowKey];
            $rows = $this->extractTableRowCandidates(is_array($container) ? $container : [], []);
            if (empty($rows) && $this->looksLikeRowList($container)) {
                $rows = array_values(array_filter($container, 'is_array'));
            }
            if (empty($rows)) {
                continue;
            }

            $headers = [];
            foreach ($headerKeys as $headerKey) {
                if (empty($attrs[$headerKey]) || !is_array($attrs[$headerKey])) {
                    continue;
                }
                foreach ($this->flattenTextItems($attrs[$headerKey], ['label', 'title', 'name', 'text', 'content']) as $label) {
                    $headers[] = $label;
                }
            }
            if (empty($headers)) {
                $headers = $this->comparisonHeadersFromAttrs($attrs);
            }
            if (empty($headers) && !empty($container) && is_array($container)) {
                $headers = $this->comparisonHeadersFromAttrs($container);
            }
            if (count($headers) >= 2) {
                $layouts[] = [
                    'headers' => $this->uniqueStrings($headers, 20),
                    'rows' => $rows,
                ];
            }
        }

        return $layouts;
    }

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,array<string,mixed>>
     */
    private function vendorSpecificProductSeeds(string $blockName, array $attrs): array
    {
        $seeds = [];
        foreach ($this->vendorSpecificItemCollections($blockName, $attrs) as $collection) {
            foreach ($collection as $index => $item) {
                if (!is_array($item)) {
                    continue;
                }
                $candidate = $this->normalizeProductCandidate($item, ['attrs' => $attrs]);
                $candidate = $this->enrichProductCandidateFromLabeledCollections($candidate, $item);
                if (empty($candidate)) {
                    continue;
                }
                if (empty($candidate['position'])) {
                    $candidate['position'] = (string) ($index + 1);
                }
                $candidate['source_block'] = $blockName;
                if (!isset($seeds[$index])) {
                    $seeds[$index] = $candidate;
                } else {
                    $seeds[$index] = $this->mergeComparisonItemData($seeds[$index], $candidate);
                }
            }
        }

        ksort($seeds);
        return array_values($seeds);
    }

    /**
     * @param array<int,string> $headers
     */
    private function headersLookLikeProductColumns(array $headers): bool
    {
        if (count($headers) < 3) {
            return false;
        }

        $firstHeader = $this->lower(trim((string) ($headers[0] ?? '')));
        if ($this->containsAny($firstHeader, ['feature', 'criteria', 'spec', 'metric', 'attribute', 'details'])) {
            return true;
        }

        $productish = 0;
        foreach (array_slice($headers, 1, 3) as $header) {
            $field = $this->headerLabelToField((string) $header);
            if ($field !== '') {
                return false;
            }
            if (trim((string) $header) !== '') {
                $productish++;
            }
        }

        return $productish >= 2;
    }

    /**
     * @param mixed $value
     */
    private function looksLikeRowList($value): bool
    {
        if (!is_array($value) || empty($value)) {
            return false;
        }

        $rows = 0;
        foreach ($value as $item) {
            if (is_array($item)) {
                $rows++;
            }
        }

        return $rows >= 2;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @param array<int,string> $headers
     * @param array<int,array<string,mixed>> $seedItems
     * @return array<int,array<string,mixed>>
     */
    private function extractComparisonMatrixItemsFromRows(array $rows, array $headers, string $blockName, array $seedItems = []): array
    {
        if (count($headers) < 3 || count($rows) < 1) {
            return [];
        }

        $items = [];
        foreach (array_slice($headers, 1) as $columnIndex => $headerLabel) {
            $seed = isset($seedItems[$columnIndex]) && is_array($seedItems[$columnIndex]) ? $seedItems[$columnIndex] : [];
            $name = $this->cleanLine((string) ($seed['name'] ?? $headerLabel));
            if ($name === '') {
                continue;
            }

            $candidate = $seed;
            $candidate['name'] = $name;
            if (empty($candidate['position'])) {
                $candidate['position'] = (string) ($columnIndex + 1);
            }
            $candidate['source_block'] = $blockName;

            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }

                $cells = $this->extractOrderedCells($row);
                if (count($cells) <= ($columnIndex + 1)) {
                    continue;
                }

                $rowLabel = $this->scalarCellText($cells[0] ?? '');
                $field = $this->headerLabelToField($rowLabel);
                $cell = $cells[$columnIndex + 1];
                $cellText = $this->scalarCellText($cell);
                $cellUrl = $this->extractCellUrl($cell);

                if ($field === 'name' && $cellText !== '') {
                    $candidate['name'] = $cellText;
                    continue;
                }
                if ($field === 'url' && $cellUrl !== '') {
                    $candidate['url'] = $cellUrl;
                    continue;
                }
                if ($field !== '' && $cellText !== '') {
                    if ($field === 'feature') {
                        $candidate['feature_summary'][] = $rowLabel !== '' ? ($rowLabel . ': ' . $cellText) : $cellText;
                    } else {
                        $candidate[$field] = $cellText;
                    }
                    continue;
                }
                if ($rowLabel !== '' && $cellText !== '' && !$this->containsAny($this->lower($rowLabel), ['product', 'name', 'title'])) {
                    $candidate['feature_summary'][] = $rowLabel . ': ' . $cellText;
                }
            }

            if (!empty($candidate['feature_summary']) && is_array($candidate['feature_summary'])) {
                $candidate['feature_summary'] = $this->uniqueStrings(array_map('strval', $candidate['feature_summary']), 8);
            }

            $candidate = $this->normalizeProductCandidate($candidate);
            if (!empty($candidate)) {
                $candidate['source_block'] = $blockName;
                $items[] = $candidate;
            }
        }

        return $items;
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @param array<int,array<string,mixed>> $items
     * @return array<string,mixed>
     */
    private function buildComparisonMeta(array $flat, array $items): array
    {
        $ranked = 0;
        $priced = 0;
        $sellerCount = 0;
        $awardCount = 0;
        $aggregateOfferCount = 0;
        $linkedCount = 0;
        $ratedCount = 0;
        $featureRichCount = 0;
        $matrixCandidateRows = 0;
        $vendorTableLayouts = 0;
        $vendorWidgetLayouts = 0;
        $vendorShortcodes = 0;
        $genericHtmlTables = 0;
        $sourceBlocks = [];
        $vendors = [];

        foreach ($items as $item) {
            if (!is_array($item)) {
                continue;
            }
            if (!empty($item['position'])) {
                $ranked++;
            }
            if (!empty($item['price']) || !empty($item['low_price']) || !empty($item['high_price'])) {
                $priced++;
            }
            if (!empty($item['seller'])) {
                $sellerCount++;
            }
            if (!empty($item['rank_label'])) {
                $awardCount++;
            }
            if (!empty($item['offer_count']) || (!empty($item['low_price']) && !empty($item['high_price']) && (string) $item['low_price'] !== (string) $item['high_price'])) {
                $aggregateOfferCount++;
            }
            if (!empty($item['url'])) {
                $linkedCount++;
            }
            if (!empty($item['rating'])) {
                $ratedCount++;
            }
            if (!empty($item['feature_summary']) && is_array($item['feature_summary'])) {
                $featureRichCount++;
            }
            if (!empty($item['source_block']) && is_scalar($item['source_block'])) {
                $sourceBlock = (string) $item['source_block'];
                $sourceBlocks[] = $sourceBlock;
                $vendor = $this->blockVendorLabel($sourceBlock);
                if ($vendor !== '') {
                    $vendors[] = $vendor;
                }
            }
        }

        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if ($name === '') {
                continue;
            }

            if ($this->rowSetLooksLikeComparisonMatrix($this->extractTableRowCandidates($attrs, $this->comparisonHeadersFromAttrs($attrs)))) {
                $matrixCandidateRows++;
            }
            if ($name === 'html/comparison-table') {
                $genericHtmlTables++;
            }
            if (substr($name, -14) === '/widget-layout') {
                $vendorWidgetLayouts++;
            }
            if (substr($name, -10) === '/shortcode') {
                $vendorShortcodes++;
            }
            $vendorTableLayouts += count($this->vendorSpecificTableLayouts($name, $attrs));

            if (!empty($sourceBlocks)) {
                continue;
            }

            $haystack = $name . ' ' . $this->lower(implode(' ', array_keys($attrs)));
            if ($this->containsAny($haystack, ['comparison', 'table', 'roundup', 'best', 'top'])) {
                $sourceBlocks[] = $name;
            }
        }

        $vendorLabels = $this->uniqueStrings($vendors, 8);
        $meta = [
            'ranked_items' => $ranked,
            'items_with_price' => $priced,
            'items_with_seller' => $sellerCount,
            'items_with_badges' => $awardCount,
            'aggregate_offer_items' => $aggregateOfferCount,
            'items_with_links' => $linkedCount,
            'items_with_rating' => $ratedCount,
            'items_with_feature_summary' => $featureRichCount,
            'matrix_table_blocks' => $matrixCandidateRows,
            'generic_html_tables' => $genericHtmlTables,
            'vendor_table_layouts' => $vendorTableLayouts,
            'vendor_widget_layouts' => $vendorWidgetLayouts,
            'vendor_shortcodes' => $vendorShortcodes,
            'source_blocks' => $this->uniqueStrings($sourceBlocks, 8),
            'vendors_detected' => $vendorLabels,
            'vendor_count' => count($vendorLabels),
        ];

        if ($ranked > 0) {
            $meta['item_list_order'] = 'https://schema.org/ItemListOrderAscending';
        }

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

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,string>
     */
    private function comparisonHeadersFromAttrs(array $attrs): array
    {
        $headers = [];
        foreach (['headers', 'head', 'columns', 'labels', 'tableHead', 'table_head', 'tableHeader', 'table_header', 'tableHeaders', 'headerRow', 'header_row', 'thead'] as $key) {
            if (!isset($attrs[$key])) {
                continue;
            }
            foreach ($this->flattenTextItems($attrs[$key], ['label', 'title', 'name', 'text', 'content']) as $label) {
                $headers[] = $label;
            }
        }

        foreach (['table', 'comparisonTable', 'comparison_table'] as $key) {
            if (empty($attrs[$key]) || !is_array($attrs[$key])) {
                continue;
            }
            foreach ($this->comparisonHeadersFromAttrs($attrs[$key]) as $label) {
                $headers[] = $label;
            }
        }

        return $this->uniqueStrings($headers, 20);
    }

    /**
     * @param array<string,mixed> $value
     * @param array<int,string> $headers
     * @return array<int,array<string,mixed>>
     */
    private function extractTableRowCandidates(array $value, array $headers): array
    {
        $rows = [];
        foreach (['rows', 'tableRows', 'table_rows', 'tableBody', 'table_body', 'body', 'tbody', 'bodyRows', 'body_rows', 'entries', 'data', 'rowData', 'row_data', 'comparisonRows', 'comparison_rows', 'criteria', 'criteriaRows', 'criteria_rows', 'tableData', 'table_data'] as $key) {
            if (empty($value[$key]) || !is_array($value[$key])) {
                continue;
            }
            foreach ($value[$key] as $row) {
                if (is_array($row)) {
                    $rows[] = $row;
                }
            }
        }

        if (!empty($rows)) {
            return $rows;
        }

        if (isset($value['cells']) && is_array($value['cells'])) {
            return [$value];
        }

        $looksLikeRowSet = [];
        foreach ($value as $key => $row) {
            if (!is_int($key) || !is_array($row)) {
                continue;
            }
            if (!empty($row['cells']) || !empty($row['columns']) || !empty($row['values'])) {
                $looksLikeRowSet[] = $row;
                continue;
            }
            if (empty($headers)) {
                continue;
            }
            $scalarParts = 0;
            foreach ($row as $cell) {
                if (is_scalar($cell)) {
                    $scalarParts++;
                }
            }
            if ($scalarParts >= 2) {
                $looksLikeRowSet[] = $row;
            }
        }

        return $looksLikeRowSet;
    }

    /**
     * @param array<string,mixed> $row
     * @param array<int,string> $headers
     * @return array<string,mixed>
     */
    private function normalizeComparisonTableRow(array $row, array $headers, int $fallbackPosition): array
    {
        $mapped = [];
        $cells = $this->extractOrderedCells($row);

        if (!empty($cells)) {
            foreach ($cells as $index => $cell) {
                $header = $headers[$index] ?? '';
                $field = $this->headerLabelToField($header);
                $text = $this->scalarCellText($cell);
                $link = $this->extractCellUrl($cell);
                if ($field !== '') {
                    if ($field === 'url' && $link !== '') {
                        $mapped[$field] = $link;
                    } elseif ($text !== '') {
                        $mapped[$field] = $text;
                    }
                }
                if ($field === '' && $text !== '') {
                    $mapped['__cell_' . $index] = $text;
                }
                if ($link !== '' && empty($mapped['url'])) {
                    $mapped['url'] = $link;
                }
            }
        }

        foreach ($row as $key => $value) {
            if (is_int($key)) {
                continue;
            }
            if (is_scalar($value)) {
                $mapped[(string) $key] = (string) $value;
            }
        }

        if (empty($mapped['name'])) {
            foreach ($mapped as $key => $value) {
                if (!is_string($value) || $value === '' || str_starts_with($key, '__cell_') === false) {
                    continue;
                }
                if (preg_match('/^#?\d+$/', trim($value)) === 1) {
                    continue;
                }
                if (filter_var($value, FILTER_VALIDATE_URL)) {
                    continue;
                }
                if ($this->normalizePriceString($value) === $value && preg_match('/\d/', $value) === 1 && preg_match('/[A-Za-z]/', $value) !== 1) {
                    continue;
                }
                $mapped['name'] = $value;
                break;
            }
        }

        if (empty($mapped['position'])) {
            $firstCell = isset($cells[0]) ? $this->scalarCellText($cells[0]) : '';
            $mapped['position'] = $this->normalizePositionValue($firstCell);
        }
        if (empty($mapped['position']) && !empty($headers) && $this->containsAny($this->lower(implode(' ', $headers)), ['rank', 'position', '#'])) {
            $mapped['position'] = (string) max(1, $fallbackPosition);
        }

        $features = [];
        foreach ($mapped as $key => $value) {
            if (!is_string($value) || $value === '') {
                continue;
            }
            if (in_array($key, ['name', 'price', 'currency', 'url', 'position', 'rank_label', 'seller', 'brand', 'sku', 'rating'], true)) {
                continue;
            }
            if (str_starts_with($key, '__cell_')) {
                $features[] = $value;
                continue;
            }
            if (!in_array($key, ['description', 'summary', 'content'], true)) {
                $features[] = $value;
            }
        }
        if (!empty($features)) {
            $mapped['feature_summary'] = $this->uniqueStrings($features, 6);
        }

        return $this->normalizeProductCandidate($mapped);
    }

    private function headerLabelToField(string $label): string
    {
        $label = $this->lower($this->cleanLine($label));
        if ($label === '') {
            return '';
        }
        if ($this->containsAny($label, ['rank', 'position', '#'])) {
            return 'position';
        }
        if ($this->containsAny($label, ['product', 'item', 'tool', 'plugin', 'name', 'title'])) {
            return 'name';
        }
        if ($this->containsAny($label, ['price', 'cost'])) {
            return 'price';
        }
        if ($this->containsAny($label, ['price range', 'range', 'deals'])) {
            return 'price';
        }
        if ($this->containsAny($label, ['currency'])) {
            return 'currency';
        }
        if ($this->containsAny($label, ['url', 'link', 'visit', 'deal', 'cta', 'buy now', 'shop'])) {
            return 'url';
        }
        if ($this->containsAny($label, ['brand', 'vendor'])) {
            return 'brand';
        }
        if ($this->containsAny($label, ['sku', 'asin', 'model'])) {
            return 'sku';
        }
        if ($this->containsAny($label, ['seller', 'merchant', 'retailer', 'store'])) {
            return 'seller';
        }
        if ($this->containsAny($label, ['rating', 'score', 'stars'])) {
            return 'rating';
        }
        if ($this->containsAny($label, ['availability', 'stock'])) {
            return 'availability';
        }
        if ($this->containsAny($label, ['best for', 'winner', 'award', 'badge', 'verdict'])) {
            return 'rank_label';
        }
        if ($this->containsAny($label, ['feature', 'highlight', 'pros', 'cons'])) {
            return 'feature';
        }
        return '';
    }

    /**
     * @param mixed $cell
     */
    private function scalarCellText($cell): string
    {
        if (is_scalar($cell)) {
            return $this->cleanLine((string) $cell);
        }
        if (!is_array($cell)) {
            return '';
        }

        $text = $this->cleanLine($this->plainText($this->firstNonEmpty($cell, ['content', 'text', 'label', 'title', 'name', 'value', 'description', 'summary'])));
        if ($text !== '') {
            return $text;
        }

        foreach (['button', 'cta', 'link', 'product', 'item'] as $nestedKey) {
            if (!isset($cell[$nestedKey]) || !is_array($cell[$nestedKey])) {
                continue;
            }
            $text = $this->scalarCellText($cell[$nestedKey]);
            if ($text !== '') {
                return $text;
            }
        }

        foreach ($cell as $value) {
            if (is_scalar($value)) {
                $text = $this->cleanLine((string) $value);
                if ($text !== '') {
                    return $text;
                }
            }
        }

        return '';
    }

    private function normalizePositionValue(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }
        if (preg_match('/#?\s*(\d{1,3})\b/', $value, $match)) {
            $number = (int) ($match[1] ?? 0);
            return $number > 0 ? (string) $number : '';
        }
        return '';
    }

    /**
     * @param array<string,mixed> $candidate
     */
    private function comparisonItemSignature(array $candidate): string
    {
        $name = isset($candidate['name']) ? trim((string) $candidate['name']) : '';
        $url = isset($candidate['url']) ? trim((string) $candidate['url']) : '';
        $signature = $name . '|' . $url;
        $signature = trim($signature, '|');
        return $signature !== '' ? $this->lower($signature) : '';
    }

    private function normalizePriceString(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }
        if (preg_match('/-?\d+(?:[.,]\d{1,2})?/', $value, $match)) {
            $price = str_replace(',', '.', (string) ($match[0] ?? ''));
            return trim($price);
        }
        return $this->cleanLine($value);
    }

    private function normalizeCurrencyCode(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        $map = [
            '$' => 'USD',
            'usd' => 'USD',
            'us$' => 'USD',
            '€' => 'EUR',
            'eur' => 'EUR',
            '£' => 'GBP',
            'gbp' => 'GBP',
            'cad' => 'CAD',
            'aud' => 'AUD',
            'inr' => 'INR',
        ];

        $lookup = $this->lower($value);
        if (isset($map[$lookup])) {
            return $map[$lookup];
        }

        if (preg_match('/^[A-Z]{3}$/', strtoupper($value)) === 1) {
            return strtoupper($value);
        }

        return strtoupper($value);
    }

    private function normalizeNumberString(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }
        if (preg_match('/-?\d+(?:[.,]\d+)?/', $value, $match)) {
            return str_replace(',', '.', (string) ($match[0] ?? ''));
        }
        return '';
    }

    /**
     * @param array<int,array<string,mixed>> $flat
     * @return array<string,mixed>
     */
    private function extractServiceData(array $flat): array
    {
        $data = [];
        foreach ($flat as $block) {
            if (!is_array($block)) {
                continue;
            }
            $name = $this->normalizeBlockName(isset($block['blockName']) ? (string) $block['blockName'] : '');
            $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : [];
            if (!in_array('service', $this->schemaTypesForBlock($name, $attrs), true)) {
                continue;
            }

            if (!isset($data['name'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['serviceType', 'name', 'title', 'heading', 'label']));
                if ($value !== '') {
                    $data['name'] = $value;
                }
            }
            if (!isset($data['description'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['description', 'content', 'text', 'body']));
                if ($value !== '') {
                    $data['description'] = $value;
                }
            }
            if (!isset($data['provider'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['provider', 'company', 'business', 'brand']));
                if ($value !== '') {
                    $data['provider'] = $value;
                }
            }
            if (!isset($data['price'])) {
                $value = $this->cleanLine($this->firstNonEmpty($attrs, ['price', 'amount', 'startingPrice', 'servicePrice']));
                if ($value !== '') {
                    $data['price'] = $value;
                }
            }
            if (!isset($data['currency'])) {
                $value = strtoupper($this->cleanLine($this->firstNonEmpty($attrs, ['currency', 'priceCurrency', 'currencyCode'])));
                if ($value !== '') {
                    $data['currency'] = $value;
                }
            }
            if (!isset($data['area_served'])) {
                $areas = $this->flattenTextItems($attrs['areaServed'] ?? ($attrs['locations'] ?? ($attrs['coverage'] ?? null)), ['name', 'label', 'text']);
                if (!empty($areas)) {
                    $data['area_served'] = $areas;
                }
            }
        }
        return $data;
    }

    /**
     * @param array<string,mixed> $attrs
     * @return array<int,string>
     */
    private function schemaTypesForBlock(string $name, array $attrs): array
    {
        $name = $this->normalizeBlockName($name);
        if ($name === '') {
            return [];
        }
        if (isset(self::BLOCK_MAP[$name])) {
            return self::BLOCK_MAP[$name]['schema'];
        }

        $types = [];
        $haystack = $name . ' ' . $this->lower(implode(' ', array_keys($attrs)));
        if ($this->containsAny($haystack, ['faq', 'accordion', 'toggle'])) {
            $types[] = 'faq';
        }
        if ($this->containsAny($haystack, ['howto', 'how-to', 'step', 'tutorial'])) {
            $types[] = 'howto';
        }
        if ($this->containsAny($haystack, ['recipe', 'ingredient', 'instruction', 'directions'])) {
            $types[] = 'recipe';
        }
        if ($this->containsAny($haystack, ['event', 'events-calendar', 'eventbrite'])) {
            $types[] = 'event';
        }
        if ($this->containsAny($haystack, ['review', 'rating', 'stars'])) {
            $types[] = 'review';
        }
        if ($this->containsAny($haystack, ['product', 'price', 'pricing-table', 'comparison', 'add-to-cart', 'buy-box'])) {
            $types[] = 'product';
        }
        if ($this->containsAny($haystack, ['service', 'pricing'])) {
            $types[] = 'service';
        }
        return array_values(array_unique($types));
    }

    private function blockVendorLabel(string $blockName): string
    {
        $prefix = strtolower((string) strtok($this->normalizeBlockName($blockName), '/'));
        $map = [
            'affiliatex' => 'AffiliateX',
            'lasso' => 'Lasso',
            'aawp' => 'AAWP',
            'reviewx' => 'ReviewX',
            'woocommerce' => 'WooCommerce',
            'rank-math' => 'Rank Math',
        ];

        return $map[$prefix] ?? '';
    }

    private function labelForBlock(string $name): string
    {
        $name = $this->normalizeBlockName($name);
        if (isset(self::BLOCK_MAP[$name])) {
            return self::BLOCK_MAP[$name]['label'];
        }
        $parts = preg_split('#[/-]+#', $name) ?: [$name];
        $parts = array_values(array_filter(array_map(static function ($part): string {
            $part = trim((string) $part);
            return $part !== '' ? ucfirst($part) : '';
        }, $parts)));
        return !empty($parts) ? implode(' ', $parts) : $name;
    }

    private function normalizeBlockName(string $name): string
    {
        $name = trim($name);
        return $name !== '' ? $this->lower($name) : '';
    }

    /**
     * @param array<int,mixed>|mixed $value
     * @param array<int,string> $preferredKeys
     * @return array<int,string>
     */
    private function flattenTextItems($value, array $preferredKeys): array
    {
        $items = [];
        if (is_string($value)) {
            $value = preg_split('/[\r\n]+/', $value) ?: [$value];
        }
        if (!is_array($value)) {
            return [];
        }
        foreach ($value as $item) {
            if (is_scalar($item)) {
                $text = $this->cleanLine((string) $item);
                if ($text !== '') {
                    $items[] = $text;
                }
                continue;
            }
            if (!is_array($item)) {
                continue;
            }
            $text = $this->cleanLine($this->plainText($this->firstNonEmpty($item, $preferredKeys)));
            if ($text !== '') {
                $items[] = $text;
                continue;
            }
            foreach ($preferredKeys as $key) {
                if (!isset($item[$key]) || !is_array($item[$key])) {
                    continue;
                }
                foreach ($this->flattenTextItems($item[$key], $preferredKeys) as $nested) {
                    $items[] = $nested;
                }
            }
        }
        return $this->uniqueStrings($items, 30);
    }

    /**
     * @param array<int,array{question:string,answer:string}> $pairs
     * @return array<int,array{question:string,answer:string}>
     */
    private function normalizeFaqPairs(array $pairs, int $limit): array
    {
        $out = [];
        $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;
            $out[] = ['question' => $question, 'answer' => $answer];
            if (count($out) >= $limit) {
                break;
            }
        }
        return $out;
    }

    /**
     * @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('/<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];
                }
            }
        }
        if (preg_match_all('/<dt[^>]*>(.*?)<\/dt>\s*<dd[^>]*>(.*?)<\/dd>/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];
                }
            }
        }
        if (preg_match_all('/<(?:div|button)[^>]*class=["\'][^"\']*(?:accordion|faq|toggle|question|trigger|title)[^"\']*["\'][^>]*>(.*?)<\/(?:div|button)>\s*<(?:div|section)[^>]*class=["\'][^"\']*(?:accordion|faq|toggle|answer|content|body|panel)[^"\']*["\'][^>]*>(.*?)<\/(?:div|section)>/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];
                }
            }
        }
        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($this->plainText((string) ($row[2] ?? '')));
                if ($question !== '' && $answer !== '') {
                    $pairs[] = ['question' => $question, 'answer' => $answer];
                }
            }
        }
        return $pairs;
    }

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

        $steps = [];
        $htmlLower = $this->lower($html);

        if (preg_match_all('/<(ol|ul)\b[^>]*>(.*?)<\/\1>/is', $html, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $row) {
                $tag = $this->lower((string) ($row[1] ?? ''));
                $listHtml = (string) ($row[0] ?? '');
                $items = $this->parseListItemsFromHtml($listHtml);
                if (count($items) < 2) {
                    continue;
                }

                $contextNeedle = (string) ($row[0] ?? '');
                $contextPos = strpos($html, $contextNeedle);
                $contextBefore = $contextPos !== false ? substr($html, max(0, $contextPos - 240), 240) : '';
                $context = $this->lower($contextBefore . ' ' . $listHtml);
                $looksLikeHowTo = $this->containsAny($context, ['how to', 'step-by-step', 'steps', 'instructions', 'directions', 'method', 'tutorial', 'installation', 'setup'])
                    || $this->listItemsLookLikeSteps($items);

                if (!$looksLikeHowTo) {
                    continue;
                }

                foreach ($items as $item) {
                    $steps[] = $item;
                }
            }
        }

        if (preg_match_all('/<h[2-6][^>]*>\s*(?:step\s*\d+|\d+[\).]\s*[^<]+|how to[^<]*|instructions?|directions?|method)\s*<\/h[2-6]>\s*(?:<(?:div|section)[^>]*>)?\s*<p[^>]*>(.*?)<\/p>/is', $html, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $row) {
                $step = $this->cleanLine($this->plainText((string) ($row[1] ?? '')));
                if ($step !== '') {
                    $steps[] = $step;
                }
            }
        }

        if (preg_match_all('/<p[^>]*>\s*(?:<strong[^>]*>)?\s*(?:step\s*\d+|\d+[\).])\s*(?:<\/strong>)?\s*(.*?)<\/p>/is', $html, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $row) {
                $step = $this->cleanLine($this->plainText((string) ($row[1] ?? '')));
                if ($step !== '') {
                    $steps[] = $step;
                }
            }
        }

        if (count($steps) < 2 && $this->containsAny($htmlLower, ['class="step', "class='step", 'class="howto', "class='howto"])) {
            if (preg_match_all('/<(?:div|section|li)[^>]*class=["\'][^"\']*(?:howto|step|instruction|direction|method)[^"\']*["\'][^>]*>(.*?)<\/(?:div|section|li)>/is', $html, $matches, PREG_SET_ORDER)) {
                foreach ($matches as $row) {
                    $step = $this->cleanLine($this->plainText((string) ($row[1] ?? '')));
                    if ($step !== '') {
                        $steps[] = $step;
                    }
                }
            }
        }

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

    /**
     * @param array<int,string> $items
     */
    private function listItemsLookLikeSteps(array $items): bool
    {
        if (count($items) < 2) {
            return false;
        }

        $signals = 0;
        foreach ($items as $item) {
            $lower = $this->lower($item);
            if ($this->containsAny($lower, ['step ', 'install', 'open ', 'click ', 'select ', 'choose ', 'add ', 'enter ', 'upload ', 'activate ', 'configure ', 'publish '])) {
                $signals++;
            }
        }

        return $signals >= min(2, count($items));
    }

    /**
     * @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) {
                $text = $this->cleanLine((string) $item);
                if ($text !== '') {
                    $items[] = $text;
                }
            }
        }
        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', 'description'] 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);
        }
        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,array<string,mixed>> $sources
     * @param array<int,string> $keys
     */
    private function firstNonEmptyFromSources(array $sources, array $keys): string
    {
        foreach ($sources as $source) {
            $value = $this->firstNonEmpty($source, $keys);
            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;
    }

    /**
     * @param array<int,string> $classNeedles
     * @param array<int,string> $tags
     */
    private function extractFirstHtmlTextByClassNeedles(string $html, array $classNeedles, array $tags = ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong', 'address']): string
    {
        $needlePattern = implode('|', array_map(static function (string $needle): string {
            return preg_quote($needle, '/');
        }, $classNeedles));
        if ($needlePattern === '') {
            return '';
        }

        $tagPattern = implode('|', array_map(static function (string $tag): string {
            return preg_quote($tag, '/');
        }, $tags));
        if ($tagPattern === '') {
            return '';
        }

        $pattern = '/<(' . $tagPattern . ')[^>]*class=["\'][^"\']*(?:' . $needlePattern . ')[^"\']*["\'][^>]*>(.*?)<\/\1>/is';
        if (!preg_match($pattern, $html, $match)) {
            return '';
        }

        return $this->cleanLine($this->plainText((string) ($match[2] ?? '')));
    }

    private function extractFirstHeadingTextFromHtml(string $html): string
    {
        if (!preg_match('/<h[1-3][^>]*>(.*?)<\/h[1-3]>/is', $html, $match)) {
            return '';
        }

        return $this->cleanLine($this->plainText((string) ($match[1] ?? '')));
    }

    private function extractFirstParagraphTextFromHtml(string $html): string
    {
        if (!preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $html, $matches, PREG_SET_ORDER)) {
            return '';
        }

        foreach ($matches as $match) {
            $text = $this->cleanLine($this->plainText((string) ($match[1] ?? '')));
            if ($text === '') {
                continue;
            }

            $length = function_exists('mb_strlen') ? (int) mb_strlen($text, 'UTF-8') : strlen($text);
            if ($length < 18 && $this->containsAny($this->lower($text), ['register', 'tickets', 'venue', 'location', 'online', 'virtual'])) {
                continue;
            }

            return $text;
        }

        return '';
    }

    /**
     * @return array<int,string>
     */
    private function extractTimeValuesFromHtml(string $html): array
    {
        $values = [];
        if (!preg_match_all('/<time\b([^>]*)>(.*?)<\/time>/is', $html, $matches, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($matches as $match) {
            $attrs = (string) ($match[1] ?? '');
            $value = '';
            if (preg_match('/\bdatetime=["\']([^"\']+)["\']/i', $attrs, $datetimeMatch)) {
                $value = trim((string) ($datetimeMatch[1] ?? ''));
            }
            if ($value === '') {
                $value = $this->cleanLine($this->plainText((string) ($match[2] ?? '')));
            }
            if ($value === '') {
                continue;
            }

            $values[] = $value;
            if (count($values) >= 2) {
                break;
            }
        }

        return $values;
    }

    /**
     * @param array<int,string> $classNeedles
     */
    private function extractFirstDateValueByClassNeedles(string $html, array $classNeedles): string
    {
        $needlePattern = implode('|', array_map(static function (string $needle): string {
            return preg_quote($needle, '/');
        }, $classNeedles));
        if ($needlePattern === '') {
            return '';
        }

        $pattern = '/<(time|div|span|p|li)[^>]*class=["\'][^"\']*(?:' . $needlePattern . ')[^"\']*["\']([^>]*)>(.*?)<\/\1>/is';
        if (!preg_match($pattern, $html, $match)) {
            return '';
        }

        $tag = strtolower((string) ($match[1] ?? ''));
        $attrs = (string) ($match[2] ?? '');
        if ($tag === 'time' && preg_match('/\bdatetime=["\']([^"\']+)["\']/i', $attrs, $datetimeMatch)) {
            return trim((string) ($datetimeMatch[1] ?? ''));
        }

        return $this->cleanLine($this->plainText((string) ($match[3] ?? '')));
    }

    private function normalizeEventAttendanceMode(string $text): string
    {
        $text = $this->lower($text);
        $hasOnline = $this->containsAny($text, ['online', 'virtual', 'webinar', 'zoom', 'livestream', 'live stream']);
        $hasOffline = $this->containsAny($text, ['in person', 'in-person', 'onsite', 'on-site', 'venue']);

        if ($hasOnline && $hasOffline) {
            return 'https://schema.org/MixedEventAttendanceMode';
        }
        if ($hasOnline) {
            return 'https://schema.org/OnlineEventAttendanceMode';
        }
        if ($hasOffline) {
            return 'https://schema.org/OfflineEventAttendanceMode';
        }

        return '';
    }

    /**
     * @return array<string,string>
     */
    private function extractReviewRatingFromHtml(string $html): array
    {
        $data = [];

        if (preg_match('/(?:data-rating|data-score|data-stars|ratingvalue|rating-value)\s*=\s*["\']?\s*([0-9]+(?:\.[0-9]+)?)\s*["\']?/i', $html, $match)) {
            $data['rating'] = trim((string) ($match[1] ?? ''));
        }
        if (preg_match('/(?:data-best-rating|bestRating|max-rating|maxRating)\s*=\s*["\']?\s*([0-9]+(?:\.[0-9]+)?)\s*["\']?/i', $html, $match)) {
            $data['best_rating'] = trim((string) ($match[1] ?? ''));
        }
        if (preg_match('/(?:data-worst-rating|worstRating|min-rating|minRating)\s*=\s*["\']?\s*([0-9]+(?:\.[0-9]+)?)\s*["\']?/i', $html, $match)) {
            $data['worst_rating'] = trim((string) ($match[1] ?? ''));
        }

        $plain = $this->plainText($html);
        if (empty($data['rating']) && preg_match('/(?:rating|score|overall|verdict)[^0-9]{0,20}([0-9]+(?:\.[0-9]+)?)\s*(?:\/|out of)\s*(10|5)(?=$|[^0-9])/iu', $plain, $match)) {
            $data['rating'] = trim((string) ($match[1] ?? ''));
            $data['best_rating'] = trim((string) ($match[2] ?? ''));
        }
        if (empty($data['rating']) && preg_match('/\b([0-9]+(?:\.[0-9]+)?)\s*(?:\/|out of)\s*(10|5)(?=$|[^0-9])/iu', $plain, $match)) {
            $data['rating'] = trim((string) ($match[1] ?? ''));
            $data['best_rating'] = trim((string) ($match[2] ?? ''));
        }
        if (empty($data['rating']) && preg_match('/(?:rating|score|overall|verdict)[^0-9]{0,20}([0-9]+(?:\.[0-9]+)?)\s*stars?/iu', $plain, $match)) {
            $data['rating'] = trim((string) ($match[1] ?? ''));
        }
        if (empty($data['rating']) && preg_match('/\b([0-9]+(?:\.[0-9]+)?)\s*stars?\b/iu', $plain, $match)) {
            $data['rating'] = trim((string) ($match[1] ?? ''));
        }

        if (!empty($data['rating']) && empty($data['best_rating'])) {
            $numeric = (float) $data['rating'];
            $data['best_rating'] = $numeric > 5.0 ? '10' : '5';
        }
        if (!empty($data['rating']) && empty($data['worst_rating'])) {
            $data['worst_rating'] = '1';
        }

        return $data;
    }

    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 lower(string $value): string
    {
        if (function_exists('mb_strtolower')) {
            return (string) mb_strtolower($value, 'UTF-8');
        }
        return strtolower($value);
    }

    /**
     * @param array<int,string> $needles
     */
    private function containsAny(string $haystack, array $needles): bool
    {
        foreach ($needles as $needle) {
            $needle = trim((string) $needle);
            if ($needle === '') {
                continue;
            }
            if (function_exists('mb_strpos')) {
                if (mb_strpos($haystack, $needle, 0, 'UTF-8') !== false) {
                    return true;
                }
            } elseif (strpos($haystack, $needle) !== false) {
                return true;
            }
        }
        return false;
    }
}
