<?php
/**
 * Tam Rank™ Content Analyzer
 *
 * Analyseert pagina-inhoud op basis van vaste regels en wegingen.
 * Berekent een totaalscore en geeft verbeterpunten terug.
 *
 * @package TamRank
 * @subpackage Content
 * @since 6.2.2
 */

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

if (!class_exists('TamRank_Meta_Calculator')) {
    require_once dirname(dirname(__DIR__)) . '/includes/class-tamrank-meta-calculator.php';
}

class TamRank_Content_Analyzer {

    /**
     * Analyse regels met wegingen
     *
     * @var array
     */
    private $rules;

    /**
     * Focus zoekwoord
     *
     * @var string
     */
    private $focus_keyword = '';

    /**
     * Mag deze analyse de vijf-minutenrem op de paginafetch overslaan?
     *
     * Standaard nee. De Content-tab heranalyseert 1,5 s na elke typepauze, en
     * die reeks mag nooit een reeks paginarenders worden. Alleen een expliciete
     * heranalyse zet dit aan; zie set_force_page_fetch().
     *
     * @since 1.3.8.2
     * @var bool
     */
    private $force_page_fetch = false;

    /**
     * Geëxtraheerde content data
     *
     * @var array
     */
    private $content_data = array();

    /**
     * Constructor - definieer alle regels
     */
    public function __construct() {
        $this->define_rules();
    }

