<?php
/**
 * Schema Renderer
 *
 * Renders JSON-LD schema markup in the frontend
 *
 * @package TamRank
 * @subpackage Schema
 */

if (!defined('ABSPATH')) {
    exit;
}

class TAMRANK_Schema_Render {

    /**
     * Templates manager instance
     *
     * @var TAMRANK_Schema_Templates
     */
    private $templates;

    /**
     * Settings manager instance
     *
     * @var TAMRANK_Schema_Settings
     */
    private $settings;

    /**
     * Detector manager instance
     *
     * @var TAMRANK_Schema_Detector_Manager|null
     */
    private $detector_manager = null;

    /**
     * Ceiling on the number of OfferShippingDetails nodes in one Offer.
     *
     * A shop with fifty zone/country combinations would otherwise turn every
     * product page into a wall of shipping markup. A shorter list is incomplete,
     * never untrue: Google reads a missing destination as unknown, not as
     * "not shipped there".
     */
    const MAX_SHIPPING_DESTINATIONS = 10;

    /**
     * Cache voor gerenderde content (voorkomt meerdere HTTP requests)
     *
     * @var array
     */
    private static $rendered_content_cache = array();

    /**
     * Flag om recursieve HTTP requests te voorkomen
     *
     * @var bool
     */
    private static $is_fetching_content = false;

    /**
     * Cache per post: draagt de pagina al een FAQPage van theme of andere plugin?
     *
     * @since 1.3.9
     * @var array
     */
    private static $external_faq_cache = array();

    /**
     * Reden waarom de laatst gebouwde FAQPage is overgeslagen (leeg = niet overgeslagen)
     *
     * @since 1.3.9
     * @var string
     */
    private $faq_skip_reason = '';

    /**
     * Initialize the renderer
     *
     * @param TAMRANK_Schema_Templates $templates Templates manager
     * @param TAMRANK_Schema_Settings $settings Settings manager
     */
    public function __construct($templates, $settings) {
        $this->templates = $templates;
        $this->settings = $settings;

        // Initialiseer detector manager als beschikbaar
        if ( class_exists( 'TAMRANK_Schema_Detector_Manager' ) ) {
            $this->detector_manager = TAMRANK_Schema_Detector_Manager::krijg_instantie();
        }

        add_action('wp_head', array($this, 'render_schema'), 1);

        // The only place that is allowed to look at the whole page: a background
        // job, never a visitor's request. See scan_external_faqpage().
        add_action('tamrank_scan_external_faqpage', array($this, 'scan_external_faqpage'));
    }

    /**
     * Genereer schema preview voor metabox
     * Gebruikt dezelfde logica als de echte schema output
     *
     * @param WP_Post $post Post object
     * @param string $bron Schema bron (automatisch, handmatig, template)
     * @return array|null Schema array voor preview
     */
    public function get_schema_preview( $post, $bron = 'automatisch' ) {
        if ( ! $post ) {
            return null;
        }

        // Zorg dat detector_manager geïnitialiseerd is
        if ( ! $this->detector_manager && class_exists( 'TAMRANK_Schema_Detector_Manager' ) ) {
            $this->detector_manager = TAMRANK_Schema_Detector_Manager::krijg_instantie();
        }

        $schemas = array();

        if ( $bron === 'automatisch' || $bron === 'handmatig' ) {
            // Hoofd schema
            $page_schema = $this->build_automatic_schema( $post );
            if ( ! empty( $page_schema ) ) {
                $page_schema['@id'] = get_permalink( $post->ID ) . '#primary';
                $schemas[] = $page_schema;
            }

            // Extra schemas (HowTo, ImageObject, VideoObject, FAQPage)
            // Gebruik dezelfde functie als de pagina output
            $extra_schemas = $this->build_extra_schemas( $post );
            if ( ! empty( $extra_schemas ) ) {
                foreach ( $extra_schemas as $extra_schema ) {
                    $schemas[] = $extra_schema;
                }
            }
        } elseif ( $bron === 'template' ) {
            $template = $this->get_post_template( $post->ID );
            if ( $template ) {
                $page_schema = $this->build_schema( $post, $template );
                if ( ! empty( $page_schema ) ) {
                    $page_schema['@id'] = get_permalink( $post->ID ) . '#primary';
                    $schemas[] = $page_schema;
                }
            }
        }

        if ( empty( $schemas ) ) {
            return null;
        }

        // Retourneer enkele schema of @graph array
        if ( count( $schemas ) === 1 ) {
            return $schemas[0];
        }

        return array(
            '@context' => 'https://schema.org',
            '@graph'   => $schemas,
        );
    }

    /**
     * Geef de @type's die ECHT in de gerenderde schema-output zitten.
     *
     * Bron-van-waarheid voor de chips in de metabox: de detector beveelt extra
     * schema's aan op score, maar build_extra_schemas() dropt elk type waarvan
     * build_X() null/leeg teruggeeft (bv. HowTo zonder stappen, FAQ zonder Q&A).
     * Wie de chips uit deze lijst voedt, kan nooit een chip tonen zonder
     * bijbehorende JSON-LD. Gebruikt exact dezelfde build als get_schema_preview().
     *
     * @param WP_Post $post Post object
     * @param string  $bron Schema bron (automatisch, handmatig, template)
     * @return array Lijst van @type strings die daadwerkelijk worden geoutput
     */
    public function get_renderable_types( $post, $bron = 'automatisch' ) {
        $preview = $this->get_schema_preview( $post, $bron );
        $types   = array();

        if ( is_array( $preview ) ) {
            if ( isset( $preview['@graph'] ) && is_array( $preview['@graph'] ) ) {
                // Meerdere nodes: verzamel @type uit elke node
                foreach ( $preview['@graph'] as $node ) {
                    if ( is_array( $node ) && isset( $node['@type'] ) && is_string( $node['@type'] ) ) {
                        $types[] = $node['@type'];
                    }
                }
            } elseif ( isset( $preview['@type'] ) && is_string( $preview['@type'] ) ) {
                // get_schema_preview() geeft bij precies één schema de bare node terug
                $types[] = $preview['@type'];
            }
        }

        return array_values( array_unique( $types ) );
    }

    /**
     * Render schema markup in wp_head
     */
    public function render_schema() {
        global $post;

        // Skip schema output als we via HTTP request worden aangeroepen voor content parsing
        // Dit voorkomt recursie en dubbele schema output
        if ( isset( $_GET['tamrank_skip_schema'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['tamrank_skip_schema'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request inspection (admin page / OAuth callback / menu detection); no state-changing action to protect.
            return;
        }

        // Check if using Custom JSON-LD instead of standard organization schema
        $entity_type = $this->settings->get_setting('entity_type', 'Organization');

        if ($entity_type === 'Custom') {
            // Output custom JSON-LD only
            $this->output_global_custom_jsonld();

            // Still add page-specific schemas if on singular post
            $this->render_page_specific_schema($post);
            return;
        }

        // Build schema graph
        $graph = array();

        // The entity node, and the @id every other node points at.
        if ($entity_type === 'LocalBusiness') {
            // Own builder, own @id. On pages outside the chosen output
            // location(s) a minimal stub still renders, so no publisher
            // reference is ever left dangling.
            $entity_id = $this->settings->get_localbusiness_id();
            $entity_schema = $this->settings->get_localbusiness_schema(null, $this->should_render_full_localbusiness());
        } else {
            $entity_id = home_url('/#organization');
            $entity_schema = $this->settings->get_organization_schema();
            if (!empty($entity_schema)) {
                $entity_schema['@id'] = $entity_id;
            }
        }

        if (!empty($entity_schema)) {
            $graph[] = $entity_schema;
        }

        // Add website schema
        $website_schema = array(
            '@type' => 'WebSite',
            '@id' => home_url('/#website'),
            'url' => home_url(),
            'name' => get_bloginfo('name'),
            'publisher' => array(
                '@id' => $entity_id
            ),
            // The site's language, and the search box a search engine may offer
            // straight in the result. Both are what Yoast puts on this node, and
            // a site that migrates over should not lose them.
            'inLanguage' => get_bloginfo('language') ?: 'nl-NL',
            'potentialAction' => array(
                array(
                    '@type' => 'SearchAction',
                    'target' => array(
                        '@type' => 'EntryPoint',
                        'urlTemplate' => home_url('/') . '?s={search_term_string}',
                    ),
                    'query-input' => array(
                        '@type' => 'PropertyValueSpecification',
                        'valueRequired' => true,
                        'valueName' => 'search_term_string',
                    ),
                ),
            ),
        );

        $graph[] = $website_schema;

        // Add page-specific schema if on singular post
        if (is_singular() && isset($post)) {
            // Check if schema is enabled for this post
            if ($this->is_schema_enabled($post->ID)) {
                // Bepaal de schema bron
                $schema_bron = get_post_meta($post->ID, 'tamrank_schema_bron', true);
                if (empty($schema_bron)) {
                    $schema_bron = 'automatisch'; // Default naar automatisch
                }

                // Bij "geen" bron, voeg geen schema toe
                if ($schema_bron === 'geen') {
                    // Skip page-specific schema
                } elseif ($schema_bron === 'template') {
                    // Alleen template-gebaseerde schema
                    $template = $this->get_post_template($post->ID);
                    if ($template) {
                        $page_schema = $this->build_schema($post, $template);
                        if (!empty($page_schema)) {
                            $page_schema['@id'] = get_permalink($post->ID) . '#primary';
                            if (isset($page_schema['publisher'])) {
                                $page_schema['publisher'] = array('@id' => $entity_id);
                            }
                            $graph[] = $page_schema;
                        }
                    }
                } elseif ($schema_bron === 'automatisch' || $schema_bron === 'handmatig') {
                    // Automatisch of handmatig: gebruik gedetecteerde/gekozen schema's
                    $page_schema = $this->build_automatic_schema($post);
                    if (!empty($page_schema)) {
                        $page_schema['@id'] = get_permalink($post->ID) . '#primary';
                        if (isset($page_schema['publisher'])) {
                            $page_schema['publisher'] = array('@id' => $entity_id);
                        }
                        $graph[] = $page_schema;
                    }

                    // Voeg extra gedetecteerde schemas toe (FAQ, HowTo, VideoObject)
                    $extra_schemas = $this->build_extra_schemas($post);
                    if (!empty($extra_schemas)) {
                        foreach ($extra_schemas as $extra_schema) {
                            $graph[] = $extra_schema;
                        }
                    }
                } else {
                    // Fallback: gebruik template indien beschikbaar
                    $template = $this->get_post_template($post->ID);
                    if ($template) {
                        $page_schema = $this->build_schema($post, $template);
                        if (!empty($page_schema)) {
                            $page_schema['@id'] = get_permalink($post->ID) . '#primary';
                            if (isset($page_schema['publisher'])) {
                                $page_schema['publisher'] = array('@id' => $entity_id);
                            }
                            $graph[] = $page_schema;
                        }
                    }
                }
            }
        }

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

        // Build final schema with @graph
        $schema_data = array(
            '@context' => 'https://schema.org',
            '@graph' => $graph
        );

        // Allow filtering before output
        $schema_data = apply_filters('tamrank_schema_graph', $schema_data, $post ?? null, $graph);

        // Output JSON-LD
        $this->output_json_ld($schema_data);
    }

    /**
     * Should the current request carry the FULL LocalBusiness node?
     *
     * Structured data belongs on the page it describes. John Mueller (Google
     * After Hours Hangout, 22 Jan 2019, ~51:36): "As far as I know it's just the
     * home page… we need to be able to find it on somewhere like the home page
     * or the contact page. But if we have it elsewhere then it doesn't change
     * anything for us." Site-wide therefore adds nothing, and on pages carrying
     * their own Article/Product/FAQ markup a full business node is noise —
     * hence homepage-only as the default for new installs.
     * See also https://developers.google.com/search/docs/appearance/structured-data/sd-policies
     *
     * Pages outside the chosen location still get a stub node (@type, @id, name,
     * url) so the WebSite/page publisher reference always resolves.
     *
     * @return bool
     */
    private function should_render_full_localbusiness() {
        $settings = $this->settings->get_settings();
        $location = isset($settings['localbusiness']['output_location'])
            ? $settings['localbusiness']['output_location']
            : 'homepage';

        if ($location === 'all') {
            return true;
        }

        if (is_front_page() || is_home()) {
            return true;
        }

        if ($location === 'homepage_contact') {
            $contact_page_id = isset($settings['localbusiness']['contact_page_id'])
                ? (int) $settings['localbusiness']['contact_page_id']
                : 0;
            if ($contact_page_id > 0 && is_page($contact_page_id)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Render page-specific schema when using Custom JSON-LD for organization
     *
     * @param WP_Post|null $post Post object
     */
    private function render_page_specific_schema($post) {
        if (!is_singular() || !isset($post)) {
            return;
        }

        if (!$this->is_schema_enabled($post->ID)) {
            return;
        }

        $schema_bron = get_post_meta($post->ID, 'tamrank_schema_bron', true);
        if (empty($schema_bron)) {
            $schema_bron = 'automatisch';
        }

        // Skip if no schema wanted
        if ($schema_bron === 'geen') {
            return;
        }

        $graph = array();

        if ($schema_bron === 'template') {
            $template = $this->get_post_template($post->ID);
            if ($template) {
                $page_schema = $this->build_schema($post, $template);
                if (!empty($page_schema)) {
                    $page_schema['@id'] = get_permalink($post->ID) . '#primary';
                    $graph[] = $page_schema;
                }
            }
        } elseif ($schema_bron === 'automatisch' || $schema_bron === 'handmatig') {
            $page_schema = $this->build_automatic_schema($post);
            if (!empty($page_schema)) {
                $page_schema['@id'] = get_permalink($post->ID) . '#primary';
                $graph[] = $page_schema;
            }

            $extra_schemas = $this->build_extra_schemas($post);
            if (!empty($extra_schemas)) {
                foreach ($extra_schemas as $extra_schema) {
                    $graph[] = $extra_schema;
                }
            }
        }

        if (!empty($graph)) {
            $schema_data = array(
                '@context' => 'https://schema.org',
                '@graph' => $graph
            );
            $this->output_json_ld($schema_data);
        }
    }

    /**
     * Check if schema is enabled for a post
     *
     * @param int $post_id Post ID
     * @return bool Schema enabled
     */
    private function is_schema_enabled($post_id) {
        $enabled = get_post_meta($post_id, 'tamrank_schema_enabled', true);

        // Default to enabled if not set
        if ($enabled === '') {
            $enabled = true;
        }

        return apply_filters('tamrank_schema_enabled', (bool)$enabled, $post_id);
    }

    /**
     * Get the template to use for a post
     *
     * @param int $post_id Post ID
     * @return array|null Template or null
     */
    private function get_post_template($post_id) {
        // Check if post has a specific template assigned
        $template_id = get_post_meta($post_id, 'tamrank_schema_template_id', true);

        if ($template_id) {
            $template = $this->templates->get_template($template_id);
            if ($template) {
                return $template;
            }
        }

        // Otherwise, find first template that supports this post type
        $post_type = get_post_type($post_id);
        $templates = $this->templates->get_templates_for_post_type($post_type);

        if (!empty($templates)) {
            return reset($templates); // Get first template
        }

        return null;
    }

    /**
     * Natural primary schema type for a standard post type. Returns '' for custom
     * post types with no obvious mapping (those may legitimately use a bound
     * template). Keeps a blog post on BlogPosting instead of a generic template.
     *
     * @param string $post_type
     * @return string
     */
    private function base_schema_type_for($post_type) {
        switch ($post_type) {
            case 'post':
                return 'BlogPosting';
            case 'page':
                return 'WebPage';
            case 'product':
                return 'Product';
            default:
                return '';
        }
    }

    /**
     * Het paginatype dat een migratie voor deze post overnam
     *
     * `tamrank_schema_type` wordt alleen geschreven door de drie migrators, en
     * alleen als de post nog geen TamRank-schemaconfiguratie had. Het is dus een
     * uitspraak over deze ene pagina, overgenomen uit de plugin waar de site
     * vandaan komt.
     *
     * De waarde gaat rechtstreeks een `@type` in, dus hij moet eruitzien als een
     * schema.org-type. Handgezette meta die dat niet doet valt terug op de rest
     * van de keten in plaats van als rommel in de graph te belanden.
     *
     * @since 1.3.9
     * @param int $post_id Post ID.
     * @return string Type, of een lege string.
     */
    private function migrated_schema_type( $post_id ) {
        $type = get_post_meta( $post_id, 'tamrank_schema_type', true );

        if ( ! is_string( $type ) || '' === $type ) {
            return '';
        }

        return preg_match( '/^[A-Za-z][A-Za-z0-9]*$/', $type ) ? $type : '';
    }

    /**
     * Build automatic/detected schema for a post
     *
     * @param WP_Post $post Post object
     * @return array|null Schema data
     */
    private function build_automatic_schema($post) {
        if (!$this->detector_manager) {
            return null;
        }

        $effectief   = $this->detector_manager->krijg_effectief_schema($post->ID);
        $schema_type = ($effectief && $effectief->get_main_type()) ? $effectief->get_main_type() : '';

        // Two rungs above the detector's guess, in this order:
        //
        //   1. tamrank_schema_type - the page type this one post carried in the
        //      SEO plugin it came from. Only the three migrators ever write it
        //      (Yoast, Rank Math, All in One SEO), so it is absent on every site
        //      that never migrated and this rung is dormant there.
        //   2. A default the site set for this whole post type. The detector
        //      reads the page and guesses; this is the site owner stating what
        //      these pages ARE, which is the one thing reading the content can
        //      never tell you. It is what the Yoast migration adopts from
        //      schema-article-type-<pt>, and on a Yoast site that setting is
        //      often the only place the answer lives.
        //
        // A per-post value beats a site-wide one: it says something about THIS
        // page. Both are beaten by anything the editor chose for this post in
        // TamRank itself, a manual pick in the Schema tab or an explicitly
        // assigned template, because that is the more recent and more deliberate
        // statement.
        //
        // The manual test asks both signals. heeft_handmatige_keuze() reads the
        // flag; krijg_bron() catches the post that carries tamrank_schema_bron =
        // handmatig with a stored type but no flag, where the choice is real and
        // already sits in $schema_type. Overwriting that would be exactly the
        // "never overrule a manual choice" this rung must respect.
        $handmatige_keuze = $this->detector_manager->heeft_handmatige_keuze($post->ID)
            || ($effectief && 'handmatig' === $effectief->krijg_bron());

        if (!$handmatige_keuze && !get_post_meta($post->ID, 'tamrank_schema_template_id', true)) {
            $gemigreerd_type = $this->migrated_schema_type($post->ID);
            $default_type    = function_exists('tamrank_get_default_schema_type') ? tamrank_get_default_schema_type($post->post_type) : '';

            if ($gemigreerd_type !== '') {
                $schema_type = $gemigreerd_type;
            } elseif ($default_type !== '') {
                $schema_type = $default_type;
            }
        }

        if ($schema_type === '') {
            // No stored detection (e.g. an older post created before auto-detect,
            // or one detection has not run on yet).
            // 1) An explicitly assigned template is a deliberate per-post choice.
            $explicit_id = get_post_meta($post->ID, 'tamrank_schema_template_id', true);
            if ($explicit_id) {
                $explicit_tpl = $this->templates->get_template($explicit_id);
                if ($explicit_tpl) {
                    return $this->build_schema($post, $explicit_tpl);
                }
            }
            // 2) Standard content types have a natural primary type — use it so a
            //    blog post always renders BlogPosting, never a generic post-type-
            //    bound template (e.g. a LocalBusiness template bound to 'post').
            $schema_type = $this->base_schema_type_for($post->post_type);
            // 3) Custom post types with no natural type: fall back to a bound
            //    template if the site defined one for them.
            if ($schema_type === '') {
                $template = $this->get_post_template($post->ID);
                return $template ? $this->build_schema($post, $template) : null;
            }
        }

        // Extra types (FAQ, Video, HowTo, Image) mogen niet als hoofd schema - dit zijn altijd aanvullende schemas
        // Als dit toch is gedetecteerd als hoofd type, fallback naar WebPage of BlogPosting
        $extra_only_types = array('FAQPage', 'VideoObject', 'HowTo', 'ImageObject');
        if (in_array($schema_type, $extra_only_types, true)) {
            // Fallback naar BlogPosting voor posts, WebPage voor pagina's
            $schema_type = ($post->post_type === 'post') ? 'BlogPosting' : 'WebPage';
        }

        // Bouw basis schema op basis van type
        $schema = array(
            '@type' => $schema_type
        );

        // Voeg basis velden toe afhankelijk van type
        switch ($schema_type) {
            case 'WebPage':
            case 'AboutPage':
            case 'ContactPage':
            case 'CollectionPage':
            case 'ItemPage':
                $schema['name'] = get_the_title($post);
                $schema['url'] = get_permalink($post->ID);

                // Strip shortcodes en HTML tags voor een schone description
                $description = get_post_meta($post->ID, '_tam_rank_meta_description', true);
                if (!$description) {
                    $clean_content = preg_replace('/\[[^\]]*\]/', '', $post->post_content);
                    $clean_content = wp_strip_all_tags($clean_content);
                    $clean_content = preg_replace('/\s+/', ' ', $clean_content);
                    $description = wp_trim_words(trim($clean_content), 30, '...');
                }
                $schema['description'] = $description;

                // Taal
                $schema['inLanguage'] = get_bloginfo('language') ?: 'nl-NL';

                // When a page was written and when it last changed. Yoast puts
                // both on its WebPage node; without them a migrated page loses
                // the only freshness signal it had.
                $schema['datePublished'] = get_the_date('c', $post);
                $schema['dateModified'] = get_the_modified_date('c', $post);

                if (has_post_thumbnail($post->ID)) {
                    $schema['image'] = $this->get_featured_image_data($post->ID);
                }
                break;

            case 'Article':
            case 'BlogPosting':
            case 'NewsArticle':
                $schema['headline'] = get_the_title($post);
                $schema['url'] = get_permalink($post->ID);
                $schema['datePublished'] = get_the_date('c', $post);
                $schema['dateModified'] = get_the_modified_date('c', $post);
                $schema['author'] = $this->get_author_schema($post);
                $schema['inLanguage'] = get_bloginfo('language') ?: 'nl-NL';

                // How long the piece is. Yoast reports it and Google reads it;
                // an Article without one looks thinner than it is.
                $word_count = $this->get_word_count($post);
                if ($word_count > 0) {
                    $schema['wordCount'] = $word_count;
                }

                // Strip shortcodes en HTML tags voor een schone description
                $description = get_post_meta($post->ID, '_tam_rank_meta_description', true);
                if (!$description) {
                    // Verwijder ALLE shortcodes met regex (inclusief Divi)
                    $clean_content = preg_replace('/\[[^\]]*\]/', '', $post->post_content);
                    $clean_content = wp_strip_all_tags($clean_content);
                    $clean_content = preg_replace('/\s+/', ' ', $clean_content);
                    $description = wp_trim_words(trim($clean_content), 30, '...');
                }
                $schema['description'] = $description;

                if (has_post_thumbnail($post->ID)) {
                    $schema['image'] = $this->get_featured_image_data($post->ID);
                }
                break;

            case 'Product':
                $schema['name'] = get_the_title($post);
                $schema['url'] = get_permalink($post->ID);
                if (has_post_thumbnail($post->ID)) {
                    $schema['image'] = $this->get_featured_image_data($post->ID);
                }

                // The automatic path shipped a Product with a name, a url and an
                // image and nothing else — no price, no availability, no Offer.
                // The three merchant fields live on the Offer, so without one
                // there is nothing to hang them on and no merchant listing to
                // speak of. The Offer is built from the existing WooCommerce
                // helper, so a site without WooCommerce, or a post type that is
                // not a WooCommerce product, gets exactly what it got before.
                $offer = $this->get_woo_offer($post->ID);
                if ($offer && $offer['price'] !== '' && $offer['price'] !== null) {
                    $schema['offers'] = $offer;
                }
                break;

            case 'LocalBusiness':
            case 'Organization':
                $schema['name'] = get_bloginfo('name');
                $schema['url'] = home_url();
                break;

            case 'VideoObject':
                $schema['name'] = get_the_title($post);
                $schema['uploadDate'] = get_the_date('c', $post);

                // Strip shortcodes en HTML tags voor een schone description
                $description = get_post_meta($post->ID, '_tam_rank_meta_description', true);
                if (!$description) {
                    // Verwijder ALLE shortcodes met regex (inclusief Divi)
                    $clean_content = preg_replace('/\[[^\]]*\]/', '', $post->post_content);
                    $clean_content = wp_strip_all_tags($clean_content);
                    $clean_content = preg_replace('/\s+/', ' ', $clean_content);
                    $description = wp_trim_words(trim($clean_content), 30, '...');
                }
                $schema['description'] = $description;

                // Probeer video URL te extraheren uit content
                $content = $post->post_content;

                // YouTube
                if (preg_match('/(?:youtube\.com\/(?:embed\/|watch\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/', $content, $youtube_match)) {
                    $video_id = $youtube_match[1];
                    $schema['contentUrl'] = 'https://www.youtube.com/watch?v=' . $video_id;
                    $schema['embedUrl'] = 'https://www.youtube.com/embed/' . $video_id;
                    $schema['thumbnailUrl'] = 'https://img.youtube.com/vi/' . $video_id . '/maxresdefault.jpg';
                }
                // Vimeo
                elseif (preg_match('/vimeo\.com\/(?:video\/)?(\d+)/', $content, $vimeo_match)) {
                    $video_id = $vimeo_match[1];
                    $schema['contentUrl'] = 'https://vimeo.com/' . $video_id;
                    $schema['embedUrl'] = 'https://player.vimeo.com/video/' . $video_id;
                }
                // HTML5 video
                elseif (preg_match('/<video[^>]*>.*?<source[^>]+src=["\']([^"\']+)["\'][^>]*>.*?<\/video>/is', $content, $video_match)) {
                    $schema['contentUrl'] = $video_match[1];
                }

                // Fallback thumbnail naar featured image
                if (empty($schema['thumbnailUrl']) && has_post_thumbnail($post->ID)) {
                    $schema['thumbnailUrl'] = get_the_post_thumbnail_url($post->ID, 'full');
                }
                break;

            default:
                $schema['name'] = get_the_title($post);
                $schema['url'] = get_permalink($post->ID);
                break;
        }

        return $schema;
    }

    /**
     * Build schema data for a post
     *
     * @param WP_Post $post Post object
     * @param array $template Template configuration
     * @return array Schema data
     */
    private function build_schema($post, $template) {
        // Check if this is a custom JSON-LD template
        if ($template['schema_type'] === 'Custom' &&
            !empty($template['custom_jsonld_enabled']) &&
            !empty($template['custom_jsonld_code'])) {

            return $this->build_custom_schema($post, $template);
        }

        $schema = array(
            '@type' => $template['schema_type']
        );

        // Get post overrides
        $overrides = get_post_meta($post->ID, 'tamrank_schema_overrides', true);
        if (!is_array($overrides)) {
            $overrides = array();
        }

        // Build each property based on field mappings
        foreach ($template['field_mappings'] as $property => $mapping) {
            // Check if there's a manual override for this post
            if (isset($overrides[$property]) && !empty($overrides[$property])) {
                $value = $this->maybe_convert_to_nested($overrides[$property], $property);
            } else {
                $value = $this->get_field_value($post, $mapping, $property);
            }

            if (!empty($value)) {
                $schema[$property] = $value;
            }
        }

        // Allow filtering before render
        $schema = apply_filters('tamrank_schema_template_before_render', $schema, $post, $template);

        return $schema;
    }

    /**
     * Build custom JSON-LD schema by replacing template variables
     *
     * @param WP_Post $post Post object
     * @param array $template Template configuration
     * @return array|null Schema data or null on error
     */
    private function build_custom_schema($post, $template) {
        $json_code = $template['custom_jsonld_code'];

        // Get all template variables and their values
        $replacements = $this->get_template_variable_values($post);

        // Replace all template variables
        foreach ($replacements as $var => $value) {
            // JSON-escape string values (quotes, backslashes, newlines, control
            // chars) before substituting into the raw JSON template. wp_json_encode()
            // of a string is always wrapped in exactly one outer pair of double
            // quotes, so strip exactly one char on each side with substr() — trim()
            // would over-strip values that legitimately start/end with a quote and
            // break json_decode().
            $escaped_value = is_string($value) ? substr(wp_json_encode($value), 1, -1) : $value;
            $json_code = str_replace($var, $escaped_value, $json_code);
        }

        // Try to decode the JSON
        $schema = json_decode($json_code, true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            // Log error but don't break the page
            return null;
        }

        // Remove @context as it will be added at the graph level
        if (isset($schema['@context'])) {
            unset($schema['@context']);
        }

        // Allow filtering
        $schema = apply_filters('tamrank_custom_schema_before_render', $schema, $post, $template);

        return $schema;
    }

    /**
     * Get template variable values for a post
     *
     * @param WP_Post $post Post object
     * @return array Variable => value pairs
     */
    private function get_template_variable_values($post) {
        $values = array();

        // Post data
        $values['{{post_title}}'] = get_the_title($post);
        $values['{{post_url}}'] = get_permalink($post->ID);
        $values['{{post_excerpt}}'] = get_the_excerpt($post);
        $values['{{post_content}}'] = wp_strip_all_tags($post->post_content);
        $values['{{post_date}}'] = get_the_date('c', $post);
        $values['{{post_modified}}'] = get_the_modified_date('c', $post);

        // Featured image
        $thumbnail_id = get_post_thumbnail_id($post->ID);
        $values['{{featured_image}}'] = $thumbnail_id ? wp_get_attachment_image_url($thumbnail_id, 'full') : '';

        // Author
        $values['{{author_name}}'] = get_the_author_meta('display_name', $post->post_author);
        $values['{{author_url}}'] = get_author_posts_url($post->post_author);

        // Site data
        $values['{{site_name}}'] = get_bloginfo('name');
        $values['{{site_url}}'] = home_url('/');
        $values['{{site_logo}}'] = $this->settings->get_setting('logo_url') ?: '';

        // Taxonomies
        $categories = get_the_category($post->ID);
        $values['{{category}}'] = !empty($categories) ? $categories[0]->name : '';

        $tags = get_the_tags($post->ID);
        $values['{{tags}}'] = $tags ? implode(', ', wp_list_pluck($tags, 'name')) : '';

        // Calculated
        $values['{{word_count}}'] = str_word_count(wp_strip_all_tags($post->post_content));

        // Meta description
        $values['{{meta_description}}'] = get_post_meta($post->ID, '_tam_rank_meta_description', true) ?: '';

        return $values;
    }

    /**
     * Get value for a field based on mapping configuration
     *
     * @param WP_Post $post Post object
     * @param array $mapping Field mapping configuration
     * @param string $property Property name for nested type detection
     * @return mixed Field value
     */
    private function get_field_value($post, $mapping, $property = '') {
        $source = $mapping['source'] ?? '';

        switch ($source) {
            case 'post_title':
                return get_the_title($post);

            case 'post_url':
                return get_permalink($post->ID);

            case 'meta_description':
                return get_post_meta($post->ID, '_tam_rank_meta_description', true);

            case 'excerpt':
                return get_the_excerpt($post);

            case 'content':
                return wp_strip_all_tags($post->post_content);

            case 'content_excerpt':
                $content = wp_strip_all_tags($post->post_content);
                return wp_trim_words($content, 30, '...');

            case 'featured_image':
                return $this->get_featured_image_data($post->ID);

            case 'first_content_image':
                return $this->get_first_content_image($post);

            case 'author_name':
                return $this->get_author_schema($post);

            case 'author_url':
                return get_author_posts_url($post->post_author);

            case 'post_date':
                return get_the_date('c', $post);

            case 'post_modified':
                return get_the_modified_date('c', $post);

            case 'category':
                $categories = get_the_category($post->ID);
                if (!empty($categories)) {
                    return $categories[0]->name;
                }
                return null;

            case 'tags':
                $tags = get_the_tags($post->ID);
                if (!empty($tags)) {
                    return implode(', ', wp_list_pluck($tags, 'name'));
                }
                return null;

            case 'auto_wordcount':
                $content = wp_strip_all_tags($post->post_content);
                return str_word_count($content);

            case 'site_url':
                return home_url('/');

            case 'global_organization':
                return $this->settings->get_organization_schema();

            case 'global_logo':
                $logo_url = $this->settings->get_setting('logo_url');
                if ($logo_url) {
                    return array(
                        '@type' => 'ImageObject',
                        'url' => $logo_url
                    );
                }
                return null;

            // WooCommerce sources
            case 'woo_price':
                return $this->get_woo_price($post->ID);

            case 'woo_currency':
                return $this->get_woo_currency();

            case 'woo_stock':
                return $this->get_woo_stock_status($post->ID);

            case 'woo_sku':
                return $this->get_woo_sku($post->ID);

            case 'woo_weight':
                return $this->get_woo_weight($post->ID);

            case 'woo_category':
                return $this->get_woo_category($post->ID);

            case 'woo_rating':
                return $this->get_woo_rating($post->ID);

            case 'woo_reviews':
                return $this->get_woo_reviews($post->ID);

            case 'woo_offer':
                return $this->get_woo_offer($post->ID);

            case 'custom_field':
                $meta_key = $mapping['custom_field'] ?? '';
                if ($meta_key) {
                    $value = get_post_meta($post->ID, $meta_key, true);
                    // Handle nested types based on property name
                    return $this->maybe_convert_to_nested($value, $property);
                }
                return null;

            case 'manual':
                $value = $mapping['value'] ?? '';
                // Handle nested types based on property name
                return $this->maybe_convert_to_nested($value, $property);

            default:
                return null;
        }
    }

    /**
     * Convert value to nested schema type if needed
     *
     * @param mixed $value Raw value
     * @param string $property Property name
     * @return mixed Converted value
     */
    private function maybe_convert_to_nested($value, $property) {
        if (empty($value)) {
            return $value;
        }

        // Handle nested types based on property name
        switch ($property) {
            case 'brand':
                if (is_string($value)) {
                    return array(
                        '@type' => 'Brand',
                        'name' => $value
                    );
                }
                return $value;

            case 'location':
            case 'jobLocation':
                if (is_string($value)) {
                    // Try to parse as JSON first
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_place_schema($decoded);
                    }
                    // Simple string - use as name
                    return array(
                        '@type' => 'Place',
                        'name' => $value
                    );
                }
                return $value;

            case 'organizer':
            case 'provider':
            case 'hiringOrganization':
            case 'author':
                if (is_string($value)) {
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_organization_schema($decoded);
                    }
                    return array(
                        '@type' => 'Organization',
                        'name' => $value
                    );
                }
                return $value;

            case 'offers':
                if (is_string($value)) {
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_offer_schema($decoded);
                    }
                    // Try to parse as price
                    if (is_numeric($value)) {
                        return array(
                            '@type' => 'Offer',
                            'price' => floatval($value),
                            'priceCurrency' => 'EUR',
                            'availability' => 'https://schema.org/InStock'
                        );
                    }
                }
                return $value;

            case 'aggregateRating':
                if (is_string($value)) {
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_rating_schema($decoded);
                    }
                    // Single number - use as rating value
                    if (is_numeric($value)) {
                        return array(
                            '@type' => 'AggregateRating',
                            'ratingValue' => floatval($value),
                            'bestRating' => 5
                        );
                    }
                }
                return $value;

            case 'address':
                if (is_string($value)) {
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_address_schema($decoded);
                    }
                    return array(
                        '@type' => 'PostalAddress',
                        'streetAddress' => $value
                    );
                }
                return $value;

            case 'geo':
                if (is_string($value)) {
                    // Parse coordinates: "52.3676, 4.9041"
                    $parts = array_map('trim', explode(',', $value));
                    if (count($parts) === 2 && is_numeric($parts[0]) && is_numeric($parts[1])) {
                        return array(
                            '@type' => 'GeoCoordinates',
                            'latitude' => floatval($parts[0]),
                            'longitude' => floatval($parts[1])
                        );
                    }
                }
                return $value;

            case 'nutrition':
                if (is_string($value)) {
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_nutrition_schema($decoded);
                    }
                }
                return $value;

            case 'baseSalary':
                if (is_string($value)) {
                    $decoded = json_decode($value, true);
                    if (is_array($decoded)) {
                        return $this->build_salary_schema($decoded);
                    }
                    if (is_numeric($value)) {
                        return array(
                            '@type' => 'MonetaryAmount',
                            'currency' => 'EUR',
                            'value' => array(
                                '@type' => 'QuantitativeValue',
                                'value' => floatval($value),
                                'unitText' => 'YEAR'
                            )
                        );
                    }
                }
                return $value;

            case 'recipeIngredient':
                if (is_string($value)) {
                    // Parse as newline-separated list
                    $ingredients = array_filter(array_map('trim', explode("\n", $value)));
                    if (!empty($ingredients)) {
                        return $ingredients;
                    }
                }
                return $value;

            case 'recipeInstructions':
            case 'step':
                if (is_string($value)) {
                    // Parse as newline-separated steps
                    $steps = array_filter(array_map('trim', explode("\n", $value)));
                    $instructions = array();
                    foreach ($steps as $index => $step) {
                        $instructions[] = array(
                            '@type' => 'HowToStep',
                            'position' => $index + 1,
                            'text' => $step
                        );
                    }
                    if (!empty($instructions)) {
                        return $instructions;
                    }
                }
                return $value;

            case 'sameAs':
                if (is_string($value)) {
                    // Parse as comma-separated URLs
                    $urls = array_filter(array_map('trim', explode(',', $value)));
                    if (!empty($urls)) {
                        return $urls;
                    }
                }
                return $value;

            case 'interactionStatistic':
                if (is_numeric($value)) {
                    return array(
                        '@type' => 'InteractionCounter',
                        'interactionType' => array('@type' => 'WatchAction'),
                        'userInteractionCount' => intval($value)
                    );
                }
                return $value;

            default:
                return $value;
        }
    }