    /**
     * Definieer alle analyse regels met wegingen
     */
    private function define_rules() {
        $this->rules = array(
            // A. Koppen en structuur (25%)
            'headings' => array(
                'title' => __( 'Headings & Structure', 'tam-rank' ),
                'icon' => 'heading',
                'weight' => 25,
                'checks' => array(
                    'h1_exists' => array(
                        'label' => __( 'Exactly one H1 present', 'tam-rank' ),
                        'weight' => 6,
                        'critical' => true,
                    ),
                    'h1_has_keyword' => array(
                        'label' => __( 'H1 contains the keyword', 'tam-rank' ),
                        'weight' => 5,
                        'critical' => false,
                    ),
                    'h1_length' => array(
                        'label' => __( 'H1 is not longer than 70 characters', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                    'h2_exists' => array(
                        'label' => __( 'At least one H2 present', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                    'heading_hierarchy' => array(
                        'label' => __( 'Heading structure is logical', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'no_duplicate_headings' => array(
                        'label' => __( 'No headings with the same text', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'headings_have_content' => array(
                        'label' => __( 'All headings have content below them', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                    'no_single_word_headings' => array(
                        'label' => __( 'No single-word headings', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                ),
            ),

            // B. Zoekwoordgebruik (25%)
            'keywords' => array(
                'title' => __( 'Keyword usage', 'tam-rank' ),
                'icon' => 'keyword',
                'weight' => 25,
                'checks' => array(
                    'keyword_in_intro' => array(
                        'label' => __( 'Keyword in first 150 words', 'tam-rank' ),
                        'weight' => 6,
                        'critical' => true,
                    ),
                    'keyword_frequency' => array(
                        'label' => __( 'Keyword appears at least 2 times', 'tam-rank' ),
                        'weight' => 4,
                        'critical' => false,
                    ),
                    'keyword_density' => array(
                        'label' => __( 'Keyword density is optimal (1-2%)', 'tam-rank' ),
                        'weight' => 5,
                        'critical' => false,
                    ),
                    'keyword_in_h2' => array(
                        'label' => __( 'Keyword in at least one H2', 'tam-rank' ),
                        'weight' => 4,
                        'critical' => false,
                    ),
                    'keyword_variants' => array(
                        'label' => __( 'Keyword variants are used', 'tam-rank' ),
                        'weight' => 6,
                        'critical' => false,
                    ),
                ),
            ),

            // C. Intro en leesbaarheid (15%)
            'readability' => array(
                'title' => __( 'Readability', 'tam-rank' ),
                'icon' => 'readability',
                'weight' => 15,
                'checks' => array(
                    'intro_is_summary' => array(
                        'label' => __( 'Intro contains a clear summary', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'intro_length' => array(
                        'label' => __( 'Intro is at least 50 words', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                    'no_generic_intro' => array(
                        'label' => __( 'No generic introduction', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => false,
                    ),
                    'paragraph_length' => array(
                        'label' => __( 'Paragraphs are not too long (max 150 words)', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'sentence_length' => array(
                        'label' => __( 'Average sentence length is acceptable', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'no_duplicate_content' => array(
                        'label' => __( 'No duplicate paragraphs or sentences', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => false,
                    ),
                    'no_text_walls' => array(
                        'label' => __( 'No extremely long text blocks', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => false,
                    ),
                    'no_caps_headings' => array(
                        'label' => __( 'No headings in all caps', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => false,
                    ),
                ),
            ),

            // D. Afbeeldingen (10%)
            'images' => array(
                'title' => __( 'Images', 'tam-rank' ),
                'icon' => 'image',
                'weight' => 10,
                'checks' => array(
                    'has_images' => array(
                        'label' => __( 'At least one image present', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'all_have_alt' => array(
                        'label' => __( 'All images have alt text', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'alt_has_keyword' => array(
                        'label' => __( 'At least one alt contains the keyword', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                    'no_filename_alts' => array(
                        'label' => __( 'No filename as alt text', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => false,
                    ),
                    'images_optimized_size' => array(
                        'label' => __( 'Images are optimized (<300KB)', 'tam-rank' ),
                        'weight' => 4,
                        'critical' => false,
                    ),
                    'images_have_webp' => array(
                        'label' => __( 'WebP versions available', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                    'images_not_oversized' => array(
                        'label' => __( 'Images not larger than needed', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                    'images_have_lazy_loading' => array(
                        'label' => __( 'Lazy loading enabled', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => false,
                    ),
                ),
            ),

            // E. Links (20%)
            'links' => array(
                'title' => __( 'Links', 'tam-rank' ),
                'icon' => 'link',
                'weight' => 20,
                'checks' => array(
                    'has_internal_links' => array(
                        'label' => __( 'At least one internal link', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => true,
                    ),
                    'no_broken_links' => array(
                        'label' => __( 'No broken links (404)', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => true,
                    ),
                    'no_spam_links' => array(
                        'label' => __( 'No spam-like external links', 'tam-rank' ),
                        'weight' => 1,
                        'critical' => false,
                    ),
                ),
            ),

            // F. Contentlengte (5%)
            'length' => array(
                'title' => __( 'Content length', 'tam-rank' ),
                'icon' => 'length',
                'weight' => 5,
                'checks' => array(
                    'min_word_count' => array(
                        'label' => __( 'At least 300 words', 'tam-rank' ),
                        'weight' => 2,
                        'critical' => true,
                    ),
                    'ideal_length' => array(
                        'label' => __( 'Meets ideal length for page type', 'tam-rank' ),
                        'weight' => 3,
                        'critical' => false,
                    ),
                ),
            ),

        );
    }

    /**
     * Voer volledige analyse uit
     *
     * @param string $content HTML content
     * @param string $focus_keyword Focus zoekwoord
     * @param int $post_id Post ID
     * @return array Analyse resultaten
     */
    public function analyze($content, $focus_keyword = '', $post_id = 0) {
        // PHP 8.1+ null-safe checks
        $focus_keyword = $focus_keyword ?? '';
        $content = $content ?? '';

        $this->focus_keyword = strtolower(trim((string) $focus_keyword));

        // Extraheer data uit content
        $this->extract_content_data((string) $content, $post_id);

        // Voer alle checks uit
        $results = $this->run_all_checks();

        // Bereken totaalscore
        $score = $this->calculate_score($results);

        // Verzamel top issues
        $top_issues = $this->get_top_issues($results);

        // Bouw categorieën op voor UI
        $categories = $this->build_categories($results);

        // Tel statistieken
        $stats = $this->calculate_stats($results);

        // Bereken secondary keywords bonus
        $secondary_bonus = $this->calculate_secondary_keywords_bonus($post_id, $content);

        return array(
            'score' => $score,
            'top_issues' => $top_issues,
            'categories' => $categories,
            'details' => array(
                'headings' => $this->content_data['headings'],
                'intro_snippet' => $this->content_data['intro_snippet'],
                'images' => $this->content_data['images'],
                'internal_links' => $this->content_data['internal_links'],
                'external_links' => $this->content_data['external_links'],
                'word_count' => $this->content_data['word_count'],
            ),
            'passed_count' => $stats['passed'],
            'warnings_count' => $stats['warnings'],
            'errors_count' => $stats['errors'],
            'total_checks' => $stats['total'],
            'secondary_keywords_bonus' => $secondary_bonus,
        );
    }

    /**
     * Extraheer alle data uit de content
     *
     * @param string $content HTML content
     * @param int $post_id Post ID
     */
    private function extract_content_data($content, $post_id) {
        // Stap 0: Probeer content uit custom theme containers te extraheren
        // Dit helpt bij themes die content in specifieke HTML structuren plaatsen
        $content = $this->extract_from_custom_theme_containers($content, $post_id);

        // Stap 1: Extraheer afbeeldingen uit page builder shortcodes VOORDAT we ze renderen
        // Dit is een fallback voor het geval do_shortcode() niet werkt (bijv. AJAX context)
        $page_builder_images = $this->extract_page_builder_images($content);

        // Stap 2: Render shortcodes naar HTML en verwijder builder markup
        // do_shortcode() converteert [et_pb_image] naar echte <img> tags
        $content = $this->strip_page_builder_content($content);

        // Haal headings op
        $this->content_data['headings'] = $this->extract_headings($content);

        // Stap 3: Extraheer afbeeldingen uit de gerenderde HTML (<img> tags)
        // Dit pakt nu ook de afbeeldingen die door do_shortcode() zijn gerenderd
        $html_images = $this->extract_images($content);

        // Stap 4: Combineer beide bronnen (page builder shortcodes + HTML img tags)
        // en verwijder duplicaten
        $this->content_data['images'] = $this->merge_images($page_builder_images, $html_images);

        // Stap 5: Voeg WordPress featured image toe als die nog niet gevonden is
        // Custom themes tonen featured images via the_post_thumbnail() in templates
        if ($post_id && has_post_thumbnail($post_id)) {
            $thumb_id = get_post_thumbnail_id($post_id);
            $thumb_url = wp_get_attachment_url($thumb_id);
            if ($thumb_url) {
                $already_found = false;
                foreach ($this->content_data['images'] as $img) {
                    if (isset($img['src']) && $img['src'] === $thumb_url) {
                        $already_found = true;
                        break;
                    }
                    if (isset($img['attachment_id']) && $img['attachment_id'] == $thumb_id) {
                        $already_found = true;
                        break;
                    }
                }
                if (!$already_found) {
                    $thumb_alt = get_post_meta($thumb_id, '_wp_attachment_image_alt', true);
                    $this->content_data['images'][] = array(
                        'src' => $thumb_url,
                        'alt' => $thumb_alt ?: '',
                        // Elke andere producent van een image-entry zet deze
                        // sleutel (zie extract_images() en de page-builder-takken);
                        // deze tak deed dat niet, waardoor check_no_filename_alts()
                        // op een Undefined array key liep en de uitgelichte
                        // afbeelding bovendien nooit op een bestandsnaam-alt werd
                        // gecontroleerd. Deze tak wordt alleen bereikt als de
                        // gerenderde pagina de uitgelichte afbeelding niet al
                        // bevat, en dat is in de praktijk elk niet-gepubliceerd
                        // bericht: fetch_full_page_html() stopt daar meteen op
                        // post_status !== 'publish'.
                        'is_filename_alt' => $this->is_filename_alt($thumb_alt ?: ''),
                        'attachment_id' => $thumb_id,
                        'has_lazy_loading' => false,
                        'render_width' => 0,
                        'render_height' => 0,
                    );
                }
            }
        }

        // Haal links op
        $links = $this->extract_links($content);
        $this->content_data['internal_links'] = $links['internal'];
        $this->content_data['external_links'] = $links['external'];

        // Strip HTML voor tekstanalyse
        $text_content = wp_strip_all_tags($content);
        $text_content = preg_replace('/\s+/', ' ', $text_content);
        $text_content = trim($text_content);

        // Woorden
        $words = preg_split('/\s+/', $text_content);
        $words = array_filter($words);
        $this->content_data['word_count'] = count($words);
        $this->content_data['words'] = $words;
        $this->content_data['text_content'] = $text_content;

        // Intro (eerste 150 woorden)
        $intro_words = array_slice($words, 0, 150);
        $this->content_data['intro_snippet'] = implode(' ', $intro_words);
        $this->content_data['intro_text'] = strtolower(implode(' ', $intro_words));

        // Alinea's
        $this->content_data['paragraphs'] = $this->extract_paragraphs($content);

        // Zinnen
        $this->content_data['sentences'] = $this->extract_sentences($text_content);
    }

    /**
     * Extraheer content uit custom theme HTML containers
     *
     * Custom themes plaatsen vaak de main content in specifieke containers.
     * Deze methode probeert de content te extraheren uit veelvoorkomende patronen.
     *
     * Ondersteunt:
     * - article.docs-main .docs-content (TamRank theme)
     * - article .entry-content (standaard WordPress themes)
     * - .post-content, .article-content, .blog-content
     * - main .content, #content .post
     * - En vele andere veelgebruikte patronen
     *
     * @param string $content HTML content
     * @param int $post_id Post ID
     * @return string Geëxtraheerde of originele content
     */
    private function extract_from_custom_theme_containers($content, $post_id) {
        // PHP 8.1+ null-safe check
        if ($content === null) {
            $content = '';
        }
        $content = (string) $content;

        // Als content al voldoende tekst bevat (bijv. page builder content), gebruik die
        $stripped_check = wp_strip_all_tags($content);
        $word_count = str_word_count($stripped_check);

        // De lat waar een andere bron overheen moet om de invoer te vervangen.
        // Die lat stond er altijd al, maar PAS NA de HTTP-fetch; hem ervoor
        // leggen is de hele winst: een bron die we gratis hebben en die er al
        // overheen komt, maakt het verzoek overbodig.
        $drempel = ($word_count > 50) ? (int) ceil($word_count * 0.8) : $word_count + 1;

        $beste       = '';
        $beste_woorden = 0;

        // 1. Gratis bronnen eerst. Met een page builder actief staat de afgemaakte
        //    markup al in the_content, en de schema-kant heeft de paginabron die
        //    ze bij het opslaan ophaalde bewaard. Op de gemeten Avada-site dekt
        //    dat alle 30 artikelen, en dan hoeft er niets opgehaald te worden.
        foreach ($this->get_local_page_sources($post_id) as $bron) {
            if ($bron === '') {
                continue;
            }

            $extracted = $this->extract_main_content_from_html($bron);
            if (empty($extracted)) {
                continue;
            }

            $woorden = str_word_count(wp_strip_all_tags($extracted));
            if ($woorden > $beste_woorden) {
                $beste         = $extracted;
                $beste_woorden = $woorden;
            }
        }

        if ($beste_woorden >= $drempel) {
            return $beste;
        }

        // 2. De gratis bronnen halen de lat niet, dus het theme rendert echt tekst
        //    daarbuiten. Eén HTTP-verzoek, en hooguit één per post per vijf
        //    minuten: de Content-tab heranalyseert 1,5 s na elke typepauze, en dat
        //    was tot nu toe een volledige paginarender per pauze.
        $opgehaald = $this->maybe_fetch_page_source($post_id);
        if ($opgehaald !== '') {
            $extracted = $this->extract_main_content_from_html($opgehaald);
            if (!empty($extracted)) {
                $woorden = str_word_count(wp_strip_all_tags($extracted));
                if ($woorden > $beste_woorden) {
                    $beste         = $extracted;
                    $beste_woorden = $woorden;
                }
            }
        }

        if ($beste_woorden >= $drempel) {
            return $beste;
        }

        // 3. Fallback: probeer ook direct uit de meegegeven HTML te extraheren
        // Dit werkt als de JS al gerenderde HTML meestuurt
        $direct_extracted = $this->extract_main_content_from_html($content);
        if (!empty($direct_extracted)) {
            $direct_words = str_word_count(wp_strip_all_tags($direct_extracted));
            if ($direct_words > $word_count) {
                return $direct_extracted;
            }
        }

        return $content;
    }

    /**
     * De paginabronnen die niets kosten.
     *
     * `the_content` (met de builder actief staat de afgemaakte markup daar al) en
     * de bron die de schema-kant bij het opslaan bewaarde. Dezelfde opslag als de
     * renderer leest, zodat de twee helften van de plugin het niet oneens kunnen
     * zijn over wat er op de pagina staat.
     *
     * @since 1.3.8.2
     * @param int $post_id Post ID.
     * @return string[]
     */
    private function get_local_page_sources($post_id) {
        $bronnen = array();

        $post = get_post($post_id);
        if (!$post) {
            return $bronnen;
        }

        // Bij een expliciete heranalyse slaan we de bewaarde bron over: wie op de
        // knop drukt vraagt om een verse meting, en de bewaarde kopie is per
        // definitie van eerder.
        if (!$this->force_page_fetch && function_exists('tamrank_get_rendered_source')) {
            $opgeslagen = tamrank_get_rendered_source($post_id);
            if ('' !== $opgeslagen) {
                $bronnen[] = $opgeslagen;
            }
        }

        $bronnen[] = $this->get_local_post_content($post);

        return $bronnen;
    }

    /**
     * Haal één keer per vijf minuten de pagina op, of helemaal niet.
     *
     * Faalt stil: elke fout levert een lege string en de analyse loopt door op de
     * lokale bronnen. Wat wél gelukt is gaat naar dezelfde opslag die de
     * schema-renderer leest, zodat de volgende analyse hem gratis vindt in stap 1
     * en de pagina niet twee keer opgehaald wordt.
     *
     * @since 1.3.8.2
     * @param int $post_id Post ID.
     * @return string
     */
    private function maybe_fetch_page_source($post_id) {
        $post_id = (int) $post_id;
        $rem     = 'tamrank_analyse_fetch_' . $post_id;

        if (!$this->force_page_fetch && get_transient($rem)) {
            return '';
        }

        // Rem vóór het verzoek: een fetch die de time-out uitzit mag niet elke
        // volgende typepauze opnieuw geprobeerd worden.
        set_transient($rem, 1, 5 * MINUTE_IN_SECONDS);

        $html = $this->fetch_full_page_html($post_id);
        if (empty($html)) {
            return '';
        }

        if (function_exists('tamrank_store_rendered_source')) {
            tamrank_store_rendered_source($post_id, $html);
        }

        return (string) $html;
    }

    /**
     * Laat de volgende analyse de vijf-minutenrem overslaan.
     *
     * Voor een expliciete heranalyse: een gebruiker die zelf op de knop drukt
     * vraagt om een verse meting, en die mag de pagina wél ophalen.
     *
     * @since 1.3.8.2
     * @param bool $force Rem overslaan?
     */
    public function set_force_page_fetch($force) {
        $this->force_page_fetch = (bool) $force;
    }

    /**
     * De post-content zoals WordPress hem zelf rendert, plus de custom velden.
     *
     * Haalt niets op over HTTP. Tot 1.3.8.2 begon deze methode met een volledige
     * paginafetch en was dit alleen de terugval; nu is dit de eerste bron en is de
     * fetch een uitzondering die zichzelf moet verdienen.
     *
     * @param WP_Post $post Post object
     * @return string HTML content
     */
    private function get_local_post_content($post) {
        if (!$post) {
            return '';
        }

        $post_id = $post->ID;

        // Gebruik the_content filter op post_content
        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- applying WordPress core 'the_content' filter, not a custom plugin hook
        $content = apply_filters('the_content', $post->post_content);

        // Methode 2: Check voor custom fields die content kunnen bevatten
        // Veel themes slaan content op in custom meta velden
        $custom_content_fields = array(
            '_tam_rank_rendered_content',
            '_elementor_data',
            '_fl_builder_data',
            'content',
            'page_content',
            'main_content',
        );

        foreach ($custom_content_fields as $field) {
            $meta_content = get_post_meta($post_id, $field, true);
            if (!empty($meta_content) && is_string($meta_content)) {
                // Als het JSON is (Elementor), probeer te parsen
                if ($field === '_elementor_data') {
                    $decoded = json_decode($meta_content, true);
                    if ($decoded) {
                        $elementor_text = $this->extract_text_from_elementor_data($decoded);
                        if (!empty($elementor_text)) {
                            $content .= ' ' . $elementor_text;
                        }
                    }
                } else {
                    $content .= ' ' . $meta_content;
                }
            }
        }

        return $content;
    }

    /**
     * Haal de volledige pagina HTML op via HTTP request
     * Dit is nodig om de H1 uit hero secties te krijgen die buiten post_content staan
     *
     * @param int $post_id Post ID
     * @return string Volledige pagina HTML of lege string bij fout
     */
    private function fetch_full_page_html($post_id) {
        // Alleen uitvoeren voor gepubliceerde posts
        $post = get_post($post_id);
        if (!$post || $post->post_status !== 'publish') {
            return '';
        }

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

        // Gebruik wp_remote_get met ruime timeout voor trage custom themes
        $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 ),
            'user-agent'  => 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'),
            'cookies'     => array(), // Geen raw cookies — triggert WAF bij upload
            'redirection' => 3,
        ));

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

        $body = wp_remote_retrieve_body($response);
        $status_code = wp_remote_retrieve_response_code($response);

        if ($status_code !== 200 || empty($body)) {
            return '';
        }

        return $body;
    }

    /**
     * Extraheer tekst uit Elementor data array
     *
     * @param array $data Elementor data
     * @return string Geëxtraheerde tekst
     */
    private function extract_text_from_elementor_data($data) {
        $text = '';

        if (!is_array($data)) {
            return $text;
        }

        foreach ($data as $element) {
            // Check voor tekst content in settings
            if (isset($element['settings'])) {
                $settings = $element['settings'];

                // Veelvoorkomende tekst velden in Elementor
                $text_fields = array('editor', 'title', 'description', 'content', 'text', 'heading_title');
                foreach ($text_fields as $field) {
                    if (isset($settings[$field]) && is_string($settings[$field])) {
                        $text .= ' ' . $settings[$field];
                    }
                }
            }

            // Recursief voor geneste elementen
            if (isset($element['elements']) && is_array($element['elements'])) {
                $text .= ' ' . $this->extract_text_from_elementor_data($element['elements']);
            }
        }

        return $text;
    }

    /**
     * Extraheer main content uit HTML met bekende container patronen
     *
     * @param string $html Volledige HTML
     * @return string Geëxtraheerde content of lege string
     */
    private function extract_main_content_from_html($html) {
        if (empty($html)) {
            return '';
        }

        // Stap 1: Probeer de H1 te extraheren uit hero/header secties
        // Veel themes plaatsen de H1 buiten de main content container
        $h1_html = $this->extract_page_h1($html);

        // Stap 2: Parse HTML met DOMDocument (robuuster dan regex voor geneste HTML)
        $doc = new DOMDocument();
        @$doc->loadHTML(mb_encode_numericentity($html, array(0x80, 0x10FFFF, 0, 0x1FFFFF), 'UTF-8'), LIBXML_NOERROR | LIBXML_NOWARNING);
        $xpath = new DOMXPath($doc);

        // XPath selectors in volgorde van specificiteit
        $selectors = array(
            // WordPress standaard content containers
            '//*[contains(@class,"entry-content")]',
            '//*[contains(@class,"post-content")]',
            '//*[contains(@class,"page-content")]',
            '//*[contains(@class,"the-content")]',
            '//*[contains(@class,"post-body")]',

            // Veelgebruikte generieke containers
            '//*[contains(@class,"article-content")]',
            '//*[contains(@class,"blog-content")]',
            '//*[contains(@class,"docs-content")]',
            '//*[contains(@class,"site-content")]',
            '//*[contains(@class,"content-area")]',
            '//*[contains(@class,"main-content")]',

            // ID-based
            '//*[@id="content"]',

            // ARIA role
            '//*[@role="main"]',

            // Semantic HTML tags (breed, vangen custom themes op)
            '//article',
            '//main',
        );

        // Filterbaar via hook zodat theme developers eigen selectors kunnen toevoegen
        $selectors = apply_filters('tamrank_content_selectors', $selectors);

        // Pass 1: bekende content-containers (precies op standaard-themes).
        $known          = '';
        $known_headings = 0;
        foreach ($selectors as $selector) {
            $nodes = $xpath->query($selector);
            if ($nodes && $nodes->length > 0) {
                $node       = $nodes->item(0);
                $inner_html = $doc->saveHTML($node);

                // Verwijder sidebar, navigation en footer elementen
                $cleaned = $this->remove_non_content_elements($inner_html);

                // Check of we daadwerkelijk content hebben gevonden
                if (str_word_count(wp_strip_all_tags($cleaned)) > 20) {
                    $known          = $cleaned;
                    $known_headings = (int) preg_match_all('/<h[1-6][^>]*>/i', $cleaned);
                    break;
                }
            }
        }

        // Pass 2: content-dichtheid-fallback voor custom/block-themes die hun
        // content-container een eigen klassenaam geven die niet op de
        // standaardlijst hierboven staat (theme-agnostisch, geen vaste namen).
        // Kiest het strakste blok met de meeste headings, na het wegfilteren van
        // menu/footer/banner. Overschrijft Pass 1 alleen bij wezenlijk meer
        // headings, zodat standaard-themes (waar Pass 1 al klopt) ongemoeid blijven.
        $dense          = $this->extract_densest_content_block($xpath, $doc);
        $dense_headings = ($dense !== '') ? (int) preg_match_all('/<h[1-6][^>]*>/i', $dense) : 0;

        $chosen = $known;
        if ($dense !== '' && $dense_headings >= 3 && $dense_headings > $known_headings + 1) {
            $chosen = $dense;
        }
        if ($chosen === '') {
            $chosen = ($dense !== '') ? $dense : $known;
        }

        // Pass 3: aggregeer top-level content-secties (theme-agnostisch). Pass 1/2
        // pakken één blok; op custom/FSE/page-builder-templates waar de content over
        // losse sibling-secties loopt (zonder herkende single container) onderschat
        // dat de pagina. We bepalen een content-scope (herkende root, anders <body>)
        // en plakken de chrome-vrije top-level blokken aan elkaar. Override alleen bij
        // wezenlijk meer headings OF meer tekst, zodat standaard-themes (waar Pass 1
        // al klopt) ongemoeid blijven.
        $aggregated = $this->extract_aggregated_sections($xpath, $doc);
        if ($aggregated !== '') {
            $chosen_words  = str_word_count(wp_strip_all_tags($chosen));
            $chosen_h      = ($chosen !== '') ? (int) preg_match_all('/<h[1-6][^>]*>/i', $chosen) : 0;
            $agg_words     = str_word_count(wp_strip_all_tags($aggregated));
            $agg_headings  = (int) preg_match_all('/<h[1-6][^>]*>/i', $aggregated);
            $more_headings = ($agg_headings > $chosen_h);
            $more_text     = ($agg_words > $chosen_words * 1.3 && $agg_headings >= max(1, (int) floor($chosen_h * 0.8)));
            if ($more_headings || $more_text) {
                $chosen = $aggregated;
            }
        }

        if ($chosen !== '') {
            // Voeg de H1 toe als die niet al in de content zit
            if (!empty($h1_html) && stripos($chosen, '<h1') === false) {
                $chosen = $h1_html . $chosen;
            }
            return $chosen;
        }

        return '';
    }

    /**
     * Aggregeer de top-level content-blokken van een pagina (theme-agnostisch).
     *
     * Fallback voor pagina's waar de content over meerdere losse top-level secties
     * loopt zonder herkende single container (custom themes, FSE/block-themes,
     * page builders zoals Divi). Bepaalt eerst een content-scope op basis van breed
     * gedragen landmarks (main/role=main/article/#content/wp-site-blocks/site-main),
     * en valt anders terug op <body>. Plakt vervolgens de chrome-vrije directe
     * section/article/div-kinderen aan elkaar zodat alle inhoud meetelt.
     *
     * @param DOMXPath    $xpath
     * @param DOMDocument $doc
     * @return string Geaggregeerde content of lege string
     */
    private function extract_aggregated_sections($xpath, $doc) {
        // 1. Content-scope: eerste herkende root, anders <body>.
        $scope = null;
        $roots = array(
            '//main',
            '//*[@role="main"]',
            '//article',
            '//*[@id="content"]',
            '//*[contains(@class,"wp-site-blocks")]',
            '//*[contains(@class,"site-main")]',
        );
        foreach ($roots as $q) {
            $n = $xpath->query($q);
            if ($n && $n->length > 0) { $scope = $n->item(0); break; }
        }
        if ($scope === null) {
            $b = $xpath->query('//body');
            $scope = ($b && $b->length > 0) ? $b->item(0) : null;
        }
        if ($scope === null) {
            return '';
        }

        // 2. Top-level content-blokken binnen de scope: directe section/article/div-
        //    kinderen die zichzelf niet als chrome labelen (nav/footer/sidebar/banner/
        //    related/cookie/overlay/breadcrumb).
        $junk   = '/(?:^|[\s_-])(?:nav|menu|sidebar|widget|footer|header|cookie|consent|banner|popup|modal|overlay|related|comment|share|social|breadcrumb|pagination|skip-link|screen-reader)(?:[\s_-]|$)/i';
        $blocks = array();
        foreach ($scope->childNodes as $child) {
            if (!($child instanceof DOMElement)) {
                continue;
            }
            $tag = strtolower($child->nodeName);
            if (!in_array($tag, array('section', 'article', 'div'), true)) {
                continue;
            }
            $label = $child->getAttribute('class') . ' ' . $child->getAttribute('id');
            if ($label !== '' && preg_match($junk, $label)) {
                continue;
            }
            $blocks[] = $child;
        }

        // < 2 blokken = één wrapper (klassiek thema); Pass 1/2 pakt dat al af.
        if (count($blocks) < 2) {
            return '';
        }

        $aggregated = '';
        foreach ($blocks as $node) {
            $aggregated .= $this->remove_non_content_elements($doc->saveHTML($node));
        }

        return $aggregated;
    }

    /**
     * Vind het content-rijkste blok op basis van dichtheid (headings + tekst).
     *
     * Fallback voor themes zonder herkenbare content-klasse. Pakt het strakste
     * element dat de meeste headings bevat en slaat menu/footer/sidebar/banner-
     * achtige containers over, zodat bijv. een cookie-banner niet meetelt.
     *
     * @param DOMXPath    $xpath
     * @param DOMDocument $doc
     * @return string Geëxtraheerde content of lege string
     */
    private function extract_densest_content_block($xpath, $doc) {
        $junk = '/(?:^|[\s_-])(?:nav|menu|sidebar|widget|footer|header|cookie|consent|banner|popup|modal|overlay|related|comment|share|social|breadcrumb|pagination)(?:[\s_-]|$)/i';

        $nodes = $xpath->query('//div | //section | //article | //main');
        if (!$nodes || $nodes->length === 0) {
            return '';
        }

        $candidates   = array();
        $max_headings = 0;

        foreach ($nodes as $node) {
            if (!($node instanceof DOMElement)) {
                continue;
            }

            // Sla containers over die zichzelf als menu/footer/banner labelen
            $label = $node->getAttribute('class') . ' ' . $node->getAttribute('id');
            if ($label !== '' && preg_match($junk, $label)) {
                continue;
            }

            // Snelle pre-filter op ruw aantal headings (vermijd duur opschonen)
            $raw_headings = $xpath->query('.//h1|.//h2|.//h3|.//h4|.//h5|.//h6', $node);
            if (!$raw_headings || $raw_headings->length < 2) {
                continue;
            }

            $cleaned  = $this->remove_non_content_elements($doc->saveHTML($node));
            $headings = (int) preg_match_all('/<h[1-6][^>]*>/i', $cleaned);
            $words    = str_word_count(wp_strip_all_tags($cleaned));

            if ($headings >= 2 && $words >= 80) {
                $candidates[] = array('html' => $cleaned, 'headings' => $headings, 'words' => $words);
                if ($headings > $max_headings) {
                    $max_headings = $headings;
                }
            }
        }

        if (empty($candidates)) {
            return '';
        }

        // Strakste wrapper die >= 80% van de meeste headings bevat (minste woorden wint).
        $best       = '';
        $best_words = PHP_INT_MAX;
        foreach ($candidates as $candidate) {
            if ($candidate['headings'] >= $max_headings * 0.8 && $candidate['words'] < $best_words) {
                $best       = $candidate['html'];
                $best_words = $candidate['words'];
            }
        }

        return $best;
    }

    /**
     * Extraheer de H1 tag uit de pagina HTML
     * Werkt met diverse custom themes door te zoeken naar de eerste H1 in de content area
     *
     * @param string $html Volledige pagina HTML
     * @return string H1 tag HTML of lege string
     */
    private function extract_page_h1($html) {
        // Stap 1: Verwijder elementen waar de H1 NIET in zou moeten staan
        // Dit voorkomt dat we een H1 uit de navigatie, sidebar of footer pakken
        $excluded_patterns = array(
            // Navigatie elementen
            '/<nav[^>]*>.*?<\/nav>/si',
            // Footer
            '/<footer[^>]*class="[^"]*(?:site-footer|main-footer|page-footer)[^"]*"[^>]*>.*?<\/footer>/si',
            // Sidebar
            '/<aside[^>]*>.*?<\/aside>/si',
            '/<div[^>]*class="[^"]*(?:sidebar|widget-area)[^"]*"[^>]*>.*?<\/div>/si',
            // Comments sectie
            '/<div[^>]*(?:id="comments"|class="[^"]*comments[^"]*")[^>]*>.*?<\/div>/si',
            '/<section[^>]*(?:id="comments"|class="[^"]*comments[^"]*")[^>]*>.*?<\/section>/si',
        );

        $clean_html = $html;
        foreach ($excluded_patterns as $pattern) {
            $clean_html = preg_replace($pattern, '', $clean_html);
        }

        // Stap 2: Zoek naar de eerste H1 in de opgeschoonde HTML
        // Dit werkt universeel ongeacht welke container het theme gebruikt
        if (preg_match('/<h1[^>]*>(.*?)<\/h1>/si', $clean_html, $matches)) {
            // Controleer of de H1 daadwerkelijk tekst bevat (niet leeg of alleen whitespace/HTML entities)
            $h1_text = trim(wp_strip_all_tags(html_entity_decode($matches[1])));
            if (!empty($h1_text) && strlen($h1_text) > 1) {
                return $matches[0]; // Return de volledige H1 tag inclusief HTML
            }
        }

        return '';
    }

    /**
     * Verwijder non-content elementen (sidebar, nav, footer, etc.)
     *
     * @param string $html HTML content
     * @return string Opgeschoonde content
     */
    private function remove_non_content_elements($html) {
        if ($html === null || trim($html) === '') {
            return '';
        }

        // DOM-gebaseerde node-removal i.p.v. regex. De oude non-greedy regexes
        // (.*? tot de eerste </div>) konden geneste containers niet aan: ze
        // stripten een wrapper maar lieten de geneste divs eronder staan, en
        // misten het WP comment_form()-blok (<div id="respond"
        // class="comment-respond">) en sidebar-widgets zonder 'sidebar'-label.
        // Een node verwijderen neemt al zijn descendants in één keer mee, dus
        // nesting is hier niet langer een probleem.
        $doc    = new DOMDocument();
        $loaded = @$doc->loadHTML(
            mb_encode_numericentity('<div id="tr-frag-root">' . $html . '</div>', array(0x80, 0x10FFFF, 0, 0x1FFFFF), 'UTF-8'),
            LIBXML_NOERROR | LIBXML_NOWARNING
        );
        if (!$loaded) {
            return $html; // Fallback: laat ruwe HTML staan bij een parse-fout.
        }

        $xpath  = new DOMXPath($doc);
        $remove = array();

        // Tag-based non-content elementen (incl. descendants verwijderd).
        $tag_nodes = $xpath->query('//aside | //nav | //footer | //script | //style | //comment()');
        if ($tag_nodes) {
            foreach ($tag_nodes as $node) {
                $remove[] = $node;
            }
        }

        // Class/id-gelabelde non-content containers. Token-match (woordgrens via
        // [\s_-] of begin/eind) op dezelfde begrippen als
        // extract_densest_content_block(), uitgebreid met het comment-formulier:
        // 'respond' / 'comment-respond' / 'comment-form' dekken het WP
        // comment_form()-blok met zijn H3 + 'Cancel reply'-link.
        // 'widget' staat bewust NIET in deze brede lijst: page builders (Elementor)
        // wrappen álle content in 'elementor-widget(-container)', wat hier anders de
        // hele inhoud zou strippen. Echte WP-sidebar-widgets vangen we hieronder met
        // een strakkere grens (woordbegin/-spatie), zodat 'elementor-widget' gespaard
        // blijft maar 'widget'/'widget-area'/'widget_*' wel als chrome tellen.
        $label_junk  = '/(?:^|[\s_-])(?:sidebar|menu|toc|table-of-contents|navigation|share|social|comments?|comment-respond|comment-form|respond|related|author-box|author-bio|post-tags|post-meta|breadcrumb|pagination)(?:[\s_-]|$)/i';
        $widget_junk = '/(?:^|\s)widget(?:-area)?(?:[\s_]|$)/i';
        $labeled     = $xpath->query('//*[@class or @id]');
        if ($labeled) {
            foreach ($labeled as $node) {
                if (!($node instanceof DOMElement)) {
                    continue;
                }
                $label = trim($node->getAttribute('class') . ' ' . $node->getAttribute('id'));
                if ($label !== '' && (preg_match($label_junk, $label) || preg_match($widget_junk, $label))) {
                    $remove[] = $node;
                }
            }
        }

        // Pas na het verzamelen verwijderen (de live NodeList muteren tijdens
        // iteratie is onveilig). parentNode-guard vangt nodes af die al met een
        // verwijderde voorouder zijn meegegaan.
        foreach ($remove as $node) {
            if ($node->parentNode) {
                $node->parentNode->removeChild($node);
            }
        }

        // Geef de inner HTML van de wrapper terug (zonder de tr-frag-root div).
        $root = $xpath->query('//*[@id="tr-frag-root"]')->item(0);
        if (!$root) {
            return '';
        }
        $inner = '';
        foreach ($root->childNodes as $child) {
            $inner .= $doc->saveHTML($child);
        }

        return $inner;
    }

    /**
     * Verwijder page builder shortcodes en behoud alleen leesbare content
     *
     * Ondersteunt: Divi Builder, Elementor, WPBakery, Beaver Builder, en generieke shortcodes
     *
     * @param string $content Ruwe content
     * @return string Schone content
     */
    private function strip_page_builder_content($content) {
        // PHP 8.1+ null-safe check
        if ($content === null || $content === '') {
            return '';
        }
        $content = (string) $content;

        // BELANGRIJK: Render shortcodes naar HTML zodat Divi afbeeldingen worden geconverteerd naar <img> tags
        // We gebruiken apply_filters('the_content', ...) voor volledige compatibiliteit met page builders
        // maar alleen als we niet al gerenderde HTML hebben (check voor <img tags)
        if (strpos($content, '<img') === false && strpos($content, '[et_pb_') !== false) {
            // Content bevat shortcodes maar geen img tags - probeer te renderen
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- applying WordPress core 'the_content' filter, not a custom plugin hook
            $content = apply_filters('the_content', $content);
        } elseif (strpos($content, '<img') === false) {
            // Fallback naar do_shortcode voor andere shortcodes
            $content = do_shortcode($content);
        }

        // Nu verwijderen we alleen de OVERBLIJVENDE shortcode tags (die niet gerenderd zijn)
        // en de builder-specifieke wrapper markup

        // Divi Builder shortcodes die niet gerenderd zijn - verwijder alle [et_pb_*] tags
        $content = preg_replace('/\[et_pb_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/et_pb_[^\]]*\]/', '', $content);

        // Divi specifieke shortcodes
        $content = preg_replace('/\[et_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/et_[^\]]*\]/', '', $content);

        // WPBakery/Visual Composer shortcodes [vc_*]
        $content = preg_replace('/\[vc_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/vc_[^\]]*\]/', '', $content);

        // Beaver Builder shortcodes [fl_*]
        $content = preg_replace('/\[fl_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/fl_[^\]]*\]/', '', $content);

        // Elementor - verwijder data attributen en wrapper divs
        // Elementor slaat content op in HTML met data-elementor attributen
        $content = preg_replace('/data-elementor[^=]*="[^"]*"/', '', $content);
        $content = preg_replace('/data-widget_type="[^"]*"/', '', $content);
        $content = preg_replace('/data-element_type="[^"]*"/', '', $content);
        $content = preg_replace('/data-settings="[^"]*"/', '', $content);
        $content = preg_replace('/data-id="[^"]*"/', '', $content);

        // Verwijder lege Elementor wrapper classes (optioneel, helpt bij cleaner output)
        $content = preg_replace('/class="elementor[^"]*"/', '', $content);

        // Fusion Builder (Avada) shortcodes [fusion_*]
        $content = preg_replace('/\[fusion_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/fusion_[^\]]*\]/', '', $content);

        // Themify Builder [tb_*]
        $content = preg_replace('/\[tb_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/tb_[^\]]*\]/', '', $content);

        // SiteOrigin Page Builder [siteorigin_*]
        $content = preg_replace('/\[siteorigin_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/siteorigin_[^\]]*\]/', '', $content);

        // Brizy Builder [brizy_*]
        $content = preg_replace('/\[brizy_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/brizy_[^\]]*\]/', '', $content);

        // Oxygen Builder shortcodes [ct_*] en [oxy_*]
        $content = preg_replace('/\[ct_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/ct_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[oxy_[^\]]*\]/', '', $content);
        $content = preg_replace('/\[\/oxy_[^\]]*\]/', '', $content);

        // Verwijder escaped JSON en data attributes die mogelijk zijn achtergebleven
        $content = preg_replace('/\{["\'][^}]*builder[^}]*\}/', '', $content);

        // Verwijder overgebleven lege shortcode-achtige patronen
        $content = preg_replace('/\[\/?[a-z_]+[^\]]*\]/i', '', $content);

        // Ruim whitespace op
        $content = preg_replace('/\s+/', ' ', $content);
        $content = trim($content);

        return $content;
    }

    /**
     * Extraheer headings uit content
     *
     * @param string $content HTML content
     * @return array Headings met level en tekst
     */
    private function extract_headings($content) {
        $headings = array();

        // PHP 8.1+ null-safe check
        if ($content === null || $content === '') {
            return $headings;
        }
        $content = (string) $content;

        // Match alle h1-h6 tags
        preg_match_all('/<h([1-6])[^>]*>(.*?)<\/h\1>/si', $content, $matches, PREG_SET_ORDER);

        $previous_level = 0;
        foreach ($matches as $match) {
            $level = intval($match[1]);
            $text = wp_strip_all_tags($match[2]);
            $text = trim($text);

            $heading = array(
                'level' => $level,
                'text' => $text,
            );

            // Check voor problemen
            if ($level > $previous_level + 1 && $previous_level > 0) {
                $heading['issue'] = __( 'Skips a level', 'tam-rank' );
            }

            $headings[] = $heading;
            $previous_level = $level;
        }

        return $headings;
    }

    /**
     * Extraheer afbeeldingen uit content
     *
     * @param string $content HTML content
     * @return array Afbeeldingen met src en alt
     */
    private function extract_images($content) {
        $images = array();

        // PHP 8.1+ null-safe check
        if ($content === null || $content === '') {
            return $images;
        }
        $content = (string) $content;

        preg_match_all('/<img[^>]+>/si', $content, $matches);

        foreach ($matches[0] as $img) {
            // Haal src op
            preg_match('/src=["\']([^"\']+)["\']/', $img, $src_match);
            $src = isset($src_match[1]) ? $src_match[1] : '';

            // Skip als geen src
            if (empty($src)) {
                continue;
            }

            // Haal alt op uit de img tag
            preg_match('/alt=["\']([^"\']*)["\']/', $img, $alt_match);
            $alt = isset($alt_match[1]) ? $alt_match[1] : '';

            // Haal width en height attributen op (voor render size detectie)
            preg_match('/width=["\']?(\d+)["\']?/', $img, $width_match);
            preg_match('/height=["\']?(\d+)["\']?/', $img, $height_match);
            $render_width = isset($width_match[1]) ? intval($width_match[1]) : 0;
            $render_height = isset($height_match[1]) ? intval($height_match[1]) : 0;

            // Check voor lazy loading attribute
            $has_lazy_loading = (strpos($img, 'loading="lazy"') !== false || strpos($img, "loading='lazy'") !== false);

            // Haal attachment ID op
            $attachment_id = $this->get_attachment_id_from_url($src);

            // Als geen alt in de img tag, probeer uit WordPress media library
            if (empty($alt) && $attachment_id) {
                $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
            }

            // Check of alt een bestandsnaam is
            $is_filename_alt = $this->is_filename_alt($alt);

            // Verzamel uitgebreide image data
            $image_data = $this->get_extended_image_data($src, $attachment_id, $render_width, $render_height);

            $images[] = array_merge(array(
                'src' => $src,
                'alt' => $alt ?: '',
                'is_filename_alt' => $is_filename_alt,
                'source' => 'html',
                'has_lazy_loading' => $has_lazy_loading,
                'render_width' => $render_width,
                'render_height' => $render_height,
                'attachment_id' => $attachment_id ?: 0,
            ), $image_data);
        }

        return $images;
    }

    /**
     * Haal uitgebreide image data op (file size, dimensions, webp)
     *
     * @param string $src Image URL
     * @param int|false $attachment_id WordPress attachment ID
     * @param int $render_width Weergave breedte
     * @param int $render_height Weergave hoogte
     * @return array Extended image data
     */
    private function get_extended_image_data($src, $attachment_id, $render_width = 0, $render_height = 0) {
        $data = array(
            'file_size' => 0,
            'file_size_formatted' => '',
            'file_size_status' => 'unknown',
            'original_width' => 0,
            'original_height' => 0,
            'is_oversized' => false,
            'oversized_factor' => 0,
            'has_webp' => false,
            'webp_url' => '',
            'format' => '',
        );

        // Haal bestandsextensie/format op
        $path_info = pathinfo(strtok($src, '?'));
        $data['format'] = strtolower($path_info['extension'] ?? '');

        // Als we een attachment ID hebben, gebruik WordPress metadata
        if ($attachment_id) {
            $file_path = get_attached_file($attachment_id);
            $metadata = wp_get_attachment_metadata($attachment_id);

            // File size
            if ($file_path && file_exists($file_path)) {
                $file_size = filesize($file_path);
                $data['file_size'] = $file_size;
                $data['file_size_formatted'] = $this->format_file_size($file_size);
                $data['file_size_status'] = $this->get_file_size_status($file_size);
            }

            // Original dimensions
            if ($metadata && isset($metadata['width']) && isset($metadata['height'])) {
                $data['original_width'] = intval($metadata['width']);
                $data['original_height'] = intval($metadata['height']);

                // Check of afbeelding oversized is (origineel > 2× render size)
                if ($render_width > 0 && $data['original_width'] > 0) {
                    $width_ratio = $data['original_width'] / $render_width;
                    if ($width_ratio > 2) {
                        $data['is_oversized'] = true;
                        $data['oversized_factor'] = round($width_ratio, 1);
                    }
                }
            }

            // Check voor WebP versie
            $data['has_webp'] = $this->check_webp_exists($file_path, $src);
            if ($data['has_webp']) {
                $data['webp_url'] = $this->get_webp_url($src);
            }
        } else {
            // Probeer file size via HTTP HEAD request (alleen voor lokale images)
            $upload_dir = wp_upload_dir();
            if (strpos($src, $upload_dir['baseurl']) !== false) {
                $file_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], strtok($src, '?'));
                if (file_exists($file_path)) {
                    $file_size = filesize($file_path);
                    $data['file_size'] = $file_size;
                    $data['file_size_formatted'] = $this->format_file_size($file_size);
                    $data['file_size_status'] = $this->get_file_size_status($file_size);

                    // Get dimensions via getimagesize
                    // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- getimagesize warns on unreadable/invalid images; we handle the false return below.
                    $image_info = @getimagesize($file_path);
                    if ($image_info) {
                        $data['original_width'] = $image_info[0];
                        $data['original_height'] = $image_info[1];

                        if ($render_width > 0 && $data['original_width'] > 0) {
                            $width_ratio = $data['original_width'] / $render_width;
                            if ($width_ratio > 2) {
                                $data['is_oversized'] = true;
                                $data['oversized_factor'] = round($width_ratio, 1);
                            }
                        }
                    }

                    // Check WebP
                    $data['has_webp'] = $this->check_webp_exists($file_path, $src);
                    if ($data['has_webp']) {
                        $data['webp_url'] = $this->get_webp_url($src);
                    }
                }
            }
        }

        return $data;
    }

    /**
     * Formatteer bestandsgrootte naar leesbaar formaat
     *
     * @param int $bytes Bestandsgrootte in bytes
     * @return string Geformatteerde grootte
     */
    private function format_file_size($bytes) {
        if ($bytes >= 1048576) {
            return round($bytes / 1048576, 1) . ' MB';
        } elseif ($bytes >= 1024) {
            return round($bytes / 1024) . ' KB';
        }
        return $bytes . ' B';
    }

    /**
     * Bepaal status van bestandsgrootte
     *
     * @param int $bytes Bestandsgrootte in bytes
     * @return string Status (good/info/warning/error)
     */
    private function get_file_size_status($bytes) {
        $kb = $bytes / 1024;

        if ($kb <= 150) {
            return 'good';      // Groen: ≤150KB
        } elseif ($kb <= 300) {
            return 'info';      // Blauw: 150-300KB
        } elseif ($kb <= 1024) {
            return 'warning';   // Oranje: 300KB-1MB
        }
        return 'error';         // Roze: >1MB
    }

    /**
     * Check of WebP versie bestaat
     *
     * @param string $file_path Pad naar origineel bestand
     * @param string $src Image URL
     * @return bool True als WebP bestaat
     */
    private function check_webp_exists($file_path, $src) {
        if (empty($file_path)) {
            return false;
        }

        // Als het bestand zelf al WebP is
        if (pathinfo($file_path, PATHINFO_EXTENSION) === 'webp') {
            return true;
        }

        // Check voor .webp versie naast het origineel
        $webp_path = preg_replace('/\.(jpe?g|png|gif)$/i', '.webp', $file_path);
        if (file_exists($webp_path)) {
            return true;
        }

        // Check voor .jpg.webp / .png.webp variant (sommige plugins)
        $webp_path_suffix = $file_path . '.webp';
        if (file_exists($webp_path_suffix)) {
            return true;
        }

        // Check in wp-content/cache/webp/ (voor caching plugins zoals WP Rocket, EWWW)
        $upload_dir = wp_upload_dir();
        $relative_path = str_replace($upload_dir['basedir'], '', $file_path);
        $cache_webp_path = WP_CONTENT_DIR . '/cache/webp' . $relative_path . '.webp';
        if (file_exists($cache_webp_path)) {
            return true;
        }

        return false;
    }

    /**
     * Haal WebP URL op
     *
     * @param string $src Originele image URL
     * @return string WebP URL of lege string
     */
    private function get_webp_url($src) {
        // Vervang extensie door .webp
        return preg_replace('/\.(jpe?g|png|gif)$/i', '.webp', $src);
    }

    /**
     * Extraheer afbeeldingen uit page builder shortcodes (Divi, WPBakery, etc.)
     * Dit moet worden aangeroepen VOORDAT shortcodes worden gestript
     *
     * @param string $content Ruwe content met shortcodes
     * @return array Afbeeldingen met src en alt
     */
    private function extract_page_builder_images($content) {
        $images = array();

        // PHP 8.1+ null-safe check
        if ($content === null || $content === '') {
            return $images;
        }
        $content = (string) $content;

        // Divi Builder: [et_pb_image src="..." alt="..."]
        // Let op: Divi shortcodes kunnen multiline zijn, dus gebruik [\s\S] in plaats van [^\]]
        // Matcht zowel self-closing [et_pb_image ...] als [et_pb_image ...][/et_pb_image]
        preg_match_all('/\[et_pb_image\s[^\]]*\]/si', $content, $divi_images);

        // Als geen matches met spatie, probeer ook zonder (voor compacte shortcodes)
        if (empty($divi_images[0])) {
            preg_match_all('/\[et_pb_image[^\]]+\]/si', $content, $divi_images);
        }

        foreach ($divi_images[0] as $shortcode) {
            $src = '';
            $alt = '';

            // Haal src op - ook met URL encoding en speciale karakters
            if (preg_match('/src=["\']([^"\']+)["\']/', $shortcode, $src_match)) {
                $src = $src_match[1];
            }

            // Haal alt op uit shortcode attribuut
            if (preg_match('/alt=["\']([^"\']*)["\']/', $shortcode, $alt_match)) {
                $alt = $alt_match[1];
            }

            // Als geen alt in shortcode, probeer de echte alt-tekst uit WordPress media library
            if (empty($alt) && !empty($src)) {
                $attachment_id = $this->get_attachment_id_from_url($src);
                if ($attachment_id) {
                    $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                }
            }

            if (!empty($src)) {
                $attachment_id = $this->get_attachment_id_from_url($src);
                $is_filename_alt = $this->is_filename_alt($alt);
                $image_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                $images[] = array_merge(array(
                    'src' => $src,
                    'alt' => $alt ?: '',
                    'is_filename_alt' => $is_filename_alt,
                    'source' => 'divi',
                    'has_lazy_loading' => false, // Page builders meestal geen native lazy loading
                    'render_width' => 0,
                    'render_height' => 0,
                    'attachment_id' => $attachment_id ?: 0,
                ), $image_data);
            }
        }

        // Divi Gallery: [et_pb_gallery gallery_ids="1,2,3"]
        preg_match_all('/\[et_pb_gallery[^\]]*gallery_ids=["\']([^"\']+)["\'][^\]]*\]/si', $content, $divi_galleries);
        foreach ($divi_galleries[1] as $gallery_ids) {
            $ids = explode(',', $gallery_ids);
            foreach ($ids as $id) {
                $id = intval(trim($id));
                if ($id > 0) {
                    $attachment = get_post($id);
                    if ($attachment && $attachment->post_type === 'attachment') {
                        $src = wp_get_attachment_url($id);
                        $alt = get_post_meta($id, '_wp_attachment_image_alt', true);
                        if (!empty($src)) {
                            $image_data = $this->get_extended_image_data($src, $id, 0, 0);
                            $images[] = array_merge(array(
                                'src' => $src,
                                'alt' => $alt ?: '',
                                'is_filename_alt' => $this->is_filename_alt($alt),
                                'source' => 'divi_gallery',
                                'has_lazy_loading' => false,
                                'render_width' => 0,
                                'render_height' => 0,
                                'attachment_id' => $id,
                            ), $image_data);
                        }
                    }
                }
            }
        }

        // WPBakery/Visual Composer: [vc_single_image image="123" img_size="..."]
        preg_match_all('/\[vc_single_image[^\]]*image=["\'](\d+)["\'][^\]]*\]/si', $content, $vc_images);
        foreach ($vc_images[1] as $attachment_id) {
            $attachment_id = intval($attachment_id);
            if ($attachment_id > 0) {
                $src = wp_get_attachment_url($attachment_id);
                $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                if (!empty($src)) {
                    $image_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                    $images[] = array_merge(array(
                        'src' => $src,
                        'alt' => $alt ?: '',
                        'is_filename_alt' => $this->is_filename_alt($alt),
                        'source' => 'wpbakery',
                        'has_lazy_loading' => false,
                        'render_width' => 0,
                        'render_height' => 0,
                        'attachment_id' => $attachment_id,
                    ), $image_data);
                }
            }
        }

        // Beaver Builder: [fl_builder_insert_layout id="123"]
        // en background images in modules
        preg_match_all('/\[fl_[^\]]*(?:photo|image)[^\]]*\]/si', $content, $fl_images);
        foreach ($fl_images[0] as $shortcode) {
            if (preg_match('/id=["\'](\d+)["\']/', $shortcode, $id_match)) {
                $attachment_id = intval($id_match[1]);
                if ($attachment_id > 0) {
                    $src = wp_get_attachment_url($attachment_id);
                    $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                    if (!empty($src)) {
                        $image_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                        $images[] = array_merge(array(
                            'src' => $src,
                            'alt' => $alt ?: '',
                            'is_filename_alt' => $this->is_filename_alt($alt),
                            'source' => 'beaver_builder',
                            'has_lazy_loading' => false,
                            'render_width' => 0,
                            'render_height' => 0,
                            'attachment_id' => $attachment_id,
                        ), $image_data);
                    }
                }
            }
        }

        // Fusion Builder (Avada): [fusion_imageframe image_id="123"]
        preg_match_all('/\[fusion_imageframe[^\]]*image_id=["\'](\d+)["\'][^\]]*\]/si', $content, $fusion_images);
        foreach ($fusion_images[1] as $attachment_id) {
            $attachment_id = intval($attachment_id);
            if ($attachment_id > 0) {
                $src = wp_get_attachment_url($attachment_id);
                $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                if (!empty($src)) {
                    $image_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                    $images[] = array_merge(array(
                        'src' => $src,
                        'alt' => $alt ?: '',
                        'is_filename_alt' => $this->is_filename_alt($alt),
                        'source' => 'fusion',
                        'has_lazy_loading' => false,
                        'render_width' => 0,
                        'render_height' => 0,
                        'attachment_id' => $attachment_id,
                    ), $image_data);
                }
            }
        }

        // Gutenberg blocks: <!-- wp:image {"id":123} --> of wp:core/image
        // Extract image IDs from Gutenberg block comments
        preg_match_all('/<!-- wp:(?:core\/)?image\s*(\{[^}]*\})?[^>]*-->/si', $content, $gutenberg_images);
        foreach ($gutenberg_images[1] as $json_attrs) {
            if (!empty($json_attrs)) {
                $attrs = json_decode($json_attrs, true);
                if ($attrs && isset($attrs['id'])) {
                    $attachment_id = intval($attrs['id']);
                    if ($attachment_id > 0) {
                        $src = wp_get_attachment_url($attachment_id);
                        $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                        if (!empty($src)) {
                            $image_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                            $images[] = array_merge(array(
                                'src' => $src,
                                'alt' => $alt ?: '',
                                'is_filename_alt' => $this->is_filename_alt($alt),
                                'source' => 'gutenberg',
                                'has_lazy_loading' => true, // WordPress adds lazy loading by default
                                'render_width' => 0,
                                'render_height' => 0,
                                'attachment_id' => $attachment_id,
                            ), $image_data);
                        }
                    }
                }
            }
        }

        // Gutenberg gallery: <!-- wp:gallery {"ids":[1,2,3]} -->
        preg_match_all('/<!-- wp:(?:core\/)?gallery\s*(\{[^}]*\})?[^>]*-->/si', $content, $gutenberg_galleries);
        foreach ($gutenberg_galleries[1] as $json_attrs) {
            if (!empty($json_attrs)) {
                $attrs = json_decode($json_attrs, true);
                if ($attrs && isset($attrs['ids']) && is_array($attrs['ids'])) {
                    foreach ($attrs['ids'] as $attachment_id) {
                        $attachment_id = intval($attachment_id);
                        if ($attachment_id > 0) {
                            $src = wp_get_attachment_url($attachment_id);
                            $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                            if (!empty($src)) {
                                $image_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                                $images[] = array_merge(array(
                                    'src' => $src,
                                    'alt' => $alt ?: '',
                                    'is_filename_alt' => $this->is_filename_alt($alt),
                                    'source' => 'gutenberg_gallery',
                                    'has_lazy_loading' => true,
                                    'render_width' => 0,
                                    'render_height' => 0,
                                    'attachment_id' => $attachment_id,
                                ), $image_data);
                            }
                        }
                    }
                }
            }
        }

        // Elementor: data-id in widgets of image URLs in JSON data
        // Elementor stores image data in data-settings attributes
        preg_match_all('/data-settings=["\']([^"\']+)["\']/si', $content, $elementor_settings);
        foreach ($elementor_settings[1] as $settings_json) {
            // Decode HTML entities first
            $settings_json = html_entity_decode($settings_json, ENT_QUOTES, 'UTF-8');
            $settings = json_decode($settings_json, true);
            if ($settings) {
                // Check for image in various Elementor formats
                $image_data = null;
                if (isset($settings['image']['id'])) {
                    $image_data = $settings['image'];
                } elseif (isset($settings['background_image']['id'])) {
                    $image_data = $settings['background_image'];
                }

                if ($image_data && isset($image_data['id'])) {
                    $attachment_id = intval($image_data['id']);
                    if ($attachment_id > 0) {
                        $src = isset($image_data['url']) ? $image_data['url'] : wp_get_attachment_url($attachment_id);
                        $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                        if (!empty($src)) {
                            $ext_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                            $images[] = array_merge(array(
                                'src' => $src,
                                'alt' => $alt ?: '',
                                'is_filename_alt' => $this->is_filename_alt($alt),
                                'source' => 'elementor',
                                'has_lazy_loading' => false,
                                'render_width' => 0,
                                'render_height' => 0,
                                'attachment_id' => $attachment_id,
                            ), $ext_data);
                        }
                    }
                }
            }
        }

        // Elementor: Check for wp-image-{id} classes in content (backup method)
        preg_match_all('/class=["\'][^"\']*wp-image-(\d+)[^"\']*["\']/si', $content, $wp_image_classes);
        foreach ($wp_image_classes[1] as $attachment_id) {
            $attachment_id = intval($attachment_id);
            if ($attachment_id > 0) {
                $src = wp_get_attachment_url($attachment_id);
                if (!empty($src)) {
                    // Check if we already have this image (by normalized src)
                    $normalized_src = $this->normalize_image_src($src);
                    $already_exists = false;
                    foreach ($images as $existing) {
                        if ($this->normalize_image_src($existing['src']) === $normalized_src) {
                            $already_exists = true;
                            break;
                        }
                    }

                    if (!$already_exists) {
                        $alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
                        $ext_data = $this->get_extended_image_data($src, $attachment_id, 0, 0);
                        $images[] = array_merge(array(
                            'src' => $src,
                            'alt' => $alt ?: '',
                            'is_filename_alt' => $this->is_filename_alt($alt),
                            'source' => 'wp_image_class',
                            'has_lazy_loading' => false,
                            'render_width' => 0,
                            'render_height' => 0,
                            'attachment_id' => $attachment_id,
                        ), $ext_data);
                    }
                }
            }
        }

        return $images;
    }

    /**
     * Check of alt-tekst een bestandsnaam is
     *
     * @param string $alt Alt-tekst
     * @return bool True als het een bestandsnaam is
     */
    private function is_filename_alt($alt) {
        if (empty($alt)) {
            return false;
        }

        return preg_match('/^(IMG_|DSC_|image|foto|picture|screen)/i', $alt) ||
               preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $alt) ||
               preg_match('/^\d{4,}$/', $alt);
    }

    /**
     * Combineer page builder images met HTML images (vermijd duplicaten)
     *
     * @param array $page_builder_images Images uit page builder shortcodes
     * @param array $html_images Images uit HTML <img> tags
     * @return array Gecombineerde unieke afbeeldingen
     */
    private function merge_images($page_builder_images, $html_images) {
        $merged = array();
        $seen_sources = array();

        // Voeg eerst page builder images toe
        foreach ($page_builder_images as $img) {
            $normalized_src = $this->normalize_image_src($img['src']);
            if (!in_array($normalized_src, $seen_sources)) {
                $seen_sources[] = $normalized_src;
                $merged[] = $img;
            }
        }

        // Voeg HTML images toe (alleen als ze niet al bestaan)
        foreach ($html_images as $img) {
            $normalized_src = $this->normalize_image_src($img['src']);
            if (!in_array($normalized_src, $seen_sources)) {
                $seen_sources[] = $normalized_src;
                $img['source'] = 'html';
                $merged[] = $img;
            }
        }

        return $merged;
    }

    /**
     * Normaliseer image src voor vergelijking (verwijder size suffixes)
     *
     * @param string $src Image URL
     * @return string Genormaliseerde URL
     */
    private function normalize_image_src($src) {
        // Verwijder size suffixes zoals -150x150, -300x200, etc.
        $normalized = preg_replace('/-\d+x\d+(\.[a-z]+)$/i', '$1', $src);

        // Verwijder query strings
        $normalized = strtok($normalized, '?');

        return strtolower($normalized);
    }

    /**
     * Haal attachment ID op basis van URL
     *
     * @param string $url Image URL
     * @return int|false Attachment ID of false als niet gevonden
     */
    private function get_attachment_id_from_url($url) {
        global $wpdb;

        if (empty($url)) {
            return false;
        }

        // Probeer eerst de ingebouwde WordPress functie
        $attachment_id = attachment_url_to_postid($url);
        if ($attachment_id) {
            return $attachment_id;
        }

        // Als dat niet werkt, probeer zonder size suffix
        $url_without_size = preg_replace('/-\d+x\d+(\.[a-z]+)$/i', '$1', $url);
        if ($url_without_size !== $url) {
            $attachment_id = attachment_url_to_postid($url_without_size);
            if ($attachment_id) {
                return $attachment_id;
            }
        }

        // Laatste poging: zoek op bestandsnaam in database
        $filename = basename($url);
        $filename = preg_replace('/-\d+x\d+(\.[a-z]+)$/i', '$1', $filename);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on wp_postmeta (only the table identifier is interpolated; user value uses prepare() placeholder with esc_like); request-scoped admin read, caching not applicable
        $attachment_id = $wpdb->get_var($wpdb->prepare(
            "SELECT post_id FROM {$wpdb->postmeta}
             WHERE meta_key = '_wp_attached_file'
             AND meta_value LIKE %s
             LIMIT 1",
            '%' . $wpdb->esc_like($filename)
        ));

        return $attachment_id ? intval($attachment_id) : false;
    }

    /**
     * Extraheer links uit content
     *
     * @param string $content HTML content
     * @return array Links opgesplitst in internal en external
     */
    private function extract_links($content) {
        $internal = array();
        $external = array();

        // PHP 8.1+ null-safe check
        if ($content === null || $content === '') {
            return array('internal' => $internal, 'external' => $external);
        }
        $content = (string) $content;

        // Haal de site host op en normaliseer (verwijder www.)
        $site_url = home_url();
        $site_host = wp_parse_url($site_url, PHP_URL_HOST);
        $site_host_normalized = $this->normalize_host($site_host);

        preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/si', $content, $matches, PREG_SET_ORDER);

        foreach ($matches as $match) {
            $url = $match[1];
            $anchor = wp_strip_all_tags($match[2]);

            // Skip lege URLs, anchors, javascript en mailto
            if (empty($url) || strpos($url, '#') === 0 || strpos($url, 'javascript:') === 0 || strpos($url, 'mailto:') === 0 || strpos($url, 'tel:') === 0) {
                continue;
            }

            // Check of het een spam-link is
            $is_spam = $this->is_spam_link($url);

            $link_data = array(
                'url' => $url,
                'anchor' => $anchor,
                'is_spam' => $is_spam,
            );

            // Bepaal of intern of extern
            $is_internal = $this->is_internal_link($url, $site_host_normalized, $site_url);

            if ($is_internal) {
                $internal[] = $link_data;
            } else {
                $external[] = $link_data;
            }
        }

        return array(
            'internal' => $internal,
            'external' => $external,
        );
    }

    /**
     * Check of een URL een interne link is
     *
     * @param string $url De URL om te checken
     * @param string $site_host_normalized Genormaliseerde site host
     * @param string $site_url Volledige site URL
     * @return bool True als interne link
     */
    private function is_internal_link($url, $site_host_normalized, $site_url) {
        // Relatieve URLs zijn altijd intern
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            return true;
        }

        // Protocol-relatieve URLs (//example.com)
        if (strpos($url, '//') === 0) {
            $url = 'https:' . $url;
        }

        // URLs die beginnen met de site URL zijn intern
        if (strpos($url, $site_url) === 0) {
            return true;
        }

        // Parse de URL om de host te krijgen
        $link_host = wp_parse_url($url, PHP_URL_HOST);

        // Geen host = relatieve URL = intern
        if (empty($link_host)) {
            return true;
        }

        // Normaliseer de link host en vergelijk
        $link_host_normalized = $this->normalize_host($link_host);

        return $link_host_normalized === $site_host_normalized;
    }

    /**
     * Normaliseer een hostname voor vergelijking
     * Verwijdert www. prefix en converteert naar lowercase
     *
     * @param string $host Hostname
     * @return string Genormaliseerde hostname
     */
    private function normalize_host($host) {
        $host = strtolower($host);

        // Verwijder www. prefix
        if (strpos($host, 'www.') === 0) {
            $host = substr($host, 4);
        }

        return $host;
    }

    /**
     * Check of een link spam-achtig is
     *
     * @param string $url URL om te checken
     * @return bool True als spam-achtig
     */
    private function is_spam_link($url) {
        $spam_patterns = array(
            'casino',
            'poker',
            'viagra',
            'cialis',
            'payday-loan',
            'cheap-',
            'buy-online',
            'free-money',
        );

        $url_lower = strtolower($url);
        foreach ($spam_patterns as $pattern) {
            if (strpos($url_lower, $pattern) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Extraheer alinea's uit content
     *
     * @param string $content HTML content
     * @return array Alinea's met woordaantal
     */
    private function extract_paragraphs($content) {
        $paragraphs = array();

        // Split op p-tags of dubbele newlines
        preg_match_all('/<p[^>]*>(.*?)<\/p>/si', $content, $matches);

        foreach ($matches[1] as $p) {
            $text = wp_strip_all_tags($p);
            $text = trim($text);
            if (!empty($text)) {
                $word_count = str_word_count($text);
                $paragraphs[] = array(
                    'text' => $text,
                    'word_count' => $word_count,
                );
            }
        }

        return $paragraphs;
    }

    /**
     * Extraheer zinnen uit tekst
     *
     * @param string $text Platte tekst
     * @return array Zinnen met woordaantal
     */
    private function extract_sentences($text) {
        $sentences = array();

        // Split op . ! ?
        $parts = preg_split('/[.!?]+/', $text);

        foreach ($parts as $sentence) {
            $sentence = trim($sentence);
            if (!empty($sentence)) {
                $word_count = str_word_count($sentence);
                $sentences[] = array(
                    'text' => $sentence,
                    'word_count' => $word_count,
                );
            }
        }

        return $sentences;
    }

    /**
     * Voer alle checks uit
     *
     * @return array Resultaten per check
     */
    private function run_all_checks() {
        $results = array();

        // Checks die alleen betekenis hebben met een focus-keyword. Zonder
        // keyword zijn ze niet van toepassing: ze tellen niet als verdiend en
        // niet als haalbaar, in plaats van "gratis" te slagen.
        $keyword_checks = array(
            'h1_has_keyword',
            'keyword_in_intro',
            'keyword_frequency',
            'keyword_density',
            'keyword_in_h2',
            'keyword_variants',
            'alt_has_keyword',
        );
        $has_keyword = ($this->focus_keyword !== '');

        foreach ($this->rules as $category_id => $category) {
            foreach ($category['checks'] as $check_id => $check) {
                $applies = $has_keyword || !in_array($check_id, $keyword_checks, true);

                $method = 'check_' . $check_id;
                if (!$applies) {
                    $passed = false; // niet uitgevoerd; telt nergens in mee
                } elseif (method_exists($this, $method)) {
                    $passed = $this->$method();
                } else {
                    $passed = true; // Standaard pass als methode niet bestaat
                }

                $results[$check_id] = array(
                    'category' => $category_id,
                    'label' => $check['label'],
                    'weight' => $check['weight'],
                    'critical' => $check['critical'],
                    'applies' => $applies,
                    'passed' => $passed,
                    'status' => !$applies ? 'na' : ($passed ? 'success' : ($check['critical'] ? 'error' : 'warning')),
                );
            }
        }

        return $results;
    }

    // ========================================
    // CHECK METHODES - Koppen & Structuur
    // ========================================

    private function check_h1_exists() {
        $h1_count = 0;
        foreach ($this->content_data['headings'] as $h) {
            if ($h['level'] === 1) {
                $h1_count++;
            }
        }
        return $h1_count === 1;
    }

    private function check_h1_has_keyword() {
        if (empty($this->focus_keyword)) {
            return true; // Skip als geen zoekwoord
        }

        foreach ($this->content_data['headings'] as $h) {
            if ($h['level'] === 1) {
                if (TamRank_Meta_Calculator::keyword_exists($h['text'], $this->focus_keyword)) {
                    return true;
                }
            }
        }
        return false;
    }

    private function check_h1_length() {
        foreach ($this->content_data['headings'] as $h) {
            if ($h['level'] === 1) {
                return mb_strlen($h['text']) <= 70;
            }
        }
        return true; // Geen H1 = check niet van toepassing
    }

    private function check_h2_exists() {
        foreach ($this->content_data['headings'] as $h) {
            if ($h['level'] === 2) {
                return true;
            }
        }
        return false;
    }

    private function check_heading_hierarchy() {
        $previous_level = 0;
        foreach ($this->content_data['headings'] as $h) {
            // Check dat we niet meer dan 1 niveau omhoog springen
            if ($previous_level > 0 && $h['level'] > $previous_level + 1) {
                return false;
            }
            $previous_level = $h['level'];
        }
        return true;
    }

    private function check_no_duplicate_headings() {
        $texts = array();
        foreach ($this->content_data['headings'] as $h) {
            $text = strtolower(trim($h['text']));
            if (in_array($text, $texts)) {
                return false;
            }
            $texts[] = $text;
        }
        return true;
    }

    private function check_headings_have_content() {
        // Vereenvoudigde check: we kunnen dit niet perfect doen zonder DOM parsing
        // We gaan ervan uit dat dit OK is als er headings zijn
        return true;
    }

    private function check_no_single_word_headings() {
        foreach ($this->content_data['headings'] as $h) {
            $words = str_word_count($h['text']);
            if ($words <= 1 && !empty(trim($h['text']))) {
                return false;
            }
        }
        return true;
    }

    // ========================================
    // CHECK METHODES - Zoekwoordgebruik
    // ========================================

    private function check_keyword_in_intro() {
        if (empty($this->focus_keyword)) {
            return true;
        }
        return TamRank_Meta_Calculator::keyword_exists($this->content_data['intro_text'], $this->focus_keyword);
    }

    private function check_keyword_frequency() {
        if (empty($this->focus_keyword)) {
            return true;
        }

        $count = TamRank_Meta_Calculator::keyword_count($this->content_data['text_content'], $this->focus_keyword);

        return $count >= 2;
    }

    private function check_keyword_density() {
        if (empty($this->focus_keyword) || $this->content_data['word_count'] === 0) {
            return true;
        }

        $keyword_count = TamRank_Meta_Calculator::keyword_count($this->content_data['text_content'], $this->focus_keyword);

        // Bereken dichtheid (zoekwoord / totaal woorden * 100)
        $keyword_words = str_word_count($this->focus_keyword);
        $density = ($keyword_count * $keyword_words / $this->content_data['word_count']) * 100;

        // Ideaal is 1-2%
        return $density >= 0.5 && $density <= 3;
    }

    private function check_keyword_in_h2() {
        if (empty($this->focus_keyword)) {
            return true;
        }

        foreach ($this->content_data['headings'] as $h) {
            if ($h['level'] === 2) {
                if (TamRank_Meta_Calculator::keyword_exists($h['text'], $this->focus_keyword)) {
                    return true;
                }
            }
        }
        return false;
    }

    private function check_keyword_variants() {
        if (empty($this->focus_keyword)) {
            return true;
        }

        // Zoek naar varianten (meervoud, synoniemen, gerelateerde woorden)
        // Vereenvoudigd: check of de woorden uit het zoekwoord ook los voorkomen
        $keyword_words = explode(' ', $this->focus_keyword);
        $text_lower = strtolower($this->content_data['text_content']);

        $found_variants = 0;
        foreach ($keyword_words as $word) {
            if (strlen($word) > 3) {
                // Check voor varianten met -en, -s, -ing etc.
                $variants = array(
                    $word . 'en',
                    $word . 's',
                    $word . 'ing',
                    preg_replace('/en$/', '', $word),
                    preg_replace('/s$/', '', $word),
                );

                foreach ($variants as $variant) {
                    if ($variant !== $word && TamRank_Meta_Calculator::keyword_exists($text_lower, $variant)) {
                        $found_variants++;
                    }
                }
            }
        }

        return $found_variants > 0;
    }

    // ========================================
    // CHECK METHODES - Leesbaarheid
    // ========================================

    private function check_intro_is_summary() {
        // Check of intro duidelijke samenvatting is
        // Vereenvoudigd: check lengte en aanwezigheid van relevante woorden
        $intro_word_count = str_word_count($this->content_data['intro_snippet']);
        return $intro_word_count >= 30;
    }

    private function check_intro_length() {
        $intro_word_count = str_word_count($this->content_data['intro_snippet']);
        return $intro_word_count >= 50;
    }

    private function check_no_generic_intro() {
        $generic_phrases = array(
            'welkom op',
            'welkom bij',
            'in dit artikel',
            'in deze blog',
            'we gaan het hebben over',
            'vandaag bespreken we',
        );

        $intro_lower = strtolower($this->content_data['intro_text']);

        foreach ($generic_phrases as $phrase) {
            if (strpos($intro_lower, $phrase) !== false) {
                return false;
            }
        }

        return true;
    }

    private function check_paragraph_length() {
        foreach ($this->content_data['paragraphs'] as $p) {
            if ($p['word_count'] > 150) {
                return false;
            }
        }
        return true;
    }

    private function check_sentence_length() {
        if (empty($this->content_data['sentences'])) {
            return true;
        }

        $total_words = 0;
        $sentence_count = count($this->content_data['sentences']);

        foreach ($this->content_data['sentences'] as $s) {
            $total_words += $s['word_count'];
        }

        $avg_length = $sentence_count > 0 ? $total_words / $sentence_count : 0;

        // Gemiddelde zinslengte onder 25 woorden is goed
        return $avg_length <= 25;
    }

    // ========================================
    // CHECK METHODES - Afbeeldingen
    // ========================================

    private function check_has_images() {
        return count($this->content_data['images']) > 0;
    }

    private function check_all_have_alt() {
        if (empty($this->content_data['images'])) {
            return true;
        }

        foreach ($this->content_data['images'] as $img) {
            if (empty($img['alt'])) {
                return false;
            }
        }
        return true;
    }

    private function check_alt_has_keyword() {
        if (empty($this->focus_keyword) || empty($this->content_data['images'])) {
            return true;
        }

        foreach ($this->content_data['images'] as $img) {
            if (!empty($img['alt']) && TamRank_Meta_Calculator::keyword_exists($img['alt'], $this->focus_keyword)) {
                return true;
            }
        }
        return false;
    }

    private function check_no_filename_alts() {
        foreach ($this->content_data['images'] as $img) {
            // Gewapend zoals class-tamrank-content-tab.php:685 het al deed: dit
            // was de enige consument die de sleutel ongewapend las, en de
            // entries komen uit een handvol producenten plus een filterbare
            // array. De producentkant is los hiervan compleet gemaakt.
            if (!empty($img['is_filename_alt'])) {
                return false;
            }
        }
        return true;
    }

    /**
     * Check of alle afbeeldingen geoptimaliseerd zijn (<300KB)
     * Pass als minstens 80% van de afbeeldingen <300KB is
     */
    private function check_images_optimized_size() {
        if (empty($this->content_data['images'])) {
            return true;
        }

        $optimized_count = 0;
        $total_with_size = 0;

        foreach ($this->content_data['images'] as $img) {
            if (!empty($img['file_size']) && $img['file_size'] > 0) {
                $total_with_size++;
                $kb = $img['file_size'] / 1024;
                if ($kb <= 300) {
                    $optimized_count++;
                }
            }
        }

        // Als we geen file sizes hebben, geef benefit of the doubt
        if ($total_with_size === 0) {
            return true;
        }

        // Pass als minstens 80% geoptimaliseerd is
        return ($optimized_count / $total_with_size) >= 0.8;
    }

    /**
     * Check of WebP versies beschikbaar zijn
     * Pass als minstens 50% van de non-WebP afbeeldingen een WebP variant heeft
     */
    private function check_images_have_webp() {
        if (empty($this->content_data['images'])) {
            return true;
        }

        $webp_available = 0;
        $non_webp_images = 0;

        foreach ($this->content_data['images'] as $img) {
            $format = $img['format'] ?? '';

            // Skip als het al een WebP afbeelding is
            if ($format === 'webp') {
                $webp_available++;
                continue;
            }

            // Tel non-WebP images
            if (in_array($format, array('jpg', 'jpeg', 'png', 'gif'))) {
                $non_webp_images++;
                if (!empty($img['has_webp'])) {
                    $webp_available++;
                }
            }
        }

        // Als alle images al WebP zijn, pass
        $total_applicable = $non_webp_images + $webp_available;
        if ($total_applicable === 0) {
            return true;
        }

        // Pass als minstens 50% WebP beschikbaar heeft
        return ($webp_available / count($this->content_data['images'])) >= 0.5;
    }

    /**
     * Check of afbeeldingen niet groter zijn dan nodig (2× render size)
     */
    private function check_images_not_oversized() {
        if (empty($this->content_data['images'])) {
            return true;
        }

        $oversized_count = 0;
        $images_with_data = 0;

        foreach ($this->content_data['images'] as $img) {
            // Alleen controleren als we render width EN original width hebben
            if (!empty($img['render_width']) && !empty($img['original_width'])) {
                $images_with_data++;
                if (!empty($img['is_oversized'])) {
                    $oversized_count++;
                }
            }
        }

        // Als we geen data hebben om te controleren, geef benefit of the doubt
        if ($images_with_data === 0) {
            return true;
        }

        // Pass als minder dan 30% oversized is
        return ($oversized_count / $images_with_data) < 0.3;
    }

    /**
     * Check of lazy loading is ingeschakeld
     * Pass als minstens 70% van de afbeeldingen lazy loading heeft
     */
    private function check_images_have_lazy_loading() {
        if (empty($this->content_data['images'])) {
            return true;
        }

        // WordPress 5.5+ voegt automatisch lazy loading toe, dus vaak zijn alle images lazy loaded
        $lazy_count = 0;

        foreach ($this->content_data['images'] as $img) {
            if (!empty($img['has_lazy_loading'])) {
                $lazy_count++;
            }
        }

        // Pass als minstens 70% lazy loading heeft
        return ($lazy_count / count($this->content_data['images'])) >= 0.7;
    }

    // ========================================
    // CHECK METHODES - Links
    // ========================================

    private function check_has_internal_links() {
        return count($this->content_data['internal_links']) > 0;
    }

    private function check_no_broken_links() {
        // Dit vereist HTTP requests en is te zwaar voor real-time
        // We retourneren standaard true
        return true;
    }

    private function check_no_spam_links() {
        foreach ($this->content_data['external_links'] as $link) {
            if ($link['is_spam']) {
                return false;
            }
        }
        return true;
    }

    // ========================================
    // CHECK METHODES - Contentlengte
    // ========================================

    private function check_min_word_count() {
        return $this->content_data['word_count'] >= 300;
    }

    private function check_ideal_length() {
        // Ideale lengte afhankelijk van type (blog: 1000-2000, pagina: 500-1500)
        // Vereenvoudigd: minstens 500 woorden is goed
        return $this->content_data['word_count'] >= 500;
    }

    // ========================================
    // CHECK METHODES - Technische kwaliteit
    // ========================================

    private function check_no_duplicate_content() {
        // Check voor dubbele alinea's
        $texts = array();
        foreach ($this->content_data['paragraphs'] as $p) {
            $normalized = strtolower(trim($p['text']));
            if (strlen($normalized) > 50) {
                if (in_array($normalized, $texts)) {
                    return false;
                }
                $texts[] = $normalized;
            }
        }
        return true;
    }

    private function check_no_text_walls() {
        foreach ($this->content_data['paragraphs'] as $p) {
            if ($p['word_count'] > 200) {
                return false;
            }
        }
        return true;
    }

    private function check_no_caps_headings() {
        foreach ($this->content_data['headings'] as $h) {
            // Check of de hele kop in hoofdletters is
            if (mb_strlen($h['text']) > 3 && $h['text'] === mb_strtoupper($h['text'])) {
                return false;
            }
        }
        return true;
    }

    // ========================================
    // SCORE BEREKENING
    // ========================================

    /**
     * Bereken totaalscore
     *
     * @param array $results Check resultaten
     * @return int Score 0-100
     */
    private function calculate_score($results) {
        // Weighted average per category, renormalised over the categories that
        // actually apply. Within a category the sub-check weights only set the
        // RELATIVE importance of each check; the category's own label weight
        // (Headings 25, Keywords 25, Readability 15, Images 10, Links 20,
        // Length 5 = 100) sets how much that category contributes to the total.
        // This keeps the category weights the single source of truth for the
        // 100-point score and removes the flat-sum drift where raw sub-check
        // weights let a category contribute more or less than its label (Images
        // counted as 20, Links as 5) and capped the max at 95.
        //
        // N/A checks (run_all_checks marks the keyword checks 'applies' => false
        // when there is no focus keyword) drop out of BOTH the earned and the
        // possible total. When that empties a whole category, its label weight is
        // removed from weight_sum too, so the score renormalises over the
        // remaining categories: the ceiling stays 100 instead of silently sinking
        // to 75 (a keyword-less page is scored on what is measurable, not
        // double-punished).
        $weighted   = 0;
        $weight_sum = 0;

        foreach ($this->rules as $category_id => $category) {
            $earned   = 0;
            $possible = 0;

            foreach ($category['checks'] as $check_id => $check) {
                $r = $results[$check_id] ?? null;
                if (!$r || (isset($r['applies']) && !$r['applies'])) {
                    continue;                       // N/A: uit teller en noemer
                }
                $possible += $r['weight'];
                if ($r['passed']) {
                    $earned += $r['weight'];
                }
            }

            if ($possible === 0) {
                continue;                           // hele categorie N/A => ook het
            }                                       // label-gewicht uit de normalisatie

            $weighted   += ($earned / $possible) * $category['weight'];
            $weight_sum += $category['weight'];
        }

        return $weight_sum > 0 ? (int) round(($weighted / $weight_sum) * 100) : 0;
    }

    /**
     * Verzamel top issues (kritieke + hoge impact)
     *
     * @param array $results Check resultaten
     * @return array Top issues
     */
    private function get_top_issues($results) {
        $issues = array();
        $default_labels = array(
            'h1_exists' => 'Exactly one H1 present',
            'has_internal_links' => 'At least one internal link',
            'min_word_count' => 'At least 300 words'
        );

        foreach ($results as $check_id => $check) {
            if (isset($check['applies']) && !$check['applies']) {
                continue; // N/A-checks zijn geen actiepunten
            }
            if (!$check['passed']) {
                $issues[] = array(
                    'id' => $check_id,
                    'title' => tamrank__('issue_' . $check_id, isset($default_labels[$check_id]) ? $default_labels[$check_id] : $check['label']),
                    'description' => $this->get_issue_description($check_id),
                    'tip' => $this->get_issue_tip($check_id),
                    'type' => $check['critical'] ? 'error' : 'warning',
                    'weight' => $check['weight'],
                    'points' => $check['weight'], // Potentiële punten winst
                );
            }
        }

        if ($this->focus_keyword === '') {
            $issues[] = array(
                'id'          => 'no_focus_keyword',
                'title'       => tamrank__('issue_no_focus_keyword', 'No focus keyword set'),
                'description' => __( 'Set a focus keyword to unlock the 7 keyword checks', 'tam-rank' ),
                'tip'         => __( 'Choose the search term this page should rank for and set it in the TamRank meta box. The keyword checks then count toward your score.', 'tam-rank' ),
                'type'        => 'warning',
                'weight'      => 25, // sorteert bovenaan de warnings; label-gewicht keywords-categorie
                'points'      => 25,
            );
        }

        // Sorteer op kritiek en gewicht
        usort($issues, function($a, $b) {
            if ($a['type'] === 'error' && $b['type'] !== 'error') return -1;
            if ($a['type'] !== 'error' && $b['type'] === 'error') return 1;
            return $b['weight'] - $a['weight'];
        });

        return array_slice($issues, 0, 5);
    }

    /**
     * Geef beschrijving voor issue
     *
     * @param string $check_id Check ID
     * @return string Beschrijving
     */
    private function get_issue_description($check_id) {
        $descriptions = array(
            'h1_exists' => __( 'Add exactly one H1 heading to your page', 'tam-rank' ),
            'h1_has_keyword' => __( 'Add your focus keyword to the H1', 'tam-rank' ),
            'h1_length' => __( 'Shorten your H1 to max 70 characters', 'tam-rank' ),
            'h2_exists' => __( 'Add at least one H2 subheading', 'tam-rank' ),
            'heading_hierarchy' => __( 'Ensure logical heading structure (H1 > H2 > H3)', 'tam-rank' ),
            'no_duplicate_headings' => __( 'Use unique text for each heading', 'tam-rank' ),
            'headings_have_content' => __( 'Add content below all headings', 'tam-rank' ),
            'no_single_word_headings' => __( 'Make headings more descriptive (more than one word)', 'tam-rank' ),
            'keyword_in_intro' => __( 'Use your keyword in the first 150 words', 'tam-rank' ),
            'keyword_frequency' => __( 'Use your keyword at least 2 times in the text', 'tam-rank' ),
            'keyword_density' => __( 'Optimize your keyword density (1-2%)', 'tam-rank' ),
            'keyword_in_h2' => __( 'Add your keyword to at least one H2', 'tam-rank' ),
            'keyword_variants' => __( 'Use variations of your keyword', 'tam-rank' ),
            'intro_is_summary' => __( 'Write a clear summary in your intro', 'tam-rank' ),
            'intro_length' => __( 'Make your intro at least 50 words', 'tam-rank' ),
            'no_generic_intro' => __( 'Avoid generic intro text like "Welcome to..."', 'tam-rank' ),
            'paragraph_length' => __( 'Shorten long paragraphs (max 150 words)', 'tam-rank' ),
            'sentence_length' => __( 'Use shorter sentences for better readability', 'tam-rank' ),
            'has_images' => __( 'Add at least one image', 'tam-rank' ),
            'all_have_alt' => __( 'Add alt text to all images', 'tam-rank' ),
            'alt_has_keyword' => __( 'Use your keyword in at least one alt text', 'tam-rank' ),
            'no_filename_alts' => __( 'Replace filenames in alt text with descriptive text', 'tam-rank' ),
            'images_optimized_size' => __( 'Optimize large images (ideally <300KB)', 'tam-rank' ),
            'images_have_webp' => __( 'Make WebP versions available for faster loading', 'tam-rank' ),
            'images_not_oversized' => __( 'Use images at the right scale (not larger than 2× display size)', 'tam-rank' ),
            'images_have_lazy_loading' => __( 'Enable lazy loading for images', 'tam-rank' ),
            'has_internal_links' => __( 'Add at least one internal link', 'tam-rank' ),
            'no_broken_links' => __( 'Fix or remove broken links', 'tam-rank' ),
            'no_spam_links' => __( 'Remove suspicious external links', 'tam-rank' ),
            'min_word_count' => __( 'Write at least 300 words', 'tam-rank' ),
            'ideal_length' => __( 'Write more content for better ranking', 'tam-rank' ),
            'no_duplicate_content' => __( 'Remove duplicate paragraphs or sentences', 'tam-rank' ),
            'no_text_walls' => __( 'Break up long text blocks with whitespace', 'tam-rank' ),
            'no_caps_headings' => __( 'Avoid headings in all caps', 'tam-rank' ),
        );

        $default = isset($descriptions[$check_id]) ? $descriptions[$check_id] : __( 'Improve this point for a higher score', 'tam-rank' );
        return $default;
    }

    /**
     * Geef concrete tip voor issue
     *
     * @param string $check_id Check ID
     * @return string Concrete tip
     */
    private function get_issue_tip($check_id) {
        $tips = array(
            'h1_exists' => __( 'Go to your page title and make sure it is formatted as H1. In Gutenberg: select the text block and choose "Heading" > "H1" in the toolbar. For multiple H1s: change the extra H1s to H2.', 'tam-rank' ),
            'h1_has_keyword' => __( 'Open your H1 heading and include your focus keyword. Example: if your keyword is "website design", use "Professional website design in your city".', 'tam-rank' ),
            'h1_length' => __( 'Keep your H1 short and powerful. Google shows max 60-70 characters. Move extra information to a subheading or intro text.', 'tam-rank' ),
            'h2_exists' => __( 'Divide your content into sections with H2 headings. Think of: "Why [topic]?", "How does it work?", "Benefits", "FAQ".', 'tam-rank' ),
            'heading_hierarchy' => __( 'Use headings in the correct order: H1 → H2 → H3. Never skip from H1 to H3. Each level is a "subchapter" of the level above.', 'tam-rank' ),
            'no_duplicate_headings' => __( 'Give each heading unique, descriptive text. Instead of "More information" twice, use specific titles like "More about our services" and "Contact us".', 'tam-rank' ),
            'headings_have_content' => __( 'Add at least one paragraph of text below each heading. A heading without content below it is confusing for readers and search engines.', 'tam-rank' ),
            'no_single_word_headings' => __( 'Make your headings descriptive and specific. Instead of "Services" use "Our SEO services for small businesses".', 'tam-rank' ),
            'keyword_in_intro' => __( 'Include your focus keyword naturally in your first paragraph. This helps Google understand what your page is about.', 'tam-rank' ),
            'keyword_frequency' => __( 'Use your keyword spread throughout the text. Too few = Google doesn\'t understand the topic. Too many = spam. Aim for 2-5 times per 1000 words.', 'tam-rank' ),
            'keyword_density' => __( 'Your keyword density is not optimal. Ideal is 1-2%. Count your keyword and divide by total words × 100. Adjust if needed.', 'tam-rank' ),
            'keyword_in_h2' => __( 'Include your focus keyword (or a variant) in at least one H2 heading. This confirms to Google that the entire page is relevant for the keyword.', 'tam-rank' ),
            'keyword_variants' => __( 'Use synonyms and related terms. For "website design" also use: "web development", "professional website", "online presence".', 'tam-rank' ),
            'intro_is_summary' => __( 'Start your page with a powerful summary (50-100 words) that immediately clarifies what the reader can expect. Answer: What? For whom? Why?', 'tam-rank' ),
            'intro_length' => __( 'Expand your introduction to at least 50 words. A good intro draws the reader further into the page and provides context for your content.', 'tam-rank' ),
            'no_generic_intro' => __( 'Replace generic text like "Welcome to our website" with specific, valuable information. Start immediately with what the reader wants to know.', 'tam-rank' ),
            'paragraph_length' => __( 'Break long paragraphs into pieces of 3-4 sentences (max 150 words). Use line breaks and whitespace. This greatly improves readability.', 'tam-rank' ),
            'sentence_length' => __( 'Keep sentences under 20 words. Mix short and longer sentences for a pleasant reading rhythm. Read your text aloud: where do you breathe? That\'s where a period goes.', 'tam-rank' ),
            'has_images' => __( 'Add at least 1 relevant image per 300-500 words. Use screenshots, infographics, product photos or stock photos that support your content.', 'tam-rank' ),
            'all_have_alt' => __( 'Click on each image and fill in the "Alt text" field. Describe what is shown: "Team of web designers at work" instead of "team-photo-1".', 'tam-rank' ),
            'alt_has_keyword' => __( 'Include your focus keyword in the alt text of at least one relevant image. Only do this if it naturally fits what the image shows.', 'tam-rank' ),
            'no_filename_alts' => __( 'Replace filename alt texts (IMG_1234.jpg, photo-2.png) with descriptive text. This also helps visually impaired visitors using screen readers.', 'tam-rank' ),
            'images_optimized_size' => __( 'Compress large images with tools like TinyPNG, ShortPixel or the Squoosh webapp. Aim for max 150KB for normal images and 300KB for hero images. Consider a WordPress plugin like Imagify or EWWW Image Optimizer for automatic compression.', 'tam-rank' ),
            'images_have_webp' => __( 'WebP images are up to 30% smaller than JPEG/PNG with the same quality. Install a plugin like WebP Express, Imagify or ShortPixel to automatically generate WebP versions. Modern browsers will then load the smaller WebP version.', 'tam-rank' ),
            'images_not_oversized' => __( 'Don\'t upload images larger than 2× the display size. An image displayed at 400px wide doesn\'t need to be larger than 800px. Scale images down before upload or let WordPress do this automatically with proper image sizes.', 'tam-rank' ),
            'images_have_lazy_loading' => __( 'WordPress 5.5+ automatically adds loading="lazy" to images. Check that your theme doesn\'t disable this. Lazy loading ensures images only load when they come into view, improving initial load time.', 'tam-rank' ),
            'has_internal_links' => __( 'Link to at least 1 other relevant page on your own website. This helps visitors navigate and strengthens your SEO. Use descriptive anchor text.', 'tam-rank' ),
            'no_broken_links' => __( 'Check if all links still work. Fix 404 errors by updating or removing the link. Broken links harm your SEO and user experience.', 'tam-rank' ),
            'no_spam_links' => __( 'Remove links to suspicious websites. External links should point to trustworthy sources. Add rel="nofollow" to sponsored links.', 'tam-rank' ),
            'min_word_count' => __( 'Write at least 300 words for this page. Short pages rarely rank well. Add valuable information that helps your target audience.', 'tam-rank' ),
            'ideal_length' => __( 'For good rankings, 1000+ words is ideal for informational pages. Add sections: explanation, examples, tips, FAQ, or comparisons.', 'tam-rank' ),
            'no_duplicate_content' => __( 'Remove repeated text blocks. Each paragraph should add unique value. Copy-paste from other pages harms your SEO.', 'tam-rank' ),
            'no_text_walls' => __( 'Use paragraphs of 3-4 sentences, bullet points, subheadings and images to break up your text. Nobody reads a wall of text.', 'tam-rank' ),
            'no_caps_headings' => __( 'Write headings in normal spelling (only first letter capitalized). ALL CAPS looks like shouting and is harder to read.', 'tam-rank' ),
        );

        $default = isset($tips[$check_id]) ? $tips[$check_id] : __( 'Adjust this point to improve your content and achieve a higher score.', 'tam-rank' );
        return $default;
    }

    /**
     * Bouw categorieën op voor UI
     *
     * @param array $results Check resultaten
     * @return array Categorieën met checks
     */
    private function build_categories($results) {
        $categories = array();

        foreach ($this->rules as $category_id => $category) {
            $checks = array();
            $passed = 0;
            $total = 0;

            foreach ($category['checks'] as $check_id => $check_config) {
                if (isset($results[$check_id])) {
                    $result = $results[$check_id];
                    $checks[] = array(
                        'id' => $check_id,
                        'label' => $result['label'],
                        'status' => $result['status'],
                    );
                    if ($result['status'] === 'na') {
                        continue; // grijs tonen, niet in passed/total
                    }
                    $total++;
                    if ($result['passed']) {
                        $passed++;
                    }
                }
            }

            $categories[$category_id] = array(
                'title' => $category['title'],
                'icon' => $category['icon'],
                'checks' => $checks,
                'passed' => $passed,
                'total' => $total,
            );
        }

        return $categories;
    }

    /**
     * Bereken statistieken
     *
     * @param array $results Check resultaten
     * @return array Statistieken
     */
    private function calculate_stats($results) {
        $passed = 0;
        $warnings = 0;
        $errors = 0;
        $not_applicable = 0;

        foreach ($results as $check) {
            if (isset($check['applies']) && !$check['applies']) {
                $not_applicable++;
            } elseif ($check['passed']) {
                $passed++;
            } elseif ($check['status'] === 'error') {
                $errors++;
            } else {
                $warnings++;
            }
        }

        return array(
            'passed' => $passed,
            'warnings' => $warnings,
            'errors' => $errors,
            'total' => count($results) - $not_applicable,
            'not_applicable' => $not_applicable,
        );
    }

    /**
     * Bereken secondary keywords bonus (FREE sinds 19 apr 2026)
     *
     * Max 10 punten bonus voor goed gebruik van secondary keywords
     *
     * @param int $post_id Post ID
     * @param string $content Content HTML
     * @return array Bonus score en details
     */
    private function calculate_secondary_keywords_bonus($post_id, $content) {
        // Default: geen bonus
        $bonus_data = array(
            'score' => 0,
            'max_score' => 10,
            'enabled' => false,
            'is_pro' => false, // FREE feature (since 19 Apr 2026); key kept for legacy response-shape compatibility
            'keywords' => array(),
        );

        // Check if TamRank_Keywords class exists
        if (!class_exists('TamRank_Keywords')) {
            return $bonus_data;
        }

        // Check if secondary keywords are enabled for this post
        $secondary_enabled = get_post_meta($post_id, '_tamrank_secondary_keywords_enabled', true);
        if ($secondary_enabled !== '1') {
            return $bonus_data;
        }

        $bonus_data['enabled'] = true;

        // Get secondary keywords
        $keywords_data = TamRank_Keywords::get_keywords($post_id);
        $secondary_keywords = $keywords_data['secondary'];

        // No keywords? Return 0 bonus
        if (empty($secondary_keywords)) {
            return $bonus_data;
        }

        // Prepare content for analysis (lowercase, strip tags)
        $content_text = strtolower(wp_strip_all_tags($content));
        $headings_text = '';
        foreach ($this->content_data['headings'] as $heading) {
            $headings_text .= ' ' . strtolower($heading['text']);
        }

        $total_bonus = 0;
        $keyword_details = array();

        // Analyze each secondary keyword (max 2.5 points each)
        foreach ($secondary_keywords as $keyword) {
            $keyword_lower = strtolower(trim($keyword));
            if (empty($keyword_lower)) {
                continue;
            }

            $keyword_score = 0;
            $checks = array();

            // Check 1: Keyword appears in content (1 point)
            $appears_in_content = (strpos($content_text, $keyword_lower) !== false);
            if ($appears_in_content) {
                $keyword_score += 1;
                $checks[] = array(
                    'label' => __('Found in content', 'tam-rank'),
                    'status' => 'passed',
                    'points' => 1,
                );
            } else {
                $checks[] = array(
                    'label' => __('Not found in content', 'tam-rank'),
                    'status' => 'error',
                    'points' => 0,
                );
            }

            // Check 2: Keyword appears in heading (1 point)
            $appears_in_heading = (strpos($headings_text, $keyword_lower) !== false);
            if ($appears_in_heading) {
                $keyword_score += 1;
                $checks[] = array(
                    'label' => __('Found in heading', 'tam-rank'),
                    'status' => 'passed',
                    'points' => 1,
                );
            } else {
                $checks[] = array(
                    'label' => __('Not in heading', 'tam-rank'),
                    'status' => 'warning',
                    'points' => 0,
                );
            }

            // Check 3: Keyword density is good (0.5 point)
            // Ideal: 0.5% - 3% of content. Density is measured the same way as the
            // primary keyword (check_keyword_density): occurrences x phrase word
            // count / total words, so multi-word secondary keywords are scored on
            // the same basis as the primary keyword.
            if ($this->content_data['word_count'] > 0) {
                $keyword_count = TamRank_Meta_Calculator::keyword_count($content_text, $keyword_lower);
                $keyword_words = str_word_count($keyword_lower);
                $density = ($keyword_count * $keyword_words / $this->content_data['word_count']) * 100;

                if ($density >= 0.5 && $density <= 3.0) {
                    $keyword_score += 0.5;
                    $checks[] = array(
                        'label' => sprintf(tamrank__('Keyword density good (%.1f%%)'), $density),
                        'status' => 'passed',
                        'points' => 0.5,
                    );
                } elseif ($density > 0 && $density < 0.5) {
                    $checks[] = array(
                        'label' => sprintf(tamrank__('Keyword density low (%.1f%%)'), $density),
                        'status' => 'warning',
                        'points' => 0,
                    );
                } elseif ($density > 3.0) {
                    $checks[] = array(
                        'label' => sprintf(tamrank__('Keyword density too high (%.1f%%)'), $density),
                        'status' => 'warning',
                        'points' => 0,
                    );
                }
            }

            $total_bonus += $keyword_score;

            $keyword_details[] = array(
                'keyword' => $keyword,
                'score' => $keyword_score,
                'max_score' => 2.5,
                'checks' => $checks,
            );
        }

        $bonus_data['score'] = min($total_bonus, 10); // Cap at 10 points
        $bonus_data['keywords'] = $keyword_details;

        return $bonus_data;
    }
}