    /**
     * Build Place schema from array
     */
    private function build_place_schema($data) {
        $schema = array('@type' => 'Place');

        if (!empty($data['name'])) {
            $schema['name'] = $data['name'];
        }

        if (!empty($data['address']) || !empty($data['streetAddress'])) {
            $schema['address'] = $this->build_address_schema($data);
        }

        if (!empty($data['latitude']) && !empty($data['longitude'])) {
            $schema['geo'] = array(
                '@type' => 'GeoCoordinates',
                'latitude' => floatval($data['latitude']),
                'longitude' => floatval($data['longitude'])
            );
        }

        return $schema;
    }

    /**
     * Build Organization schema from array
     */
    private function build_organization_schema($data) {
        $schema = array('@type' => 'Organization');

        $fields = array('name', 'url', 'logo', 'telephone', 'email');
        foreach ($fields as $field) {
            if (!empty($data[$field])) {
                $schema[$field] = $data[$field];
            }
        }

        if (!empty($data['address']) || !empty($data['streetAddress'])) {
            $schema['address'] = $this->build_address_schema($data);
        }

        return $schema;
    }

    /**
     * Build Offer schema from array
     */
    private function build_offer_schema($data) {
        $schema = array('@type' => 'Offer');

        if (!empty($data['price'])) {
            $schema['price'] = floatval($data['price']);
        }
        $schema['priceCurrency'] = $data['priceCurrency'] ?? 'EUR';

        if (!empty($data['availability'])) {
            $schema['availability'] = $data['availability'];
        } else {
            $schema['availability'] = 'https://schema.org/InStock';
        }

        if (!empty($data['url'])) {
            $schema['url'] = $data['url'];
        }

        if (!empty($data['validFrom'])) {
            $schema['validFrom'] = $data['validFrom'];
        }

        return $schema;
    }

    /**
     * Build AggregateRating schema from array
     */
    private function build_rating_schema($data) {
        $schema = array('@type' => 'AggregateRating');

        if (!empty($data['ratingValue'])) {
            $schema['ratingValue'] = floatval($data['ratingValue']);
        }
        $schema['bestRating'] = $data['bestRating'] ?? 5;
        $schema['worstRating'] = $data['worstRating'] ?? 1;

        if (!empty($data['ratingCount'])) {
            $schema['ratingCount'] = intval($data['ratingCount']);
        }
        if (!empty($data['reviewCount'])) {
            $schema['reviewCount'] = intval($data['reviewCount']);
        }

        return $schema;
    }

    /**
     * Build PostalAddress schema from array
     */
    private function build_address_schema($data) {
        $schema = array('@type' => 'PostalAddress');

        $fields = array(
            'streetAddress', 'addressLocality', 'addressRegion',
            'postalCode', 'addressCountry'
        );

        foreach ($fields as $field) {
            if (!empty($data[$field])) {
                $schema[$field] = $data[$field];
            }
        }

        // Also check for nested address
        if (!empty($data['address']) && is_string($data['address'])) {
            $schema['streetAddress'] = $data['address'];
        }

        return $schema;
    }

    /**
     * Build NutritionInformation schema from array
     */
    private function build_nutrition_schema($data) {
        $schema = array('@type' => 'NutritionInformation');

        $fields = array(
            'calories', 'carbohydrateContent', 'proteinContent',
            'fatContent', 'fiberContent', 'sodiumContent', 'sugarContent'
        );

        foreach ($fields as $field) {
            if (!empty($data[$field])) {
                $schema[$field] = $data[$field];
            }
        }

        return $schema;
    }

    /**
     * Build MonetaryAmount schema for salary
     */
    private function build_salary_schema($data) {
        $schema = array(
            '@type' => 'MonetaryAmount',
            'currency' => $data['currency'] ?? 'EUR'
        );

        $value_schema = array('@type' => 'QuantitativeValue');

        if (!empty($data['value'])) {
            $value_schema['value'] = floatval($data['value']);
        } elseif (!empty($data['minValue']) && !empty($data['maxValue'])) {
            $value_schema['minValue'] = floatval($data['minValue']);
            $value_schema['maxValue'] = floatval($data['maxValue']);
        }

        $value_schema['unitText'] = $data['unitText'] ?? 'YEAR';

        $schema['value'] = $value_schema;

        return $schema;
    }

    /**
     * WooCommerce: Get product price
     */
    private function get_woo_price($post_id) {
        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product($post_id);
        if (!$product) {
            return null;
        }

        return $product->get_price();
    }

    /**
     * WooCommerce: Get currency
     */
    private function get_woo_currency() {
        if (!function_exists('get_woocommerce_currency')) {
            return 'EUR';
        }

        return get_woocommerce_currency();
    }

    /**
     * WooCommerce: Get stock status
     */
    private function get_woo_stock_status($post_id) {
        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product($post_id);
        if (!$product) {
            return null;
        }

        $status = $product->get_stock_status();
        $status_map = array(
            'instock' => 'https://schema.org/InStock',
            'outofstock' => 'https://schema.org/OutOfStock',
            'onbackorder' => 'https://schema.org/BackOrder'
        );

        return $status_map[$status] ?? 'https://schema.org/InStock';
    }

    /**
     * WooCommerce: Get SKU
     */
    private function get_woo_sku($post_id) {
        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product($post_id);
        if (!$product) {
            return null;
        }

        return $product->get_sku();
    }

    /**
     * WooCommerce: Get weight
     */
    private function get_woo_weight($post_id) {
        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product($post_id);
        if (!$product) {
            return null;
        }

        $weight = $product->get_weight();
        if ($weight) {
            $unit = get_option('woocommerce_weight_unit', 'kg');
            return $weight . ' ' . $unit;
        }

        return null;
    }

    /**
     * WooCommerce: Get category
     */
    private function get_woo_category($post_id) {
        $terms = get_the_terms($post_id, 'product_cat');
        if (!empty($terms) && !is_wp_error($terms)) {
            return $terms[0]->name;
        }
        return null;
    }

    /**
     * WooCommerce: Get aggregate rating
     */
    private function get_woo_rating($post_id) {
        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product($post_id);
        if (!$product) {
            return null;
        }

        $rating = $product->get_average_rating();
        $count = $product->get_review_count();

        if ($rating && $count > 0) {
            return array(
                '@type' => 'AggregateRating',
                'ratingValue' => floatval($rating),
                'reviewCount' => intval($count),
                'bestRating' => 5,
                'worstRating' => 1
            );
        }

        return null;
    }

    /**
     * WooCommerce: Get reviews
     */
    private function get_woo_reviews($post_id) {
        $comments = get_comments(array(
            'post_id' => $post_id,
            'status' => 'approve',
            'type' => 'review',
            'number' => 5
        ));

        if (empty($comments)) {
            return null;
        }

        $reviews = array();
        foreach ($comments as $comment) {
            $rating = get_comment_meta($comment->comment_ID, 'rating', true);
            $review = array(
                '@type' => 'Review',
                'author' => array(
                    '@type' => 'Person',
                    'name' => $comment->comment_author
                ),
                'reviewBody' => $comment->comment_content,
                'datePublished' => get_comment_date('c', $comment)
            );

            if ($rating) {
                $review['reviewRating'] = array(
                    '@type' => 'Rating',
                    'ratingValue' => intval($rating),
                    'bestRating' => 5
                );
            }

            $reviews[] = $review;
        }

        return $reviews;
    }

    /**
     * WooCommerce: Get full offer schema
     */
    private function get_woo_offer($post_id) {
        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product($post_id);
        if (!$product) {
            return null;
        }

        $offer = array(
            '@type' => 'Offer',
            'price' => $product->get_price(),
            'priceCurrency' => get_woocommerce_currency(),
            'availability' => $this->get_woo_stock_status($post_id),
            'url' => get_permalink($post_id)
        );

        // Add sale price info if applicable. Only when a real end date exists —
        // the previous version assigned null on a sale without an end date,
        // which shipped "priceValidUntil": null into the markup.
        if ($product->is_on_sale() && $product->get_sale_price() && $product->get_date_on_sale_to()) {
            $offer['priceValidUntil'] = $product->get_date_on_sale_to()->date('Y-m-d');
        }

        $this->add_merchant_fields($offer, $product);

        return $offer;
    }

    /**
     * Attach the merchant listing fields to an Offer node (MERCH-01).
     *
     * shippingDetails, hasMerchantReturnPolicy and itemCondition are the three
     * fields Google asks a webshop for on top of a product snippet. Each one is
     * added only when its source actually holds a value:
     *
     *  - shippingDetails is derived from the shop's own WooCommerce shipping
     *    zones and rates. No zones, no numeric rate, no country: no field.
     *  - hasMerchantReturnPolicy has no WooCommerce source at all and comes
     *    from the schema settings. Not configured: no field.
     *  - itemCondition likewise. WooCommerce has no product condition field, so
     *    a silent default "new" would be TamRank claiming something on the
     *    shop's behalf.
     *
     * @param array      $offer   Offer node, by reference.
     * @param WC_Product $product The product the caller already loaded.
     */
    private function add_merchant_fields(&$offer, $product) {
        $merchant = $this->settings->get_merchant_settings();

        if (!empty($merchant['shipping_enabled'])) {
            $shipping = $this->get_woo_shipping_details($product, $merchant);
            if (!empty($shipping)) {
                // One destination stays a single node; several become a list,
                // which is what Google expects for a multi-country shop.
                $offer['shippingDetails'] = (count($shipping) === 1) ? $shipping[0] : $shipping;
            }
        }

        $return_policy = $this->settings->get_return_policy_schema();
        if ($return_policy) {
            $offer['hasMerchantReturnPolicy'] = $return_policy;
        }

        if ($merchant['item_condition'] !== '') {
            $offer['itemCondition'] = $merchant['item_condition'];
        }
    }

    /**
     * WooCommerce: build OfferShippingDetails nodes from the shop's shipping
     * zones (MERCH-01).
     *
     * The rules below are all "do not claim what the shop did not configure":
     *
     *  - A zone contributes only through location types that carry a country:
     *    'country' (code is the ISO code) and 'state' (code is 'NL:ZH', so the
     *    country is the part before the colon and the region the part after).
     *    'continent' zones carry no ISO country code and 'postcode' zones carry
     *    no country at all, so neither can produce a truthful
     *    shippingDestination. Zone 0 ("Locations not covered by your other
     *    zones") has no locations by definition and is skipped for the same
     *    reason.
     *  - A rate is only usable when it resolves to a plain number for a single
     *    item. A flat_rate cost of '10.00 + [qty] * 2' depends on the order, so
     *    it is skipped rather than guessed at. Free shipping counts as 0 only
     *    when it carries no condition — a "free above 50 euro" rate is not free
     *    for this product.
     *  - Shipping class costs are added the way WooCommerce adds them for a
     *    one-item package (base cost plus this product's class cost), so a shop
     *    that uses shipping classes gets its real number instead of the base.
     *  - Tax follows the shop's own display setting: WooCommerce stores the
     *    shipping cost excluding tax, so with 'incl' display the base-location
     *    shipping tax is added and with 'excl' the stored number is exactly what
     *    the shop shows.
     *
     * @param WC_Product $product  The product, already loaded by the caller.
     * @param array      $merchant Merchant settings subtree.
     * @return array List of OfferShippingDetails nodes (possibly empty).
     */
    private function get_woo_shipping_details($product, $merchant) {
        if (!class_exists('WC_Shipping_Zones') || !$product) {
            return array();
        }

        // A virtual or downloadable product is never shipped, so a shipping
        // rate would be a statement about something that does not happen.
        if ($product->is_virtual() || $product->is_downloadable()) {
            return array();
        }

        $currency      = get_woocommerce_currency();
        $delivery_time = $this->build_delivery_time($merchant);
        $nodes         = array();
        $seen          = array();

        foreach ($this->get_woo_shipping_zones() as $zone) {
            if ((int) $zone->get_id() === 0) {
                continue;
            }

            // Destinations first: the zone's locations are already in memory
            // from the batched read, while its shipping methods cost a query
            // per zone. A continent or postcode zone can never produce a
            // destination, so asking it for its rates is wasted work.
            $destinations = $this->get_zone_destinations($zone);
            if (empty($destinations)) {
                continue;
            }

            $rate = $this->get_zone_shipping_rate($zone, $product);
            if ($rate === null) {
                continue;
            }

            foreach ($destinations as $destination) {
                $key = $destination['addressCountry'] . '|' . (isset($destination['addressRegion']) ? $destination['addressRegion'] : '');
                if (isset($seen[$key])) {
                    // First zone wins. A later zone for the same destination is
                    // an override the shop set up for a subset of orders, and
                    // two OfferShippingDetails for one country read as a
                    // contradiction rather than a choice.
                    continue;
                }
                $seen[$key] = true;

                $node = array(
                    '@type'        => 'OfferShippingDetails',
                    'shippingRate' => array(
                        '@type'    => 'MonetaryAmount',
                        'value'    => $rate,
                        'currency' => $currency,
                    ),
                    'shippingDestination' => array_merge(
                        array('@type' => 'DefinedRegion'),
                        $destination
                    ),
                );

                if ($delivery_time) {
                    $node['deliveryTime'] = $delivery_time;
                }

                $nodes[] = $node;

                if (count($nodes) >= self::MAX_SHIPPING_DESTINATIONS) {
                    return $nodes;
                }
            }
        }

        return $nodes;
    }

    /**
     * The shop's shipping zones as objects.
     *
     * WC_Shipping_Zones::get_shipping_zones() is preferred over get_zones():
     * the latter also builds a human-readable location label for every zone,
     * which this renderer never uses and which emits a PHP notice on a zone
     * whose state code WooCommerce no longer knows. Older WooCommerce releases
     * that lack the method fall back to the array API.
     *
     * @return WC_Shipping_Zone[]
     */
    private function get_woo_shipping_zones() {
        if (method_exists('WC_Shipping_Zones', 'get_shipping_zones')) {
            return WC_Shipping_Zones::get_shipping_zones();
        }

        $zones = array();
        foreach (WC_Shipping_Zones::get_zones() as $zone_data) {
            if (isset($zone_data['zone_id'])) {
                $zones[] = new WC_Shipping_Zone((int) $zone_data['zone_id']);
            }
        }
        return $zones;
    }

    /**
     * The ShippingDeliveryTime node, or null when the shop left the times empty.
     *
     * WooCommerce stores no handling or transit time anywhere, so these two
     * come from the schema settings and stay absent until someone fills them in.
     * Google needs a min and a max for each; a half-filled pair is dropped.
     *
     * @param array $merchant Merchant settings subtree.
     * @return array|null
     */
    private function build_delivery_time($merchant) {
        $node = array('@type' => 'ShippingDeliveryTime');

        $pairs = array(
            'handlingTime' => array('handling_time_min', 'handling_time_max'),
            'transitTime'  => array('transit_time_min', 'transit_time_max'),
        );

        foreach ($pairs as $property => $keys) {
            list($min_key, $max_key) = $keys;
            if ($merchant[$min_key] === '' || $merchant[$max_key] === '') {
                continue;
            }
            $min = (int) $merchant[$min_key];
            $max = (int) $merchant[$max_key];
            if ($max < $min) {
                continue;
            }
            $node[$property] = array(
                '@type'    => 'QuantitativeValue',
                'minValue' => $min,
                'maxValue' => $max,
                'unitCode' => 'DAY',
            );
        }

        return count($node) > 1 ? $node : null;
    }

    /**
     * The destinations a shipping zone covers, as DefinedRegion fragments.
     *
     * @param WC_Shipping_Zone $zone Zone.
     * @return array List of arrays with addressCountry and optionally addressRegion.
     */
    private function get_zone_destinations($zone) {
        $destinations = array();
        $locations    = $zone->get_zone_locations();

        // "Limit to specific postcodes" is stored as extra locations of type
        // 'postcode' NEXT TO the country in the same zone, so reading only the
        // country would widen the shop's own rule from a handful of postcodes
        // to the whole country. DefinedRegion cannot express that narrowing
        // here, so the zone is skipped entirely: incomplete is never untrue.
        foreach ($locations as $location) {
            if ($location->type === 'postcode') {
                return array();
            }
        }

        foreach ($locations as $location) {
            if ($location->type === 'country') {
                $country = strtoupper(trim($location->code));
                if (preg_match('/^[A-Z]{2}$/', $country)) {
                    $destinations[] = array('addressCountry' => $country);
                }
                continue;
            }

            if ($location->type === 'state') {
                // WooCommerce stores a state as 'NL:ZH'. Shipping only to one
                // region and saying "we ship to NL" would widen the shop's own
                // rule, so the region always ships along.
                $parts = explode(':', $location->code, 2);
                $country = strtoupper(trim($parts[0]));
                if (count($parts) === 2 && preg_match('/^[A-Z]{2}$/', $country) && trim($parts[1]) !== '') {
                    $destinations[] = array(
                        'addressCountry' => $country,
                        'addressRegion'  => trim($parts[1]),
                    );
                }
            }

            // 'continent' deliberately produces nothing: it carries no ISO
            // country code that DefinedRegion can state. 'postcode' already
            // took the whole zone out above.
        }

        return $destinations;
    }

    /**
     * The lowest shipping rate a customer can actually get in this zone for
     * this single product, or null when no rate resolves to a plain number.
     *
     * @param WC_Shipping_Zone $zone    Zone.
     * @param WC_Product       $product Product.
     * @return float|null
     */
    private function get_zone_shipping_rate($zone, $product) {
        $lowest = null;

        foreach ($zone->get_shipping_methods(true) as $method) {
            $cost = null;

            if ($method->id === 'flat_rate') {
                $cost = $this->resolve_flat_rate_cost($method, $product);
            } elseif ($method->id === 'free_shipping') {
                // 'requires' empty means the WooCommerce method is available
                // without a coupon or a minimum order — see
                // WC_Shipping_Free_Shipping::is_available(), where every other
                // value gates availability on the cart.
                if (trim((string) $method->get_option('requires')) === '') {
                    $cost = 0.0;
                }
            }
            // local_pickup is collection, not shipping, and every other method
            // is a third-party class whose cost model is unknown here.

            if ($cost === null) {
                continue;
            }

            $cost = $this->add_shipping_tax($cost, $method, $product);

            if ($lowest === null || $cost < $lowest) {
                $lowest = $cost;
            }
        }

        return $lowest;
    }

    /**
     * A flat rate's cost for one unit of this product, or null when the cost is
     * a formula rather than a number.
     *
     * @param WC_Shipping_Method $method  Flat rate method instance.
     * @param WC_Product         $product Product.
     * @return float|null
     */
    private function resolve_flat_rate_cost($method, $product) {
        $base = trim((string) $method->get_option('cost'));
        if ($base === '' || !is_numeric($base)) {
            return null;
        }

        $total = (float) $base;

        // WooCommerce adds the shipping class cost on top of the base cost; for
        // a one-item package the 'class' and 'order' cost types land on the same
        // number. See WC_Shipping_Flat_Rate::calculate_shipping().
        $class_id  = $product->get_shipping_class_id();
        $class_key = $class_id ? 'class_cost_' . $class_id : 'no_class_cost';
        $class_cost = trim((string) $method->get_option($class_key, ''));

        if ($class_cost !== '') {
            if (!is_numeric($class_cost)) {
                return null;
            }
            $total += (float) $class_cost;
        }

        // A shop can type a negative cost into the field and WooCommerce takes
        // it, but "we pay you to ship it" is not a price any shop shows. No
        // claim beats a wrong one.
        if ($total < 0) {
            return null;
        }

        return $total;
    }

    /**
     * Add shipping tax when the shop displays prices including tax.
     *
     * WooCommerce stores a shipping cost excluding tax. With
     * woocommerce_tax_display_shop = 'excl' the stored number is exactly what
     * the shop shows, so it ships unchanged. With 'incl' the customer sees the
     * taxed number, and the tax is computed at the shop's base location so the
     * markup does not move with the visitor.
     *
     * The tax CLASS follows the shop the way WC_Tax::get_shipping_tax_rates()
     * does: the woocommerce_shipping_tax_class option wins unless it is left on
     * 'inherit', in which case the class of the items in the package applies —
     * for this one-item package, the class of this product. Always billing the
     * standard rate would claim a shipping price the shop never shows.
     *
     * @param float              $cost    Cost excluding tax.
     * @param WC_Shipping_Method $method  Method instance.
     * @param WC_Product         $product The product this package holds.
     * @return float
     */
    private function add_shipping_tax($cost, $method, $product) {
        if (!function_exists('wc_tax_enabled') || !wc_tax_enabled() || !class_exists('WC_Tax')) {
            return $cost;
        }
        if (get_option('woocommerce_tax_display_shop') !== 'incl') {
            return $cost;
        }
        if ($method->get_option('tax_status') === 'none') {
            return $cost;
        }

        // WC_Tax::get_shipping_tax_rates() reads this option first and only
        // falls back to the package's own class on 'inherit'; 'standard' is
        // stored as the empty class.
        $shipping_class = get_option('woocommerce_shipping_tax_class', 'inherit');
        if ('inherit' === $shipping_class) {
            $tax_class = $product ? $product->get_tax_class() : '';
        } else {
            $tax_class = ('standard' === $shipping_class) ? '' : $shipping_class;
        }

        $base = wc_get_base_location();
        $rates = WC_Tax::find_shipping_rates(array(
            'country'   => isset($base['country']) ? $base['country'] : '',
            'state'     => isset($base['state']) ? $base['state'] : '',
            'postcode'  => '',
            'city'      => '',
            'tax_class' => $tax_class,
        ));

        if (empty($rates)) {
            return $cost;
        }

        $taxes = WC_Tax::calc_shipping_tax($cost, $rates);

        return round($cost + array_sum($taxes), wc_get_price_decimals());
    }

    /**
     * Get featured image data
     *
     * @param int $post_id Post ID
     * @return array|null Image schema
     */
    private function get_featured_image_data($post_id) {
        $thumbnail_id = get_post_thumbnail_id($post_id);

        if (!$thumbnail_id) {
            return null;
        }

        $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
        $image_data = wp_get_attachment_metadata($thumbnail_id);

        $schema = array(
            '@type' => 'ImageObject',
            'url' => $image_url
        );

        if (isset($image_data['width'])) {
            $schema['width'] = $image_data['width'];
        }

        if (isset($image_data['height'])) {
            $schema['height'] = $image_data['height'];
        }

        return $schema;
    }

    /**
     * Get first image from post content
     *
     * @param WP_Post $post Post object
     * @return array|null Image schema
     */
    private function get_first_content_image($post) {
        preg_match('/<img.+?src=[\'"]([^\'"]+)[\'"].*?>/i', $post->post_content, $matches);

        if (isset($matches[1])) {
            return array(
                '@type' => 'ImageObject',
                'url' => $matches[1]
            );
        }

        return null;
    }

    /**
     * Get author schema
     *
     * @param WP_Post $post Post object
     * @return array Author schema
     */
    private function get_author_schema($post) {
        $author_id = $post->post_author;

        $author = array(
            '@type' => 'Person',
            'name' => get_the_author_meta('display_name', $author_id),
            'url' => get_author_posts_url($author_id)
        );

        // The avatar, when the site has one to show. Gravatar is off on plenty of
        // sites and then get_avatar_url() returns false, so this stays absent
        // rather than pointing at an image that does not load. An imported post
        // with author 0 gets nothing either: Gravatar hands out a placeholder for
        // an address it has never seen, and a made-up face is worse than none.
        if ($author_id && function_exists('get_avatar_url')) {
            $avatar = get_avatar_url($author_id, array('size' => 96));
            if (!empty($avatar)) {
                $author['image'] = array(
                    '@type' => 'ImageObject',
                    'url' => $avatar,
                );
            }
        }

        return $author;
    }

    /**
     * How many words the post holds, shortcodes and markup removed.
     *
     * str_word_count() is ASCII-only, so it splits every accented Dutch word in
     * two. Counting whitespace-separated tokens on the cleaned text is both
     * simpler and right for the languages this plugin ships in.
     *
     * @since 1.3.8.2
     * @param WP_Post $post Post object.
     * @return int
     */
    private function get_word_count($post) {
        $content = preg_replace('/\[[^\]]*\]/', '', (string) $post->post_content);
        $content = wp_strip_all_tags($content);
        $content = trim(preg_replace('/\s+/', ' ', $content));

        if ('' === $content) {
            return 0;
        }

        return count(preg_split('/\s+/u', $content, -1, PREG_SPLIT_NO_EMPTY));
    }

    /**
     * Output JSON-LD script tag
     *
     * @param array $schema_data Schema data
     */
    private function output_json_ld($schema_data) {
        echo '<script type="application/ld+json">' . "\n";
        echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP);
        echo "\n</script>\n";
    }

    /**
     * Output global custom JSON-LD when entity type is Custom
     */
    private function output_global_custom_jsonld() {
        // Get custom JSON-LD code
        $custom_code = $this->settings->get_setting('custom_jsonld_code', '');
        if (empty($custom_code)) {
            return;
        }

        // Validate JSON before output
        $decoded = json_decode($custom_code);
        if (json_last_error() !== JSON_ERROR_NONE) {
            // Invalid JSON, log error but don't output
            return;
        }

        // Output the custom JSON-LD
        echo '<script type="application/ld+json">' . "\n";
        echo wp_json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP);
        echo "\n</script>\n";
    }

    /**
     * Output debug informatie naar error_log
     *
     * @param string $functie   Naam van de functie
     * @param array  $debug_info Debug informatie
     */
    private function output_debug( $functie, $debug_info ) {
        // Alleen naar error_log schrijven voor debugging (geen HTML output)
    }

    /**
     * Get the source material the extra schemas (FAQ/HowTo/Video/Image) are built from
     *
     * In order, cheapest first:
     *   1. `the_content` - on the vast majority of sites (Avada, Divi and
     *      WPBakery included, as long as the builder is active) this already
     *      renders the shortcodes in full;
     *   2. the page source stored in post meta, written during save_post;
     *   3. only inside a logged-in admin context: a self-fetch of the post's own
     *      permalink.
     *
     * An anonymous front-end request never reaches step 3. A visitor must not
     * wait on a loopback call to this very site, so without a stored source the
     * front end quietly falls back to the `the_content` result.
     *
     * @param WP_Post $post Post object
     * @return string Rendered HTML content
     */
    private function get_rendered_content( $post ) {
        $post_id = $post->ID;

        // Check cache eerst
        if ( isset( self::$rendered_content_cache[ $post_id ] ) ) {
            return self::$rendered_content_cache[ $post_id ];
        }

        // Voorkom recursieve HTTP requests (als we al bezig zijn met fetchen)
        if ( self::$is_fetching_content ) {
            return $post->post_content;
        }

        // Probeer eerst de normale content filter (werkt voor de meeste builders)
        $content = apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- applying WordPress core 'the_content' filter, not a custom plugin hook

        // Decide on the RENDERED content, not on raw post_content. With the page
        // builder active `the_content` has already expanded the shortcodes, so the
        // rendered content IS the source material and no second copy is needed.
        // Judging the raw content is what made every Avada page fetch itself.
        if ( ! tamrank_needs_rendered_source( $post, $content ) ) {
            self::$rendered_content_cache[ $post_id ] = $content;
            $this->note_rendered_source( $post_id, 'the_content' );
            return $content;
        }

        // The stored page source. save_post writes this meta; the transient is the
        // pre-1.3.9 cache and stays readable until the post is saved again.
        $stored = tamrank_get_rendered_source( $post_id );
        if ( '' !== $stored ) {
            self::$rendered_content_cache[ $post_id ] = $stored;
            $this->note_rendered_source( $post_id, 'stored-meta' );
            return $stored;
        }

        // Nothing stored. Only a logged-in admin context may still fetch; a
        // visitor quietly gets the `the_content` result instead.
        if ( ! $this->may_self_fetch( $post_id ) ) {
            self::$rendered_content_cache[ $post_id ] = $content;
            $this->note_rendered_source( $post_id, 'the_content-fallback' );
            return $content;
        }

        $url = get_permalink( $post_id );
        if ( empty( $url ) ) {
            self::$rendered_content_cache[ $post_id ] = $content;
            $this->note_rendered_source( $post_id, 'the_content-fallback' );
            return $content;
        }

        $rendered_content = $this->fetch_rendered_source( $post_id, $url );
        if ( '' === $rendered_content ) {
            self::$rendered_content_cache[ $post_id ] = $content;
            $this->note_rendered_source( $post_id, 'the_content-fallback' );
            return $content;
        }

        self::$rendered_content_cache[ $post_id ] = $rendered_content;
        $this->note_rendered_source( $post_id, 'self-fetch' );

        return $rendered_content;
    }

    /**
     * May this request fetch the post's own permalink over HTTP?
     *
     * Never from an anonymous front-end request: that loopback is exactly what
     * kept visitors waiting. Only WP-CLI and a logged-in editor in an admin,
     * AJAX or REST context - where the wait lands on the editor, not on a
     * visitor - are allowed to fetch.
     *
     * @param int $post_id Post ID
     * @return bool
     */
    private function may_self_fetch( $post_id ) {
        if ( defined( 'WP_CLI' ) && WP_CLI ) {
            return true;
        }

        if ( ! is_user_logged_in() || ! current_user_can( 'edit_post', $post_id ) ) {
            return false;
        }

        return is_admin() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST );
    }

    /**
     * Fetch the rendered page and store the result persistently
     *
     * Fails silently: every error returns an empty string, after which the caller
     * falls back to the `the_content` result.
     *
     * @param int    $post_id Post ID
     * @param string $url     Permalink of the post
     * @return string Rendered content, or an empty string
     */
    private function fetch_rendered_source( $post_id, $url ) {
        if ( tamrank_rendered_source_fetch_blocked( $post_id ) ) {
            return '';
        }

        // Zet flag om recursie te voorkomen
        self::$is_fetching_content = true;

        // Voeg een query param toe om onze eigen schema output te skippen bij de HTTP request
        $url = add_query_arg( 'tamrank_skip_schema', '1', $url );

        // HTTP request naar de pagina - met extra opties voor lokale omgevingen
        $response = wp_remote_get( $url, array(
            'timeout'     => 15,
            // Self-fetch of this site's own permalink. Verify TLS in production; relax only on local WP_DEBUG dev.
            'sslverify'   => ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ),
            'redirection' => 5,
            'httpversion' => '1.1',
            'blocking'    => true,
            'headers'     => array(
                'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            ),
            // Voorkom dat WordPress caching interfereert
            'cookies'     => array(),
        ) );

        // Reset flag
        self::$is_fetching_content = false;

        if ( is_wp_error( $response ) ) {
            tamrank_mark_rendered_source_fetch_failed( $post_id );
            return '';
        }

        $html = wp_remote_retrieve_body( $response );
        if ( empty( $html ) ) {
            tamrank_mark_rendered_source_fetch_failed( $post_id );
            return '';
        }

        // The whole page is in hand exactly here, and the content-area extraction
        // below throws the <script> blocks away. So answer the FAQPage collision
        // question now, for free, instead of paying for a second request.
        tamrank_store_external_faqpage( $post_id, $this->content_has_external_faq_schema( $html ) );

        // Voor Divi: gebruik gewoon de volledige HTML body want het bevat alle gerenderde modules
        // Dit is de meest betrouwbare manier om alle content te krijgen
        $rendered_content = $html;

        // Probeer specifieke content areas te extraheren voor betere prestaties
        // Divi: et-main-area bevat de hoofdcontent
        if ( preg_match( '/<div[^>]*id=["\']et-main-area["\'][^>]*>(.*)/is', $html, $match ) ) {
            // Haal alles tot de footer
            $main_area = $match[1];
            if ( preg_match( '/^(.*?)(?:<footer|<div[^>]*id=["\'](?:footer|et-footer)["\'])/is', $main_area, $footer_match ) ) {
                $rendered_content = $footer_match[1];
            } else {
                $rendered_content = $main_area;
            }
        }
        // Elementor: elementor content area
        elseif ( preg_match( '/<div[^>]*class="[^"]*elementor[^"]*"[^>]*data-elementor-type=["\']wp-page["\'][^>]*>(.*?)<\/div>\s*(?:<footer|$)/is', $html, $match ) ) {
            $rendered_content = $match[1];
        }
        // Algemeen (block/custom-themes): neem de hele <body> minus scripts/styles.
        // Bewust NIET de eerste <article>/<main> pakken — block-themes hebben vaak
        // kleine decoy-<article>'s (bijv. hulp-kaartjes onderaan een FAQ-pagina)
        // die de echte content missen, zoals de <details>-FAQ-accordions. De
        // extra-schema-builders zoeken naar specifieke patronen, dus de bredere
        // body-content schaadt niet maar zorgt wél dat de accordions meekomen.
        else {
            $body_content = $this->extract_body_content( $html );
            if ( ! empty( $body_content ) ) {
                $rendered_content = $body_content;
            }
        }

        // Store it, so from here on the front end only has to read.
        tamrank_store_rendered_source( $post_id, $rendered_content );

        return $rendered_content;
    }

    /**
     * Record which source the extra schemas were built from (observability only)
     *
     * @param int    $post_id Post ID
     * @param string $bron    the_content|the_content-fallback|stored-meta|self-fetch
     */
    private function note_rendered_source( $post_id, $bron ) {
        /**
         * Fires once the renderer has picked a source for the extra schemas.
         *
         * @since 1.3.9
         * @param int    $post_id Post ID
         * @param string $bron    The chosen source
         */
        do_action( 'tamrank_schema_rendered_source', $post_id, $bron );
    }

    /**
     * Extraheer de <body> uit volledige pagina-HTML, minus scripts/styles/svg.
     *
     * Theme-agnostisch: geen vaste klassenamen. We strippen alleen wat veilig is
     * (script/style/svg/noscript kunnen geen pagina-content over-strippen) en
     * behouden de content-tags (zoals <details>/<summary>) die de schema-builders
     * nodig hebben. Bedoeld voor block/custom-themes waar de content in het
     * template zit i.p.v. in een herkenbare content-container.
     *
     * @param string $html Volledige pagina-HTML
     * @return string Body-HTML of lege string
     */
    private function extract_body_content( $html ) {
        if ( empty( $html ) ) {
            return '';
        }

        if ( preg_match( '/<body\b[^>]*>(.*)<\/body>/is', $html, $body_match ) ) {
            $body = $body_match[1];
        } else {
            $body = $html;
        }

        $body = preg_replace( array(
            '/<script[^>]*>.*?<\/script>/si',
            '/<style[^>]*>.*?<\/style>/si',
            '/<svg[^>]*>.*?<\/svg>/si',
            '/<noscript[^>]*>.*?<\/noscript>/si',
        ), '', $body );

        return trim( (string) $body );
    }


    /**
     * Bouw extra schemas op basis van detectie (FAQ, HowTo, VideoObject, ImageObject)
     *
     * @param WP_Post $post Post object
     * @return array Array van extra schema's
     */
    private function build_extra_schemas( $post ) {
        $extra_schemas = array();
        $debug_info = array();

        // Verse build, dus een reden uit een vorige build mag niet blijven hangen.
        $this->faq_skip_reason = '';

        // Controleer of detector beschikbaar is
        if ( ! $this->detector_manager ) {
            $this->output_debug( 'build_extra_schemas', array( 'error' => 'detector_manager niet beschikbaar' ) );
            return $extra_schemas;
        }

        // Frontend schema rendering is strictly read-only: use the stored
        // detection result. Re-running detection is an authenticated action
        // (the editor's AJAX handler, guarded by current_user_can('edit_post'),
        // and automatic detection on save_post) and must never be reachable from
        // an anonymous front-end request.
        $effectief = $this->detector_manager->krijg_effectief_schema( $post->ID );

        // Debug: log wat we ophalen uit de database
        if ( $effectief ) {
        }

        // Do NOT bail when there is no stored detection: post/page/product always
        // get a BreadcrumbList (SEO best practice, no detection needed), so an
        // undetected post must still reach the BreadcrumbList branch below.
        $extra_types = $effectief ? $effectief->get_extra_types() : array();

        // BreadcrumbList wordt ALTIJD toegevoegd voor posts, pages en products
        // Dit is SEO best practice en hoeft niet gedetecteerd te worden
        if ( tamrank_post_type_has_breadcrumb_schema( $post->post_type ) ) {
            if ( ! in_array( 'BreadcrumbList', $extra_types, true ) ) {
                $extra_types[] = 'BreadcrumbList';
            }
        }

        $debug_info['extra_types'] = $extra_types;

        if ( empty( $extra_types ) ) {
            $this->output_debug( 'build_extra_schemas', array( 'error' => 'geen extra types', 'effectief' => $effectief ? $effectief->naar_array() : null ) );
            return $extra_schemas;
        }

        // Only the content-derived extra types need the (cached) rendered-content
        // self-fetch; a BreadcrumbList-only post (e.g. one without detection) skips
        // the HTTP round-trip entirely.
        $content_extra_types = array( 'FAQPage', 'HowTo', 'VideoObject', 'ImageObject' );
        $combined_content    = '';
        if ( array_intersect( $extra_types, $content_extra_types ) ) {
            // Haal gerenderde content op (met caching - slechts 1 HTTP request per pagina)
            $rendered_content = $this->get_rendered_content( $post );

            // Combineer gerenderde content met ruwe post_content voor betere dekking
            // Dit is belangrijk omdat HTTP requests in lokale omgevingen kunnen falen
            // en sommige patronen (zoals YouTube URLs) ook in shortcodes zitten
            $ruwe_content     = $post->post_content;
            $combined_content = $rendered_content . "\n\n" . $ruwe_content;

            $debug_info['ruwe_content_length']     = strlen( $ruwe_content );
            $debug_info['rendered_content_length'] = strlen( $rendered_content );
            $debug_info['combined_content_length'] = strlen( $combined_content );
            $debug_info['ruwe_content_preview']    = substr( $ruwe_content, 0, 500 );
        }

        foreach ( $extra_types as $extra_type ) {
            switch ( $extra_type ) {
                case 'FAQPage':
                    // Draagt de pagina al een FAQPage van het theme of een andere
                    // plugin, dan zwijgen we. Twee FAQPages met verschillende vragen
                    // naast elkaar is voor Google tegenstrijdige markup, en de partij
                    // die de accordion zelf rendert weet beter wat erin staat.
                    //
                    // Twee bronnen voor dat antwoord, want de content alleen is niet
                    // genoeg: Avada print zijn FAQPage in wp_footer en Yoast/Rank Math
                    // in de <head>, allebei buiten `the_content`. Gemeten op 30 echte
                    // Avada-artikelen: 0 van de 30 zichtbaar in de content, 30 van de
                    // 30 in de volledige pagina. De achtergrondcheck kijkt daarom
                    // eenmalig naar de hele pagina en legt het antwoord vast in post
                    // meta; een bezoeker wacht daar nooit op.
                    $externe_faq = tamrank_get_external_faqpage( $post->ID );
                    if ( null === $externe_faq ) {
                        tamrank_queue_external_faqpage_scan( $post->ID );
                    }

                    // Een Yoast- of Rank Math-FAQ-blok verraadt zichzelf in de ruwe
                    // post_content, en dat is het enige signaal dat we meteen hebben:
                    // die twee printen hun graph in de <head>, dus noch `the_content`
                    // noch de gerenderde content die hierboven staat laat hem zien, en
                    // de achtergrondcheck heeft zijn antwoord de eerste keer nog niet.
                    // Zonder deze regel bouwt TamRank er dus een tweede FAQPage naast,
                    // precies wat de regel hierboven wil voorkomen.
                    if ( $this->content_has_foreign_faq_block( $post->post_content ) ) {
                        $this->faq_skip_reason          = 'faq_block_from_other_plugin';
                        $debug_info['faq_overgeslagen'] = $this->faq_skip_reason;
                        break;
                    }

                    if ( true === $externe_faq || $this->content_has_external_faq_schema( $combined_content ) ) {
                        $this->faq_skip_reason          = 'existing_faqpage_in_source';
                        $debug_info['faq_overgeslagen'] = $this->faq_skip_reason;
                        break;
                    }

                    $faq_schema = $this->build_faq_schema( $combined_content, $post );
                    if ( $faq_schema ) {
                        $extra_schemas[] = $faq_schema;
                    }
                    break;

                case 'HowTo':
                    $howto_schema = $this->build_howto_schema( $combined_content, $post );
                    if ( $howto_schema ) {
                        $extra_schemas[] = $howto_schema;
                    }
                    break;

                case 'VideoObject':
                    $video_schema = $this->build_video_schema( $combined_content, $post );
                    if ( $video_schema ) {
                        $extra_schemas[] = $video_schema;
                    }
                    break;

                case 'ImageObject':
                    $image_schemas = $this->build_image_schemas( $combined_content, $post );
                    if ( ! empty( $image_schemas ) ) {
                        foreach ( $image_schemas as $img_schema ) {
                            $extra_schemas[] = $img_schema;
                        }
                    }
                    break;

                case 'BreadcrumbList':
                    $breadcrumb_schema = $this->build_breadcrumb_schema( $post );
                    if ( $breadcrumb_schema ) {
                        $extra_schemas[] = $breadcrumb_schema;
                    }
                    break;
            }
        }

        // Debug output voor build_extra_schemas
        $debug_info['schemas_built'] = array_map( function( $schema ) {
            return isset( $schema['@type'] ) ? $schema['@type'] : 'unknown';
        }, $extra_schemas );
        $this->output_debug( 'build_extra_schemas', $debug_info );

        /**
         * Filter extra schemas voordat ze worden toegevoegd
         *
         * @param array   $extra_schemas Array van extra schema's
         * @param WP_Post $post          Post object
         * @param object  $effectief     Effectieve schema keuze
         */
        return apply_filters( 'tamrank_extra_schemas', $extra_schemas, $post, $effectief );
    }

    /**
     * Bouw FAQPage schema van content
     * Ondersteunt: Divi shortcodes, Divi HTML, Elementor accordions, standaard headings, Vraag/Antwoord patronen
     *
     * @param string  $content Post content
     * @param WP_Post $post    Post object
     * @return array|null FAQPage schema of null
     */
    private function build_faq_schema( $content, $post ) {
        $faq_items = array();

        // ============================================
        // DIVI SHORTCODE PATRONEN (database opslag)
        // ============================================

        // Patroon 0a: Divi Toggle SHORTCODE met title attribuut
        // Voorbeeld: [et_pb_toggle title="Dit is een vraag?"]Antwoord hier[/et_pb_toggle]
        if ( preg_match_all( '/\[et_pb_toggle[^\]]*title="([^"]+)"[^\]]*\](.*?)\[\/et_pb_toggle\]/is', $content, $matches, PREG_SET_ORDER ) ) {
            foreach ( $matches as $match ) {
                $question = wp_strip_all_tags( trim( $match[1] ) );
                // Strip ALLE shortcodes met regex (inclusief geneste Divi shortcodes)
                $answer   = preg_replace( '/\[[^\]]*\]/', '', $match[2] );
                $answer   = wp_strip_all_tags( trim( $answer ) );
                $answer   = preg_replace( '/\s+/', ' ', $answer );
                $answer   = trim( $answer );

                if ( ! empty( $question ) && ! empty( $answer ) && strlen( $answer ) > 5 ) {
                    $faq_items[] = array(
                        '@type'          => 'Question',
                        'name'           => $question,
                        'acceptedAnswer' => array(
                            '@type' => 'Answer',
                            'text'  => $answer,
                        ),
                    );
                }
            }
        }

        // Patroon 0b: Divi Accordion Item SHORTCODE
        // Voorbeeld: [et_pb_accordion_item title="Vraag?"]Antwoord[/et_pb_accordion_item]
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/\[et_pb_accordion_item[^\]]*title="([^"]+)"[^\]]*\](.*?)\[\/et_pb_accordion_item\]/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = wp_strip_all_tags( trim( $match[1] ) );
                    // Strip ALLE shortcodes met regex (inclusief geneste Divi shortcodes)
                    $answer   = preg_replace( '/\[[^\]]*\]/', '', $match[2] );
                    $answer   = wp_strip_all_tags( trim( $answer ) );
                    $answer   = preg_replace( '/\s+/', ' ', $answer );
                    $answer   = trim( $answer );

                    if ( ! empty( $question ) && ! empty( $answer ) && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // ============================================
        // DIVI HTML PATRONEN (gerenderde frontend)
        // ============================================

        // Patroon 1: Divi Toggle/Accordion modules - Meest exacte match
        // Voorbeeld: <h5 class="et_pb_toggle_title">Vraag?</h5><div class="et_pb_toggle_content clearfix">Antwoord</div>
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/<h[1-6]\s+class="et_pb_toggle_title"[^>]*>([^<]+)<\/h[1-6]><div\s+class="et_pb_toggle_content[^"]*"[^>]*>(.*?)<\/div>/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = wp_strip_all_tags( trim( $match[1] ) );
                    $answer   = wp_strip_all_tags( trim( $match[2] ) );

                    if ( ! empty( $question ) && ! empty( $answer ) && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 1b: Divi Toggle HTML - flexibeler met whitespace en andere attributen
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/<h[1-6][^>]*class="[^"]*et_pb_toggle_title[^"]*"[^>]*>(.*?)<\/h[1-6]>\s*<div[^>]*class="[^"]*et_pb_toggle_content[^"]*"[^>]*>(.*?)<\/div>/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = wp_strip_all_tags( trim( $match[1] ) );
                    $answer   = wp_strip_all_tags( trim( $match[2] ) );

                    if ( ! empty( $question ) && ! empty( $answer ) && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 1c: Divi Toggle HTML - zoek op class namen los van elkaar (meest flexibel)
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/et_pb_toggle_title[^>]*>([^<]+)<\/h[1-6]>.*?et_pb_toggle_content[^>]*>(.*?)<\/div>/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = wp_strip_all_tags( trim( $match[1] ) );
                    $answer   = wp_strip_all_tags( trim( $match[2] ) );

                    if ( ! empty( $question ) && ! empty( $answer ) && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 1d: Avada / Fusion Builder. Het FAQ-element en het
        // Accordion/Toggle-element renderen dezelfde markup:
        //   div.fusion-faqs-wrapper (of div.fusion-accordian)
        //     > div.fusion-panel > div.panel-heading > h3.panel-title.toggle > a
        //       > div.fusion-toggle-heading   = de vraag
        //     > div.panel-collapse > div.panel-body.toggle-content = het antwoord
        // Het woord "faq" staat alleen op de wrapper, nooit op het trigger-element,
        // dus patroon 2d (class-gebaseerd) loopt hier straal langs. Zonder dit
        // patroon viel een Avada-pagina terug op de koppen-heuristiek en kreeg de
        // FAQPage de sectiekoppen in plaats van de echte accordion-vragen.
        if ( empty( $faq_items ) ) {
            $faq_items = $this->extract_fusion_faq_items( $content );
        }

        // Patroon 2: Elementor Accordion widgets
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/<[^>]*class="[^"]*elementor-tab-title[^"]*"[^>]*>(.*?)<\/[^>]+>\s*<[^>]*class="[^"]*elementor-tab-content[^"]*"[^>]*>(.*?)<\/div>/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = wp_strip_all_tags( trim( $match[1] ) );
                    $answer   = wp_strip_all_tags( trim( $match[2] ) );

                    if ( ! empty( $question ) && ! empty( $answer ) && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 2b: HTML5 <details>/<summary> accordions (theme-agnostisch).
        // Dé framework-onafhankelijke FAQ-bouwsteen. De vraag halen we uit de
        // <summary> (specifiek de tekst-run met het vraagteken, zodat nummers of
        // categorie-labels zoals "01" / "GETTING STARTED" ervoor niet meekomen);
        // het antwoord is de rest van het <details>-element.
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/<details\b[^>]*>.*?<summary\b[^>]*>(.*?)<\/summary>(.*?)<\/details>/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    if ( preg_match( '/([^<>]*\?)/s', $match[1], $vraag_match ) ) {
                        $question = trim( preg_replace( '/\s+/', ' ', $vraag_match[1] ) );
                    } else {
                        $question = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $match[1] ) ) );
                    }

                    $answer = preg_replace( '/\[[^\]]*\]/', '', $match[2] );
                    $answer = wp_strip_all_tags( $answer );
                    $answer = preg_replace( '/\s+/', ' ', $answer );
                    $answer = trim( $answer );

                    if ( ! empty( $question ) && strpos( $question, '?' ) !== false && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 2c: definitielijst-FAQ (<dt> = vraag, <dd> = antwoord). Vereist
        // een vraagteken zodat een gewone woordenlijst geen FAQ wordt.
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/<dt\b[^>]*>(.*?)<\/dt>\s*<dd\b[^>]*>(.*?)<\/dd>/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $match[1] ) ) );

                    $answer = preg_replace( '/\[[^\]]*\]/', '', $match[2] );
                    $answer = wp_strip_all_tags( $answer );
                    $answer = preg_replace( '/\s+/', ' ', $answer );
                    $answer = trim( $answer );

                    if ( ! empty( $question ) && strpos( $question, '?' ) !== false && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 2d: class-gebaseerde FAQ-accordion (theme-agnostisch, geen
        // <details>). Een trigger-element (button/link/div/heading) met een
        // class die "faq" + een trigger-woord bevat, direct gevolgd door een
        // content-element met "faq" + een content-woord. Dekt hand-gerolde en
        // block-theme accordions zoals `faq-item__trigger` / `faq-item__content`.
        // Cruciaal: doordat de vraag aan een class-gebonden antwoord-element
        // hangt, kan een losse CTA-kop hier nooit binnensluipen.
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '~<(button|a|div|h[1-6])[^>]*class="[^"]*faq[^"]*(?:trigger|question|header|title|toggle|head)[^"]*"[^>]*>(.*?)</\1>\s*<(div|section|p)[^>]*class="[^"]*faq[^"]*(?:content|answer|body|panel)[^"]*"[^>]*>(.*?)</\3>~is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    // Vraag: pak de tekst-run met het vraagteken (zodat icon-/label-
                    // markup in de trigger niet meekomt); anders alle tags strippen.
                    if ( preg_match( '/([^<>]*\?)/s', $match[2], $vraag_match ) ) {
                        $question = trim( preg_replace( '/\s+/', ' ', $vraag_match[1] ) );
                    } else {
                        $question = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $match[2] ) ) );
                    }

                    $answer = preg_replace( '/\[[^\]]*\]/', '', $match[4] );
                    $answer = wp_strip_all_tags( $answer );
                    $answer = preg_replace( '/\s+/', ' ', $answer );
                    $answer = trim( $answer );

                    // Zelfde vraagteken-eis als patronen 2b/2c: zonder '?' kan
                    // een sectiekop met faq-classes ("Frequently Asked Questions")
                    // als nep-vraag in het schema belanden.
                    if ( ! empty( $question ) && strpos( $question, '?' ) !== false && strlen( $answer ) > 5 ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Patroon 3: H2/H3/H4 headings die eindigen met ? — laatste redmiddel.
        // Dit heuristiek heeft geen structureel FAQ-signaal (geen accordion, geen
        // Q/A-koppeling), dus twee harde guards tegen valse positieven zoals een
        // CTA-kop ("Ready to take control of your SEO?"):
        //   (a) CTA-context: sla koppen over die vlak achter een CTA/knop-wrapper
        //       staan (class bevat "cta") — dat is een call-to-action, geen vraag.
        //   (c) Antwoord-kwaliteit: het antwoordblok moet minstens een alinea met
        //       acht of meer woorden bevatten die niet uit links of knoppen bestaat.
        //   (b) Een losse '?'-kop is nooit een FAQ. Vereis 2+ vraag/antwoord-paren,
        //       net als de scorer (bereken_score_faq geeft 0 punten voor 1 vraagkop).
        //       Tellen doen we op UNIEKE vragen: build_extra_schemas() voert deze
        //       methode de gerenderde én de ruwe content na elkaar, dus dezelfde
        //       kop matcht standaard twee keer. Op de ruwe telling haalt één
        //       CTA-kop daardoor alsnog de drempel — precies hoe "Still deciding
        //       whether to switch?" op tamrank.com FAQPage-schema werd.
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/<h[2-4][^>]*>([^<]*\?)<\/h[2-4]>/i', $content, $matches, PREG_SET_ORDER ) ) {
                $heading_items = array();
                foreach ( $matches as $match ) {
                    // Zoek het antwoord (content tot de volgende heading)
                    $pos = strpos( $content, $match[0] );
                    if ( $pos === false ) {
                        continue;
                    }

                    // (a) CTA-context guard: een kop direct na een CTA/knop-wrapper
                    // (class ~ "cta") is een call-to-action, geen FAQ-vraag. Het
                    // venster eindigt exact op de kop-positie (lengte = $pos - $start)
                    // zodat het nooit vooruit leest naar een CTA verderop.
                    $lead_start = max( 0, $pos - 250 );
                    $lead_in    = substr( $content, $lead_start, $pos - $lead_start );
                    if ( preg_match( '/class="[^"]*\bcta\b[^"]*"/i', $lead_in ) ) {
                        continue;
                    }

                    $question       = wp_strip_all_tags( $match[1] );
                    $after_question = substr( $content, $pos + strlen( $match[0] ) );

                    // Haal content tot de volgende heading
                    if ( preg_match( '/^(.*?)(?=<h[1-6]|$)/is', $after_question, $answer_match ) ) {
                        // (c) Antwoord-kwaliteit. Een kop telt alleen als vraag als er
                        // een echte alinea onder staat: minstens een <p> die na het
                        // weghalen van links en knoppen acht woorden overhoudt. Een
                        // CTA-blok ("Snel meer zekerheid?" met een rijtje knoppen
                        // eronder) heeft die niet en valt hier af, ook als de wrapper
                        // geen class met "cta" draagt zoals guard (a) verwacht.
                        if ( ! $this->faq_answer_has_paragraph( $answer_match[1] ) ) {
                            continue;
                        }

                        // Strip ALLE shortcodes met regex (inclusief Divi shortcodes)
                        $answer = preg_replace( '/\[[^\]]*\]/', '', $answer_match[1] );
                        // Strip HTML tags
                        $answer = wp_strip_all_tags( $answer );
                        // Normaliseer whitespace (verwijder tabs, newlines, meervoudige spaties)
                        $answer = preg_replace( '/\s+/', ' ', $answer );
                        $answer = trim( $answer );

                        if ( ! empty( $answer ) && strlen( $answer ) > 10 ) {
                            $heading_items[] = array(
                                '@type'          => 'Question',
                                'name'           => $question,
                                'acceptedAnswer' => array(
                                    '@type' => 'Answer',
                                    'text'  => $answer,
                                ),
                            );
                        }
                    }
                }

                // (b) Alleen aannemen als er minstens 2 UNIEKE vraag/antwoord-paren zijn.
                $heading_items = $this->dedupe_faq_items( $heading_items );
                if ( count( $heading_items ) >= 2 ) {
                    $faq_items = $heading_items;
                }
            }
        }

        // Patroon 4: Zoek naar "Vraag:" / "Antwoord:" patronen
        if ( empty( $faq_items ) ) {
            if ( preg_match_all( '/(?:vraag|question)[:\s]+([^<\n]+).*?(?:antwoord|answer)[:\s]+([^<\n]+)/is', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $question = wp_strip_all_tags( trim( $match[1] ) );
                    $answer   = wp_strip_all_tags( trim( $match[2] ) );

                    if ( ! empty( $question ) && ! empty( $answer ) ) {
                        $faq_items[] = array(
                            '@type'          => 'Question',
                            'name'           => $question,
                            'acceptedAnswer' => array(
                                '@type' => 'Answer',
                                'text'  => $answer,
                            ),
                        );
                    }
                }
            }
        }

        // Eén vraag hoort één keer in mainEntity te staan. Google beschouwt een
        // herhaalde Question als ongeldige FAQPage-markup, en herhalen gebeurt
        // hier vanzelf: build_extra_schemas() plakt de gerenderde content en de
        // ruwe post_content achter elkaar, dus content die in post_content staat
        // (het gewone geval) matcht elk patroon twee keer. Dit is de vangnet-stap
        // die daar niet van afhangt: ook een thema dat zijn FAQ twee keer in de
        // HTML zet, levert hierna één set vragen op.
        $faq_items = $this->dedupe_faq_items( $faq_items );

        if ( empty( $faq_items ) ) {
            return null;
        }

        return array(
            '@type'      => 'FAQPage',
            '@id'        => get_permalink( $post->ID ) . '#faq',
            'mainEntity' => $faq_items,
        );
    }

    /**
     * Haal de vraag/antwoord-paren uit Avada's FAQ- en Accordion-element
     *
     * De vraag staat in `div.fusion-toggle-heading`, het antwoord in de
     * eerstvolgende `div...toggle-content`. Dat antwoordblok bevat geneste divs
     * (kolommen, knoppen), dus we tellen de div-diepte uit in plaats van te
     * stoppen bij de eerste `</div>`.
     *
     * @since 1.3.9
     * @param string $content Gerenderde bron.
     * @return array Question-items, in documentvolgorde.
     */
    private function extract_fusion_faq_items( $content ) {
        $items = array();

        if ( stripos( $content, 'fusion-toggle-heading' ) === false ) {
            return $items;
        }

        $found = preg_match_all(
            '~<div\b[^>]*class="[^"]*fusion-toggle-heading[^"]*"[^>]*>(.*?)</div>~is',
            $content,
            $koppen,
            PREG_SET_ORDER | PREG_OFFSET_CAPTURE
        );
        if ( ! $found ) {
            return $items;
        }

        $aantal = count( $koppen );
        foreach ( $koppen as $index => $kop ) {
            $question = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $kop[1][0] ) ) );
            if ( $question === '' ) {
                continue;
            }

            // Het antwoord hoort bij deze kop, dus het moet vóór de volgende kop
            // beginnen. Zonder die grens zou de laatste kop het antwoord van een
            // los accordion verderop op de pagina kunnen inpikken.
            $na_kop  = $kop[0][1] + strlen( $kop[0][0] );
            $grens   = ( $index + 1 < $aantal ) ? $koppen[ $index + 1 ][0][1] : strlen( $content );
            $treffer = preg_match(
                '~<div\b[^>]*class="[^"]*\btoggle-content\b[^"]*"[^>]*>~i',
                $content,
                $body,
                PREG_OFFSET_CAPTURE,
                $na_kop
            );
            if ( ! $treffer || $body[0][1] >= $grens ) {
                continue;
            }

            $answer = $this->inner_html_of_div( $content, $body[0][1] );
            $answer = preg_replace( '/\[[^\]]*\]/', '', $answer );
            // Een antwoord met meerdere blokken mag niet aaneengeplakt raken:
            // zonder deze spatie wordt "...aangezegd.</p><p>Zo staat..." tot
            // "aangezegd.Zo staat".
            $answer = preg_replace( '~<br\s*/?>|</(?:p|div|li|h[1-6]|blockquote)>~i', ' ', $answer );
            $answer = wp_strip_all_tags( $answer );
            $answer = trim( preg_replace( '/\s+/', ' ', $answer ) );

            if ( strlen( $answer ) > 5 ) {
                $items[] = array(
                    '@type'          => 'Question',
                    'name'           => $question,
                    'acceptedAnswer' => array(
                        '@type' => 'Answer',
                        'text'  => $answer,
                    ),
                );
            }
        }

        return $items;
    }

    /**
     * Geef de inhoud van het <div> dat op $offset begint, met geneste divs mee
     *
     * Een niet-gulzige `(.*?)</div>` stopt bij de eerste sluittag en kapt daarmee
     * elk antwoord af dat kolommen of knoppen bevat. Deze scanner telt de diepte
     * uit en levert het hele blok.
     *
     * @since 1.3.9
     * @param string $html   HTML-bron.
     * @param int    $offset Positie van de `<` van de opening-tag.
     * @return string Inhoud van het element, of een lege string bij ongebalanceerde HTML.
     */
    private function inner_html_of_div( $html, $offset ) {
        $open_end = strpos( $html, '>', $offset );
        if ( $open_end === false ) {
            return '';
        }

        $start = $open_end + 1;
        $diepte = 1;
        $pos    = $start;
        $lengte = strlen( $html );

        while ( $pos < $lengte && $diepte > 0 ) {
            if ( ! preg_match( '~<div\b|</div\s*>~i', $html, $token, PREG_OFFSET_CAPTURE, $pos ) ) {
                return '';
            }

            $is_open = ( strtolower( substr( $token[0][0], 0, 2 ) ) === '<d' );
            if ( $is_open ) {
                $diepte++;
                $pos = $token[0][1] + strlen( $token[0][0] );
                continue;
            }

            $diepte--;
            if ( $diepte === 0 ) {
                return substr( $html, $start, $token[0][1] - $start );
            }
            $pos = $token[0][1] + strlen( $token[0][0] );
        }

        return '';
    }

    /**
     * Heeft dit antwoordblok een echte alinea, of is het een knoppenblok?
     *
     * De koppen-terugval heeft geen structureel FAQ-signaal, dus hij pikt een
     * CTA-kop met een rijtje knoppen eronder net zo makkelijk op als een echte
     * vraag. Een antwoord telt daarom alleen als er minstens één <p> in staat die
     * na het weghalen van links en knoppen nog acht woorden overhoudt.
     *
     * @since 1.3.9
     * @param string $html Antwoord-HTML zoals die in de bron staat.
     * @return bool
     */
    private function faq_answer_has_paragraph( $html ) {
        if ( ! preg_match_all( '~<p\b[^>]*>(.*?)</p>~is', (string) $html, $alineas ) ) {
            return false;
        }

        foreach ( $alineas[1] as $alinea ) {
            $zonder_knoppen = preg_replace( '~<(a|button)\b[^>]*>.*?</\1>~is', '', $alinea );
            $tekst          = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $zonder_knoppen ) ) );
            if ( $tekst !== '' && str_word_count( $tekst ) >= 8 ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Kijk eenmalig naar de HELE pagina en leg vast of iemand anders al een FAQPage levert.
     *
     * Draait uitsluitend uit WP-Cron, nooit uit het verzoek van een bezoeker. Dat
     * is de kern van de zaak: Avada print zijn FAQPage in `wp_footer` en Yoast en
     * Rank Math in de `<head>`, allebei buiten `the_content`, dus de bron die de
     * renderer op de front-end ziet kan de vraag niet beantwoorden. Gemeten op 30
     * echte Avada-artikelen: 0 van de 30 zichtbaar in de content, 30 van de 30 in
     * de volledige pagina.
     *
     * Mislukt de fetch, dan blijft het antwoord onbekend in plaats van "nee": een
     * kapotte loopback mag ons niet stilletjes een dubbele FAQPage laten sturen.
     *
     * @since 1.3.8.2
     * @param int $post_id Post ID.
     */
    public function scan_external_faqpage( $post_id ) {
        $post_id = (int) $post_id;
        $post    = get_post( $post_id );

        if ( ! $post || 'publish' !== $post->post_status ) {
            return;
        }

        $url = get_permalink( $post_id );
        if ( empty( $url ) ) {
            return;
        }

        // tamrank_skip_schema=1 keeps our own graph out of the response, so our
        // own FAQPage can never be mistaken for somebody else's.
        $url = add_query_arg( 'tamrank_skip_schema', '1', $url );

        $response = wp_remote_get( $url, array(
            'timeout'     => 10,
            // Self-fetch of this site's own permalink. Verify TLS in production; relax only on local WP_DEBUG dev.
            'sslverify'   => ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ),
            'redirection' => 5,
            'httpversion' => '1.1',
            'blocking'    => true,
            'cookies'     => array(),
        ) );

        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            // Leave the answer unknown rather than guess "no". The next pageview
            // queues a new look once the hourly cap has passed.
            return;
        }

        $html = wp_remote_retrieve_body( $response );
        if ( '' === $html ) {
            return;
        }

        tamrank_store_external_faqpage( $post_id, $this->content_has_external_faq_schema( $html ) );
    }

    /**
     * Draagt de ruwe post_content een FAQ-blok van Yoast of Rank Math?
     *
     * Allebei renderen hun FAQPage in de <head>, buiten `the_content` en buiten de
     * gerenderde content die build_extra_schemas ophaalt. De blok-marker in
     * post_content is daarmee het enige signaal dat er meteen is. Dezelfde twee
     * markers waar de detector op r597 op let, zodat detector en renderer over
     * hetzelfde blok praten.
     *
     * @since 1.3.9
     * @param string $post_content Ruwe post_content.
     * @return bool
     */
    private function content_has_foreign_faq_block( $post_content ) {
        $post_content = (string) $post_content;
        if ( '' === $post_content ) {
            return false;
        }

        return 1 === preg_match( '~<!--\s*wp:(yoast-seo|rank-math)/faq~i', $post_content );
    }

    /**
     * Draagt deze bron al een FAQPage-JSON-LD van het theme of een andere plugin?
     *
     * Avada's FAQ-element, Yoast's FAQ-blok, Rank Math en Elementor Pro schrijven
     * hun eigen FAQPage. Een tweede, afwijkende FAQPage van ons ernaast is voor
     * Google tegenstrijdige markup, dus dan zwijgen we. Onze eigen output kan hier
     * niet in zitten: de opgeslagen bron wordt met `tamrank_skip_schema=1` gehaald
     * en `the_content` draagt nooit schema.
     *
     * Dekt alleen wat IN de bron staat. Voor schema dat buiten `the_content` wordt
     * geprint is scan_external_faqpage() de tweede bron van hetzelfde antwoord.
     *
     * @since 1.3.9
     * @param string $content Bron waaruit de extra-schema's gebouwd worden.
     * @return bool
     */
    private function content_has_external_faq_schema( $content ) {
        $content = (string) $content;
        if ( stripos( $content, 'application/ld+json' ) === false || stripos( $content, 'faqpage' ) === false ) {
            return false;
        }

        if ( ! preg_match_all( '~<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>~is', $content, $blokken ) ) {
            return false;
        }

        foreach ( $blokken[1] as $blok ) {
            if ( stripos( $blok, 'faqpage' ) === false ) {
                continue;
            }
            $data = json_decode( trim( $blok ), true );
            if ( is_array( $data ) && $this->json_ld_has_type( $data, 'FAQPage' ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Zoek een @type ergens in een gedecodeerde JSON-LD-structuur
     *
     * @since 1.3.9
     * @param mixed  $node   Gedecodeerde JSON-LD (of een deel ervan).
     * @param string $type   Gezocht type.
     * @param int    $diepte Recursiediepte-teller.
     * @return bool
     */
    private function json_ld_has_type( $node, $type, $diepte = 0 ) {
        if ( ! is_array( $node ) || $diepte > 12 ) {
            return false;
        }

        if ( isset( $node['@type'] ) ) {
            foreach ( (array) $node['@type'] as $gevonden ) {
                if ( is_string( $gevonden ) && strcasecmp( $gevonden, $type ) === 0 ) {
                    return true;
                }
            }
        }

        foreach ( $node as $waarde ) {
            if ( is_array( $waarde ) && $this->json_ld_has_type( $waarde, $type, $diepte + 1 ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Waarom is de FAQPage in de laatste build overgeslagen?
     *
     * Lege string = niet overgeslagen. De Schema-tab gebruikt dit om uit te leggen
     * waarom de FAQ-chip ontbreekt.
     *
     * @since 1.3.9
     * @return string
     */
    public function get_faq_skip_reason() {
        return $this->faq_skip_reason;
    }

    /**
     * Draagt deze post al een FAQPage van het theme of een andere plugin?
     *
     * Publiek zodat de Schema-tab kan uitleggen waarom de FAQ-chip ontbreekt.
     * Gebruikt dezelfde bron als de renderer, dus geen extra HTTP-aanroep.
     *
     * @since 1.3.9
     * @param WP_Post $post Post object.
     * @return bool
     */
    public function page_has_external_faq_schema( $post ) {
        if ( ! $post instanceof WP_Post ) {
            return false;
        }

        if ( isset( self::$external_faq_cache[ $post->ID ] ) ) {
            return self::$external_faq_cache[ $post->ID ];
        }

        $bron   = $this->get_rendered_content( $post ) . "\n\n" . $post->post_content;
        $result = $this->content_has_external_faq_schema( $bron );

        self::$external_faq_cache[ $post->ID ] = $result;

        return $result;
    }

    /**
     * Houd per vraag alleen het eerste Question-item over.
     *
     * @param array $items Question-items zoals build_faq_schema ze verzamelt.
     * @return array Items zonder herhaalde vragen, oorspronkelijke volgorde.
     */
    private function dedupe_faq_items( $items ) {
        if ( count( $items ) < 2 ) {
            return $items;
        }

        $unique = array();
        $seen   = array();

        foreach ( $items as $item ) {
            $name = isset( $item['name'] ) ? (string) $item['name'] : '';
            $key  = $this->faq_question_key( $name );

            // Levert normaliseren niets op, vergelijk dan op de ruwe tekst. Nooit
            // op een lege sleutel ontdubbelen: dan zou één rare vraag alle andere
            // met dezelfde uitkomst wegvagen.
            if ( '' === $key ) {
                $key = $name;
            }

            if ( isset( $seen[ $key ] ) ) {
                continue;
            }

            $seen[ $key ] = true;
            $unique[]     = $item;
        }

        return $unique;
    }

    /**
     * Vergelijkingssleutel voor een vraag.
     *
     * Twee kopieën van dezelfde vraag zijn zelden teken voor teken gelijk: de
     * gerenderde helft en de ruwe post_content-helft schrijven aanhalingstekens
     * verschillend (`&#8220;` tegenover `"`). Vandaar entiteiten decoderen,
     * typografische aanhalings- en koppeltekens platslaan, witruimte normaliseren
     * en kleine letters. Alleen bedoeld om te vergelijken — wat er in het schema
     * belandt blijft de oorspronkelijke tekst.
     *
     * @param string $question Vraagtekst.
     * @return string Genormaliseerde sleutel.
     */
    private function faq_question_key( $question ) {
        $key = wp_strip_all_tags( (string) $question );
        $key = html_entity_decode( $key, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

        $key = str_replace(
            array( '“', '”', '„', '‟', '«', '»', '‘', '’', '‚', '‛', '–', '—' ),
            array( '"', '"', '"', '"', '"', '"', "'", "'", "'", "'", '-', '-' ),
            $key
        );

        // Een vraag met kapotte UTF-8 laat de /u-variant op null vallen. Terugvallen
        // op de byte-variant in plaats van op een lege sleutel, want een lege sleutel
        // zou het item helemaal uit het schema gooien.
        $collapsed = preg_replace( '/\s+/u', ' ', $key );
        if ( null === $collapsed ) {
            $collapsed = preg_replace( '/\s+/', ' ', $key );
        }

        $key = trim( (string) $collapsed );

        return function_exists( 'mb_strtolower' ) ? mb_strtolower( $key, 'UTF-8' ) : strtolower( $key );
    }

    /**
     * Bouw HowTo schema van content
     *
     * @param string  $content Post content
     * @param WP_Post $post    Post object
     * @return array|null HowTo schema of null
     */
    private function build_howto_schema( $content, $post ) {
        $steps = array();
        $debug_info = array(
            'content_length' => strlen( $content ),
            'content_preview' => substr( $content, 0, 500 ),
            'methode_gevonden' => null,
            'stappen_gevonden' => 0,
        );

        // Nederlandse en Engelse nummer woorden mapping
        $nummer_woorden = array(
            'een'     => 1,
            'één'     => 1,
            'one'     => 1,
            'first'   => 1,
            'eerste'  => 1,
            'twee'    => 2,
            'two'     => 2,
            'second'  => 2,
            'tweede'  => 2,
            'drie'    => 3,
            'three'   => 3,
            'third'   => 3,
            'derde'   => 3,
            'vier'    => 4,
            'four'    => 4,
            'fourth'  => 4,
            'vierde'  => 4,
            'vijf'    => 5,
            'five'    => 5,
            'fifth'   => 5,
            'vijfde'  => 5,
            'zes'     => 6,
            'six'     => 6,
            'sixth'   => 6,
            'zesde'   => 6,
            'zeven'   => 7,
            'seven'   => 7,
            'seventh' => 7,
            'zevende' => 7,
            'acht'    => 8,
            'eight'   => 8,
            'eighth'  => 8,
            'achtste' => 8,
            'negen'   => 9,
            'nine'    => 9,
            'ninth'   => 9,
            'negende' => 9,
            'tien'    => 10,
            'ten'     => 10,
            'tenth'   => 10,
            'tiende'  => 10,
        );

        // Decode HTML entities zodat "één" correct wordt herkend
        $content = html_entity_decode( $content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

        $woorden_pattern = implode( '|', array_keys( $nummer_woorden ) );

        // METHODE 1: Zoek in de GEHELE content naar stap-patronen (meest betrouwbaar)
        // Dit werkt ongeacht of het Divi, Elementor, of standaard content is
        $plain_content = wp_strip_all_tags( $content );
        $plain_content = html_entity_decode( $plain_content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $plain_content = preg_replace( '/\s+/', ' ', $plain_content );

        $debug_info['plain_content_preview'] = substr( $plain_content, 0, 1000 );

        // Zoek naar "Stap één:", "Stap twee:" etc. met nummer woorden
        // Flexibel patroon: matcht "Stap/Step + nummer" overal, ongeacht wat ervoor staat
        if ( preg_match_all( '/(?:stap|step)\s+(' . $woorden_pattern . ')[\s:]+([^.!?]+)/iu', $plain_content, $matches, PREG_SET_ORDER ) ) {
            $debug_info['methode_gevonden'] = 'METHODE 1a - woorden in plain text';
            $debug_info['regex_matches'] = $matches;
            $gevonden_posities = array(); // Track welke posities al gevonden zijn
            foreach ( $matches as $match ) {
                $nummer_woord = mb_strtolower( trim( $match[1] ) );
                $position = isset( $nummer_woorden[ $nummer_woord ] ) ? $nummer_woorden[ $nummer_woord ] : 0;
                $step_text = trim( $match[2] );
                // Verwijder trailing punt als die er is
                $step_text = rtrim( $step_text, '.' );

                // Skip duplicaat posities (content kan dubbel voorkomen)
                if ( in_array( $position, $gevonden_posities, true ) ) {
                    continue;
                }

                if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                    $gevonden_posities[] = $position;
                    $steps[] = array(
                        '@type'    => 'HowToStep',
                        'position' => $position,
                        'text'     => $step_text,
                    );
                }
            }
        }

        // Zoek naar "Stap 1:", "Stap 2:" etc. met cijfers
        if ( empty( $steps ) && preg_match_all( '/(?:stap|step)\s*(\d+)[\s:]+([^.!?]+)/iu', $plain_content, $matches, PREG_SET_ORDER ) ) {
            $debug_info['methode_gevonden'] = 'METHODE 1b - cijfers in plain text';
            $debug_info['regex_matches'] = $matches;
            foreach ( $matches as $match ) {
                $position = intval( $match[1] );
                $step_text = trim( $match[2] );
                $step_text = rtrim( $step_text, '.' );

                if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                    $steps[] = array(
                        '@type'    => 'HowToStep',
                        'position' => $position,
                        'text'     => $step_text,
                    );
                }
            }
        }

        // METHODE 2: Divi SHORTCODE patronen als fallback
        if ( empty( $steps ) && preg_match_all( '/\[et_pb_text[^\]]*\](.*?)\[\/et_pb_text\]/is', $content, $shortcode_matches ) ) {
            foreach ( $shortcode_matches[1] as $text_content ) {
                $text_content = html_entity_decode( $text_content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
                $clean_text = wp_strip_all_tags( $text_content );

                // Zoek "Stap één:", "Stap twee:" etc. met nummer woorden
                if ( preg_match( '/(?:stap|step)\s+(' . $woorden_pattern . ')[:\.\s]+(.+)/iu', $clean_text, $match ) ) {
                    $nummer_woord = mb_strtolower( trim( $match[1] ) );
                    $position = isset( $nummer_woorden[ $nummer_woord ] ) ? $nummer_woorden[ $nummer_woord ] : 0;
                    $step_text = trim( $match[2] );

                    if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
                // Zoek "Stap 1:", "Stap 2:" etc. met cijfers
                elseif ( preg_match( '/(?:stap|step)\s*(\d+)[:\.\s]+(.+)/iu', $clean_text, $match ) ) {
                    $position = intval( $match[1] );
                    $step_text = trim( $match[2] );

                    if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
            }
        }

        // METHODE 3: Divi gerenderde HTML - et_pb_text_inner divs
        if ( empty( $steps ) && preg_match_all( '/<div[^>]*class="[^"]*et_pb_text_inner[^"]*"[^>]*>(.*?)<\/div>/is', $content, $divi_matches ) ) {

            foreach ( $divi_matches[1] as $text_content ) {
                $text_content = html_entity_decode( $text_content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
                $clean_text = wp_strip_all_tags( $text_content );

                // Zoek "Stap één:", "Stap twee:" etc. met nummer woorden
                if ( preg_match( '/(?:stap|step)\s+(' . $woorden_pattern . ')[:\.\s]+(.+)/iu', $clean_text, $match ) ) {
                    $nummer_woord = mb_strtolower( trim( $match[1] ) );
                    $position = isset( $nummer_woorden[ $nummer_woord ] ) ? $nummer_woorden[ $nummer_woord ] : 0;
                    $step_text = trim( $match[2] );

                    if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
                // Zoek "Stap 1:", "Stap 2:" etc. met cijfers
                elseif ( preg_match( '/(?:stap|step)\s*(\d+)[:\.\s]+(.+)/iu', $clean_text, $match ) ) {
                    $position = intval( $match[1] );
                    $step_text = trim( $match[2] );

                    if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
            }
        }

        // Zoek naar genummerde stappen in ordered lists
        if ( empty( $steps ) && preg_match_all( '/<ol[^>]*>(.*?)<\/ol>/is', $content, $ol_matches ) ) {
            foreach ( $ol_matches[1] as $ol_content ) {
                if ( preg_match_all( '/<li[^>]*>(.*?)<\/li>/is', $ol_content, $li_matches ) ) {
                    foreach ( $li_matches[1] as $index => $li_content ) {
                        $step_text = wp_strip_all_tags( $li_content );
                        $step_text = trim( $step_text );

                        if ( ! empty( $step_text ) ) {
                            $steps[] = array(
                                '@type'    => 'HowToStep',
                                'position' => $index + 1,
                                'text'     => $step_text,
                            );
                        }
                    }
                }
            }
        }

        // Strip alle HTML tags en zoek in platte tekst
        if ( empty( $steps ) ) {
            $plain_text = wp_strip_all_tags( $content );
            $plain_text = preg_replace( '/\s+/', ' ', $plain_text ); // Normaliseer whitespace

            // Zoek naar "Stap X:" patronen met cijfers
            if ( preg_match_all( '/(?:stap|step)\s*(\d+)[:\.\s]+([^\.]+\.?)/iu', $plain_text, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $position  = intval( $match[1] );
                    $step_text = trim( $match[2] );

                    if ( ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
            }
        }

        // Zoek naar "Stap één:", "Stap twee:" etc. patronen met woorden in platte tekst
        if ( empty( $steps ) ) {
            $plain_text = wp_strip_all_tags( $content );
            $plain_text = preg_replace( '/\s+/', ' ', $plain_text );
            $woorden_pattern = implode( '|', array_keys( $nummer_woorden ) );

            if ( preg_match_all( '/(?:stap|step)\s+(' . $woorden_pattern . ')[:\.\s]+([^\.]+\.?)/iu', $plain_text, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $nummer_woord = mb_strtolower( trim( $match[1] ) );
                    $position = isset( $nummer_woorden[ $nummer_woord ] ) ? $nummer_woorden[ $nummer_woord ] : 0;
                    $step_text = trim( $match[2] );

                    if ( $position > 0 && ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
            }
        }

        // Zoek naar "1.", "2.", "3." patronen aan begin van regels of na <br>
        if ( empty( $steps ) ) {
            if ( preg_match_all( '/(?:^|<br\s*\/?>|<p[^>]*>)\s*(\d+)[\.\)]\s*([^<\n]+)/im', $content, $matches, PREG_SET_ORDER ) ) {
                foreach ( $matches as $match ) {
                    $position  = intval( $match[1] );
                    $step_text = wp_strip_all_tags( trim( $match[2] ) );

                    if ( ! empty( $step_text ) && strlen( $step_text ) > 3 ) {
                        $steps[] = array(
                            '@type'    => 'HowToStep',
                            'position' => $position,
                            'text'     => $step_text,
                        );
                    }
                }
            }
        }

        // Verwijder duplicaten op basis van position
        $unique_steps = array();
        $seen_positions = array();
        foreach ( $steps as $step ) {
            if ( ! in_array( $step['position'], $seen_positions, true ) ) {
                $unique_steps[] = $step;
                $seen_positions[] = $step['position'];
            }
        }
        $steps = $unique_steps;

        // Sorteer steps op position
        if ( ! empty( $steps ) ) {
            usort( $steps, function( $a, $b ) {
                return $a['position'] - $b['position'];
            } );
        }

        // Debug output
        $debug_info['stappen_gevonden'] = count( $steps );
        if ( empty( $debug_info['methode_gevonden'] ) ) {
            $debug_info['methode_gevonden'] = 'GEEN METHODE GEVONDEN';
        }
        $this->output_debug( 'build_howto_schema', $debug_info );

        if ( empty( $steps ) ) {
            return null;
        }

        $howto = array(
            '@type' => 'HowTo',
            '@id'   => get_permalink( $post->ID ) . '#howto',
            'name'  => get_the_title( $post ),
            'step'  => $steps,
        );

        // Voeg beschrijving toe indien beschikbaar
        // Gebruik TamRank meta description als die beschikbaar is, anders excerpt
        $description = get_post_meta( $post->ID, '_tam_rank_meta_description', true );
        if ( empty( $description ) ) {
            $description = get_the_excerpt( $post );
        }
        if ( ! empty( $description ) ) {
            // Strip ALLE shortcodes met regex (inclusief Divi [et_pb_*] die niet geregistreerd zijn in admin)
            // strip_shortcodes() werkt alleen voor geregistreerde shortcodes
            $clean_description = preg_replace( '/\[[^\]]*\]/', '', $description );
            // Strip eventuele overgebleven HTML tags
            $clean_description = wp_strip_all_tags( $clean_description );
            // Normaliseer whitespace
            $clean_description = preg_replace( '/\s+/', ' ', $clean_description );
            $clean_description = trim( $clean_description );

            if ( ! empty( $clean_description ) ) {
                $howto['description'] = $clean_description;
            }
        }

        // Voeg afbeelding toe indien beschikbaar
        $thumbnail_id = get_post_thumbnail_id( $post->ID );
        if ( $thumbnail_id ) {
            $image_url      = wp_get_attachment_image_url( $thumbnail_id, 'full' );
            $howto['image'] = $image_url;
        }

        return $howto;
    }

    /**
     * Bouw VideoObject schema van content
     *
     * @param string  $content Post content
     * @param WP_Post $post    Post object
     * @return array|null VideoObject schema of null
     */
    private function build_video_schema( $content, $post ) {
        $video_url   = null;
        $embed_url   = null;
        $thumbnail   = null;
        $video_name  = get_the_title( $post );

        // Zoek naar YouTube embeds
        if ( preg_match( '/(?:youtube\.com\/(?:embed\/|watch\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/', $content, $youtube_match ) ) {
            $video_id  = $youtube_match[1];
            $video_url = 'https://www.youtube.com/watch?v=' . $video_id;
            $embed_url = 'https://www.youtube.com/embed/' . $video_id;
            $thumbnail = 'https://img.youtube.com/vi/' . $video_id . '/maxresdefault.jpg';
        }

        // Zoek naar Vimeo embeds
        if ( ! $video_url && preg_match( '/vimeo\.com\/(?:video\/)?(\d+)/', $content, $vimeo_match ) ) {
            $video_id  = $vimeo_match[1];
            $video_url = 'https://vimeo.com/' . $video_id;
            $embed_url = 'https://player.vimeo.com/video/' . $video_id;
            // Vimeo thumbnail vereist API call, gebruik featured image als fallback
        }

        // Zoek naar HTML5 video tags
        if ( ! $video_url && preg_match( '/<video[^>]*>.*?<source[^>]+src=["\']([^"\']+)["\'][^>]*>.*?<\/video>/is', $content, $video_match ) ) {
            $video_url = $video_match[1];
        }

        if ( ! $video_url ) {
            return null;
        }

        $video = array(
            '@type'       => 'VideoObject',
            '@id'         => get_permalink( $post->ID ) . '#video',
            'name'        => $video_name,
            'contentUrl'  => $video_url,
            'uploadDate'  => get_the_date( 'c', $post ),
        );

        if ( $embed_url ) {
            $video['embedUrl'] = $embed_url;
        }

        // Thumbnail: gebruik gevonden thumbnail of featured image
        if ( $thumbnail ) {
            $video['thumbnailUrl'] = $thumbnail;
        } else {
            $thumbnail_id = get_post_thumbnail_id( $post->ID );
            if ( $thumbnail_id ) {
                $video['thumbnailUrl'] = wp_get_attachment_image_url( $thumbnail_id, 'full' );
            }
        }

        // Beschrijving - strip shortcodes en HTML tags voor een schone description
        $description = get_post_meta( $post->ID, '_tam_rank_meta_description', true );
        if ( empty( $description ) ) {
            $description = get_the_excerpt( $post );
            // Strip ALLE shortcodes met regex (inclusief Divi [et_pb_*] die niet geregistreerd zijn in admin)
            $description = preg_replace( '/\[[^\]]*\]/', '', $description );
            $description = wp_strip_all_tags( $description );
            $description = preg_replace( '/\s+/', ' ', $description );
            $description = trim( $description );
        }
        if ( empty( $description ) ) {
            // Verwijder ALLE shortcodes met regex (inclusief Divi)
            $clean_content = preg_replace( '/\[[^\]]*\]/', '', $content );
            $clean_content = wp_strip_all_tags( $clean_content );
            $clean_content = preg_replace( '/\s+/', ' ', $clean_content );
            $description = wp_trim_words( trim( $clean_content ), 30, '...' );
        }
        if ( ! empty( $description ) ) {
            $video['description'] = $description;
        }

        return $video;
    }

    /**
     * Bouw meerdere ImageObject schemas van content
     *
     * @param string  $content Post content (kan gerenderde HTML zijn)
     * @param WP_Post $post    Post object
     * @return array Array van ImageObject schemas (kan leeg zijn)
     */
    private function build_image_schemas( $content, $post ) {
        $image_schemas = array();
        $found_urls = array(); // Track gevonden URLs om duplicaten te voorkomen
        $debug_info = array(
            'content_length' => strlen( $content ),
            'images_found' => 0,
        );

        // Helper functie om een ImageObject te bouwen
        $build_single_image = function( $url, $alt = '', $width = null, $height = null, $index = 0 ) use ( $post, &$found_urls ) {
            // Skip duplicaten
            if ( in_array( $url, $found_urls, true ) ) {
                return null;
            }
            $found_urls[] = $url;

            // Skip data URLs, icons, kleine afbeeldingen, en externe services
            if ( strpos( $url, 'data:' ) === 0 ) {
                return null;
            }
            if ( preg_match( '/icon|logo|favicon|sprite|placeholder/i', $url ) ) {
                return null;
            }
            // Skip Gravatar en andere avatar/externe services
            if ( preg_match( '/gravatar\.com|avatar|wp-admin|wp-includes/i', $url ) ) {
                return null;
            }

            // Haal attachment metadata op
            $attachment_id = attachment_url_to_postid( $url );
            $bijschrift = '';   // wp_get_attachment_caption()
            $beschrijving = ''; // attachment post_content
            $titel = '';        // attachment title

            if ( $attachment_id ) {
                // Haal width/height als niet beschikbaar
                if ( ! $width || ! $height ) {
                    $image_meta = wp_get_attachment_metadata( $attachment_id );
                    if ( $image_meta ) {
                        $width  = $width ?: ( $image_meta['width'] ?? null );
                        $height = $height ?: ( $image_meta['height'] ?? null );
                    }
                }

                // Haal bijschrift (caption) van attachment
                $bijschrift = wp_get_attachment_caption( $attachment_id );

                // Haal beschrijving (post_content van attachment)
                $attachment_post = get_post( $attachment_id );
                if ( $attachment_post ) {
                    $beschrijving = $attachment_post->post_content;
                    $titel = $attachment_post->post_title;
                }

                // Haal alt-tekst als niet meegegeven
                if ( empty( $alt ) ) {
                    $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
                }
            }

            // Meta beschrijving als laatste fallback
            $meta_beschrijving = get_post_meta( $post->ID, '_tam_rank_meta_description', true );
            if ( empty( $meta_beschrijving ) ) {
                $meta_beschrijving = get_the_excerpt( $post );
                $meta_beschrijving = preg_replace( '/\[[^\]]*\]/', '', $meta_beschrijving );
                $meta_beschrijving = wp_strip_all_tags( $meta_beschrijving );
                $meta_beschrijving = preg_replace( '/\s+/', ' ', $meta_beschrijving );
                $meta_beschrijving = trim( $meta_beschrijving );
            }

            $image = array(
                '@type'      => 'ImageObject',
                '@id'        => get_permalink( $post->ID ) . '#image-' . ( $index + 1 ),
                'contentUrl' => $url,
            );

            if ( $width ) {
                $image['width'] = intval( $width );
            }
            if ( $height ) {
                $image['height'] = intval( $height );
            }

            // Caption fallback: bijschrift → alt tekst → titel → meta beschrijving
            $caption = '';
            if ( ! empty( $bijschrift ) ) {
                $caption = $bijschrift;
            } elseif ( ! empty( $alt ) ) {
                $caption = $alt;
            } elseif ( ! empty( $titel ) ) {
                $caption = $titel;
            } elseif ( ! empty( $meta_beschrijving ) ) {
                $caption = $meta_beschrijving;
            }

            if ( ! empty( $caption ) ) {
                $image['caption'] = $caption;
            }

            // Description fallback: beschrijving → alt tekst → bijschrift → meta beschrijving
            $description = '';
            if ( ! empty( $beschrijving ) ) {
                $description = $beschrijving;
            } elseif ( ! empty( $alt ) ) {
                $description = $alt;
            } elseif ( ! empty( $bijschrift ) ) {
                $description = $bijschrift;
            } elseif ( ! empty( $meta_beschrijving ) ) {
                $description = $meta_beschrijving;
            }

            if ( ! empty( $description ) ) {
                $image['description'] = $description;
            }

            return $image;
        };

        // METHODE 1: Zoek naar img tags in HTML (gerenderde content)
        if ( preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $img_matches, PREG_SET_ORDER ) ) {
            foreach ( $img_matches as $index => $match ) {
                $url = $match[1];
                $alt = '';
                $width = null;
                $height = null;

                // Haal alt tekst
                if ( preg_match( '/alt=["\']([^"\']*)["\']/', $match[0], $a ) ) {
                    $alt = $a[1];
                }
                // Haal width/height
                if ( preg_match( '/width=["\']?(\d+)["\']?/i', $match[0], $w ) ) {
                    $width = $w[1];
                }
                if ( preg_match( '/height=["\']?(\d+)["\']?/i', $match[0], $h ) ) {
                    $height = $h[1];
                }

                $schema = $build_single_image( $url, $alt, $width, $height, count( $image_schemas ) );
                if ( $schema ) {
                    $image_schemas[] = $schema;
                }
            }
        }

        // METHODE 2: Zoek naar Divi shortcode afbeeldingen
        if ( preg_match_all( '/\[et_pb_image[^\]]*src=["\']([^"\']+)["\'][^\]]*\]/is', $content, $shortcode_matches, PREG_SET_ORDER ) ) {
            foreach ( $shortcode_matches as $match ) {
                $url = $match[1];
                $alt = '';

                // Haal alt tekst uit shortcode
                if ( preg_match( '/alt=["\']([^"\']*)["\']/', $match[0], $a ) ) {
                    $alt = $a[1];
                } elseif ( preg_match( '/title_text=["\']([^"\']*)["\']/', $match[0], $t ) ) {
                    $alt = $t[1];
                }

                $schema = $build_single_image( $url, $alt, null, null, count( $image_schemas ) );
                if ( $schema ) {
                    $image_schemas[] = $schema;
                }
            }
        }

        // METHODE 3: Fallback naar featured image als geen afbeeldingen gevonden
        if ( empty( $image_schemas ) ) {
            $thumbnail_id = get_post_thumbnail_id( $post->ID );
            if ( $thumbnail_id ) {
                $url = wp_get_attachment_image_url( $thumbnail_id, 'full' );
                $image_meta = wp_get_attachment_metadata( $thumbnail_id );
                $width = $image_meta['width'] ?? null;
                $height = $image_meta['height'] ?? null;
                $alt = wp_get_attachment_caption( $thumbnail_id );

                $schema = $build_single_image( $url, $alt, $width, $height, 0 );
                if ( $schema ) {
                    $image_schemas[] = $schema;
                }
            }
        }

        // Debug output
        $debug_info['images_found'] = count( $image_schemas );
        $debug_info['image_urls'] = $found_urls;
        $this->output_debug( 'build_image_schemas', $debug_info );

        return $image_schemas;
    }

    /**
     * Bouw BreadcrumbList schema voor een post
     *
     * De trail zelf wordt NIET hier berekend. Sinds 1.3.7 staat die logica in
     * includes/class-tamrank-breadcrumbs.php en leest de zichtbare breadcrumb
     * ([tamrank_breadcrumb] / tamrank_the_breadcrumbs()) exact dezelfde functie.
     * Deze methode doet nog maar één ding: de gedeelde {label, url}-paren
     * omzetten naar ListItem-objecten.
     *
     * Bouw hier nooit opnieuw een eigen trail. Zodra de zichtbare breadcrumb en
     * dit schema los van elkaar worden berekend, lopen ze uit elkaar en vertelt
     * de BreadcrumbList iets anders aan Google dan wat de bezoeker ziet.
     *
     * @param WP_Post $post Post object
     * @return array|null BreadcrumbList schema of null
     */
    private function build_breadcrumb_schema( $post ) {
        if ( ! $post ) {
            return null;
        }

        // Gedeelde bron; leeg op de homepage (die is de root, dus een trail
        // heeft daar geen betekenis en zou een dubbele "Home"-entry opleveren).
        $trail = function_exists( 'tamrank_get_breadcrumb_trail' )
            ? tamrank_get_breadcrumb_trail( $post )
            : array();

        if ( empty( $trail ) ) {
            return null;
        }

        $breadcrumb_items = array();
        $position         = 1;

        foreach ( $trail as $entry ) {
            $item = array(
                '@type'    => 'ListItem',
                'position' => $position++,
                // Het label is HTML: het komt uit get_the_title() / $term->name en
                // die zijn entiteit-gecodeerd. In de zichtbare breadcrumb klopt dat
                // (esc_html() dubbelcodeert niet, dus de bezoeker leest '&'), maar
                // JSON-LD is geen HTML. Ongedecodeerd las Google hier letterlijk
                // '&#038;' en '&#8217;' in plaats van '&' en een krulaanhalingsteken.
                //
                // Dit is de enige plek die decodeert: de gedeelde trail blijft
                // HTML-tekst voor de HTML-consument, deze consument maakt er platte
                // tekst van. Openbreken van het <script>-blok blijft onmogelijk -
                // dat wordt niet hier maar door JSON_HEX_TAG|JSON_HEX_AMP bij het
                // encoderen afgedekt (regel 1486), dus '<' en '>' gaan als \u003C
                // en \u003E naar buiten.
                'name'     => html_entity_decode( $entry['label'], ENT_QUOTES, get_bloginfo( 'charset' ) ),
            );

            // De laatste crumb is de huidige pagina en krijgt geen 'item'-link,
            // conform Google's richtlijn.
            if ( $entry['url'] !== '' ) {
                $item['item'] = $entry['url'];
            }

            $breadcrumb_items[] = $item;
        }

        // Bouw het BreadcrumbList schema
        $schema = array(
            '@type'           => 'BreadcrumbList',
            '@id'             => get_permalink( $post->ID ) . '#breadcrumb',
            'itemListElement' => $breadcrumb_items,
        );

        // Debug output
        $this->output_debug( 'build_breadcrumb_schema', array(
            'post_id'    => $post->ID,
            'post_type'  => $post->post_type,
            'items'      => count( $breadcrumb_items ),
        ) );

        return $schema;
    }
}
