<?php
/**
 * Tam Rank™ SEO Audit Admin Page
 */

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

class TamRank_Audit_Page
{

    /**
     * TamRank Total Score instance
     *
     * @var TamRank_Total_Score
     */
    private static $total_score_calculator = null;

    /**
     * Seconds without a heartbeat after which a scan lock is considered stale.
     *
     * A live scan refreshes tam_rank_audit_scan_start_time on every batch
     * (batches run every ~0.5s, max ~25s each), so a lock older than this means
     * the scan was abandoned (tab closed, fatal mid-batch). Used to avoid a frozen
     * "0 / 0" progress banner + endless conflict-retry loop after an interrupted scan.
     */
    const SCAN_HEARTBEAT_TIMEOUT = 60;

    /**
     * Is a bulk audit genuinely running right now?
     *
     * Returns true only when the lock is set AND its heartbeat is fresh. A stale
     * lock (abandoned scan) is cleared here so the UI self-heals on the next load.
     *
     * @return bool
     */
    private static function is_scan_alive(): bool
    {
        if (!get_option('tam_rank_audit_scan_running')) {
            return false;
        }
        $started = (int) get_option('tam_rank_audit_scan_start_time', 0);
        if ($started > 0 && (time() - $started) < self::SCAN_HEARTBEAT_TIMEOUT) {
            return true;
        }
        // Stale lock from an interrupted scan — clear it.
        delete_option('tam_rank_audit_scan_running');
        delete_option('tam_rank_audit_scan_start_time');
        return false;
    }

    public function __construct()
    {

        // This page is already added in settings-page.php as a submenu
        // We just need to handle the audit functionality here
        add_action('wp_ajax_tam_rank_bulk_audit', array($this, 'ajax_bulk_audit'));
        add_action('wp_ajax_tam_rank_reset_scan_lock', array($this, 'ajax_reset_scan_lock'));
        add_action('wp_ajax_tam_rank_run_stats_scan', array($this, 'ajax_run_stats_scan'));
        // CSV export is a FREE feature (the audit data is the user's own data).
        add_action('wp_ajax_tam_rank_export_audit_results', array($this, 'ajax_export_audit_results'));

        // Detail view, Bulk Meta editor + SEO report stay in the tam-rank-pro
        // add-on (cluster-13 strip). PRO registers their AJAX handlers
        // (tam_rank_get_audit_details / _generate_single_meta / _bulk_save_meta /
        // _generate_seo_report) on the tamrank_loaded handshake, gated on seo_audit.

        // Audit exclusion AJAX handlers
        add_action('wp_ajax_tam_rank_search_posts_for_exclusion', array($this, 'ajax_search_posts_for_exclusion'));
        add_action('wp_ajax_tam_rank_save_audit_exclusions', array($this, 'ajax_save_audit_exclusions'));
    }

    /**
     * Neutralize spreadsheet formula injection: a cell value starting with
     * =, +, -, @, tab or CR can be executed as a formula by Excel/Sheets.
     * Prefix such values with a single quote so they render as literal text.
     *
     * @param mixed $value Cell value.
     * @return string Safe cell value.
     */
    private function csv_safe($value) {
        $value = (string) $value;
        if ($value !== '' && in_array($value[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
            return "'" . $value;
        }
        return $value;
    }

    /**
     * Build a single RFC 4180 CSV line from an array of fields.
     *
     * Hand-built instead of fputcsv() so the export needs no PHP file handles
     * (php://temp) — Plugin Check flags fopen/fclose as filesystem operations,
     * and this export only ever builds an in-memory string. Fields are quoted
     * only when they contain a comma, quote, CR or LF; inner quotes doubled.
     *
     * @param array $fields Row values (already passed through csv_safe()).
     * @return string CSV line without trailing newline.
     */
    private function csv_row(array $fields) {
        $cells = array();
        foreach ($fields as $field) {
            $field = (string) $field;
            if (preg_match('/[",\r\n]/', $field)) {
                $field = '"' . str_replace('"', '""', $field) . '"';
            }
            $cells[] = $field;
        }
        return implode(',', $cells);
    }

    /**
     * AJAX: export all audit results as a CSV string (FREE feature).
     *
     * Frontend (doExport) fetches this via AJAX and turns the returned string
     * into a client-side download, rather than streaming a file.
     */
    public function ajax_export_audit_results()
    {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        $type = isset($_REQUEST['type']) ? sanitize_text_field(wp_unslash($_REQUEST['type'])) : 'all';

        $rows = array();

        // CSV header row
        $rows[] = $this->csv_row(array(
            'ID',
            tamrank__('title', 'Title'),
            tamrank__('type', 'Type'),
            'URL',
            tamrank__('seo_score', 'SEO Score'),
            tamrank__('meta_title', 'Meta Title'),
            tamrank__('meta_title_length', 'Meta Title Length'),
            tamrank__('meta_description', 'Meta Description'),
            tamrank__('meta_description_length', 'Meta Description Length'),
            tamrank__('focus_keyword', 'Focus Keyword'),
            'Noindex',
            'Nofollow',
            tamrank__('last_audit', 'Last Audit'),
            tamrank__('status', 'Status')
        ));

        // Get data based on type
        if ($type === 'posts') {
            $data = self::get_audit_data(array('post'));
        } elseif ($type === 'pages') {
            $data = self::get_audit_data(array('page'));
        } else {
            $enabled_types = TamRank_Content_Type_Config::get_enabled_audit_slugs();
            if (empty($enabled_types)) {
                $enabled_types = array('post', 'page');
            }
            $data = self::get_audit_data($enabled_types);
        }

        foreach ($data as $item) {
            // Stored total score (written by Score Service); null if never scored.
            $score = (isset($item->audit_score) && $item->audit_score !== '') ? (int) $item->audit_score : null;
            $last_audit = get_post_meta($item->ID, '_tam_rank_last_audit', true);
            $title_len = $item->meta_title ? mb_strlen($item->meta_title) : 0;
            $desc_len  = $item->meta_description ? mb_strlen($item->meta_description) : 0;

            if ($score === null) {
                $status = tamrank__('not_analyzed', 'Not analyzed');
            } elseif ($score >= 80) {
                $status = tamrank__('excellent', 'Excellent');
            } elseif ($score >= 60) {
                $status = tamrank__('good', 'Good');
            } elseif ($score >= 40) {
                $status = tamrank__('needs_attention', 'Needs Attention');
            } else {
                $status = tamrank__('needs_improvement', 'Needs Improvement');
            }

            $rows[] = $this->csv_row(array(
                $item->ID,
                $this->csv_safe($item->post_title),
                $item->post_type,
                $this->csv_safe(get_permalink($item->ID)),
                $score === null ? tamrank__('not_analyzed', 'Not analyzed') : $score,
                $this->csv_safe($item->meta_title ?: tamrank__('not_set', 'Not set')),
                $title_len,
                $this->csv_safe($item->meta_description ?: tamrank__('not_set', 'Not set')),
                $desc_len,
                $this->csv_safe($item->focus_keyword ?: tamrank__('not_set', 'Not set')),
                get_post_meta($item->ID, '_tam_rank_noindex', true) ? tamrank__('yes', 'Yes') : tamrank__('no', 'No'),
                get_post_meta($item->ID, '_tam_rank_nofollow', true) ? tamrank__('yes', 'Yes') : tamrank__('no', 'No'),
                $last_audit ?: tamrank__('never', 'Never'),
                $status
            ));
        }

        // UTF-8 BOM so Excel renders accented characters correctly.
        $csv = "\xEF\xBB\xBF" . implode("\r\n", $rows) . "\r\n";

        wp_send_json_success(array(
            'csv'      => $csv,
            'filename' => 'tamrank-audit-' . $type . '-' . gmdate('Y-m-d') . '.csv',
        ));
    }

    /**
     * AJAX handler: search posts by title for exclusion list.
     */
    public function ajax_search_posts_for_exclusion()
    {
        check_ajax_referer('tam_rank_nonce', '_ajax_nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => 'Unauthorized'));
        }

        global $wpdb;

        $search  = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
        $in_clause = self::build_post_type_in_clause();

        if ($search !== '') {
            $like    = '%' . $wpdb->esc_like($search) . '%';
            // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the {$wpdb->posts} table identifier and the runtime-built %s,%s placeholder string are interpolated; user values (LIKE term, post-type slugs) use prepare() placeholders; admin-context read on core tables, caching not applicable.
            $results = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT ID, post_title, post_type
                     FROM {$wpdb->posts}
                     WHERE post_title LIKE %s
                       AND post_status = 'publish'
                       AND post_type IN ({$in_clause['placeholders']})
                     ORDER BY post_title ASC
                     LIMIT 20",
                    array_merge(array($like), $in_clause['slugs'])
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter
        } else {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the {$wpdb->posts} table identifier and the runtime-built %s,%s placeholder string are interpolated; post-type slugs use prepare() placeholders; admin-context read on core tables, caching not applicable.
            $results = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT ID, post_title, post_type
                     FROM {$wpdb->posts}
                     WHERE post_status = 'publish'
                       AND post_type IN ({$in_clause['placeholders']})
                     ORDER BY post_date DESC
                     LIMIT 20",
                    ...$in_clause['slugs']
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter
        }

        $data = array();
        foreach ($results as $row) {
            $data[] = array(
                'id'    => intval($row->ID),
                'title' => esc_html($row->post_title),
                'type'  => esc_html($row->post_type),
            );
        }

        wp_send_json_success($data);
    }

    /**
     * AJAX handler: save audit exclusion settings.
     */
    public function ajax_save_audit_exclusions()
    {
        check_ajax_referer('tam_rank_nonce', '_ajax_nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => 'Unauthorized'));
        }

        // Sanitize excluded post IDs
        $raw_ids       = isset($_POST['excluded_posts']) ? sanitize_text_field(wp_unslash($_POST['excluded_posts'])) : '[]';
        $decoded_ids   = json_decode($raw_ids, true);
        $excluded_ids  = is_array($decoded_ids) ? array_map('intval', $decoded_ids) : array();

        // Validate posts exist
        $excluded_ids = array_filter($excluded_ids, function ($id) {
            return $id > 0 && get_post($id) !== null;
        });
        $excluded_ids = array_values($excluded_ids);

        // Save excluded IDs
        update_option('tam_rank_audit_excluded_posts', $excluded_ids);

        // Save noindex toggle + excluded taxonomies into general settings
        $exclude_noindex  = isset($_POST['exclude_noindex']) && $_POST['exclude_noindex'] === '1';
        $raw_taxonomies   = isset($_POST['excluded_taxonomies']) ? sanitize_text_field(wp_unslash($_POST['excluded_taxonomies'])) : '[]';
        $decoded_taxonomies = json_decode($raw_taxonomies, true);
        $excluded_taxonomies = is_array($decoded_taxonomies) ? array_map('sanitize_key', $decoded_taxonomies) : array();

        $general_settings = tam_rank_cached_option('tam_rank_general_settings', array());
        $general_settings['audit_exclude_noindex'] = $exclude_noindex;
        $general_settings['audit_excluded_taxonomies'] = $excluded_taxonomies;
        update_option('tam_rank_general_settings', $general_settings);

        // Invalidate audit stats cache so next load reflects new exclusions
        delete_option('tam_rank_audit_overall_stats');

        wp_send_json_success(array('message' => 'saved'));
    }


    /**
     * Get TamRank Total Score calculator instance
     *
     * @return TamRank_Total_Score
     */
    public static function get_total_score_calculator()
    {
        if (null === self::$total_score_calculator) {
            if (!class_exists('TamRank_Total_Score')) {
                require_once dirname(__FILE__) . '/content/class-tamrank-total-score.php';
            }
            self::$total_score_calculator = TamRank_Total_Score::get_instance();
        }
        return self::$total_score_calculator;
    }

    /**
     * Get CSS modifier class based on score threshold
     */
    public static function get_score_modifier($score)
    {
        if ($score >= 80) return 'green';
        if ($score >= 60) return 'teal';
        if ($score >= 40) return 'amber';
        return 'red';
    }

    /**
     * Get human-readable score label
     */
    private static function get_score_label($score)
    {
        if ($score >= 80) return tamrank__('score_label_excellent', 'Excellent');
        if ($score >= 60) return tamrank__('score_label_good', 'Good');
        if ($score >= 40) return tamrank__('score_label_needs_improvement', 'Needs improvement');
        return tamrank__('score_label_poor', 'Poor');
    }

    /**
     * Render audit page content (called from settings-page.php)
     * Rebuilt with tr-* CSS classes — no inline styles for colors.
     */
    public static function render_audit_page()
    {
        $is_premium = tam_rank_license_active();

        // Exclusion settings (needed in toolbar + JSON block)
        $general_settings_rp = tam_rank_cached_option('tam_rank_general_settings', array());
        $exclude_noindex_rp  = isset($general_settings_rp['audit_exclude_noindex']) ? (bool) $general_settings_rp['audit_exclude_noindex'] : true;
        $excluded_ids_rp     = get_option('tam_rank_audit_excluded_posts', array());
        $excluded_ids_rp     = is_array($excluded_ids_rp) ? array_map('intval', $excluded_ids_rp) : array();
        $excluded_taxonomies_rp = isset($general_settings_rp['audit_excluded_taxonomies']) ? (array) $general_settings_rp['audit_excluded_taxonomies'] : array();

        // Get all public taxonomies for exclusion UI
        $all_public_taxonomies = get_taxonomies(array('public' => true), 'objects');
        $audit_taxonomies = array();
        $internal_tax = array('post_format', 'nav_menu', 'link_category', 'wp_theme', 'wp_template_part_area', 'post_tag');

        // First pass: collect candidates + count duplicate labels so we can
        // disambiguate (e.g. WP 'category' and a CPT's taxonomy both labelled
        // "Categories") by appending the linked post type(s).
        $tax_candidates = array();
        $label_counts   = array();
        foreach ($all_public_taxonomies as $tax_slug => $tax_obj) {
            if (in_array($tax_slug, $internal_tax, true)) continue;
            $tax_candidates[$tax_slug] = $tax_obj;
            $label_counts[$tax_obj->label] = ($label_counts[$tax_obj->label] ?? 0) + 1;
        }
        foreach ($tax_candidates as $tax_slug => $tax_obj) {
            $label = $tax_obj->label;
            if (($label_counts[$label] ?? 0) > 1) {
                $pt_labels = array();
                foreach ((array) $tax_obj->object_type as $pt) {
                    $pto = get_post_type_object($pt);
                    if ($pto && !empty($pto->labels->name)) {
                        $pt_labels[] = $pto->labels->name;
                    }
                }
                $label .= ' (' . (!empty($pt_labels) ? implode(', ', $pt_labels) : $tax_slug) . ')';
            }
            $audit_taxonomies[$tax_slug] = $label;
        }

        $enabled_types = TamRank_Content_Type_Config::get_enabled_audit_slugs();
        if (empty($enabled_types)) {
            $enabled_types = array('post', 'page');
        }
        $all_items = self::get_audit_data($enabled_types);
        $term_items = self::get_terms_audit_data();
        $all_items = array_merge($all_items, $term_items);

        // Prime the post-meta cache in one query for every post row. The render
        // loop below calls per-post meta readers (e.g. the PRO PageSpeed score via
        // TamRank_PageSpeed_Queue::get_score()), which otherwise trigger WordPress'
        // lazy per-post update_meta_cache() — an N+1 that grows with the audited
        // post count (~200 extra queries at 1k posts with PRO active). One bulk
        // prime collapses that to a single query; harmless no-op for FREE-only.
        $prime_ids = array();
        foreach ($all_items as $prime_item) {
            if (is_object($prime_item) && empty($prime_item->_tr_is_term) && !empty($prime_item->ID)) {
                $prime_ids[] = (int) $prime_item->ID;
            }
        }
        if (!empty($prime_ids)) {
            update_meta_cache('post', $prime_ids);
        }

        $overall_stats = self::get_overall_stats();
        $last_scan = get_option('tam_rank_last_scan', false);
        // Only treat the scan as running when its heartbeat is fresh; a stale lock
        // (abandoned scan) is cleared so we don't render a frozen "0 / 0" banner
        // and auto-resume into an endless conflict-retry loop.
        $scan_running = self::is_scan_alive();

        $avg_score = round($overall_stats['average_score']);
        $total_pages = max(1, $overall_stats['total_pages']);
        ?>
        <div class="wrap tr-page-wrap tr-page-wrap--narrow">
                <h1 class="tr-audit-title"><?php tamrank_e('seo_audit', 'SEO Audit'); ?></h1>
                <p class="tr-audit-subtitle">
                    <?php echo esc_html(sprintf(tamrank__('pages_analyzed_of', '%d pages analyzed of %d total'), $overall_stats['audited_pages'], $overall_stats['total_pages'])); ?>
                </p>

                <!-- Tab Navigation -->
                <div class="tr-pill-nav" role="tablist">
                    <button class="tr-pill is-active" data-tab="overview" role="tab"><?php tamrank_e('overview', 'Overview'); ?></button>
                    <?php
                    /**
                     * Bulk Meta tab pill (PRO). The tam-rank-pro add-on injects the
                     * pill here when seo_audit is licensed; FREE shows Overview +
                     * Tools only.
                     */
                    do_action('tamrank_audit_render_tab_pills');
                    ?>
                    <button class="tr-pill" data-tab="orphans" role="tab"><?php tamrank_e('orphan_pages', 'Orphan pages'); ?></button>
                    <button class="tr-pill" data-tab="tools" role="tab"><?php tamrank_e('tools', 'Tools'); ?></button>
                </div>

                <!-- ==================== TAB 1: OVERVIEW ==================== -->
                <div id="tab-overview" class="tr-tab-panel is-active">

                    <!-- Progress Banner (hidden until scan starts) -->
                    <div class="tr-progress-banner" id="tr-progress-banner"<?php if (!$scan_running): ?> style="display: none;"<?php endif; ?>>
                        <div class="tr-progress-banner__label">
                            <svg class="tr-icon-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-dashoffset="12"/></svg>
                            <?php tamrank_e('scan_in_progress', 'Scan in progress...'); ?>
                        </div>
                        <div class="tr-progress-row">
                            <div class="tr-progress-bar-wrap">
                                <div class="tr-progress-bar-fill" id="tr-progress-fill" style="width: 0%"></div>
                            </div>
                            <span class="tr-progress-count" id="tr-progress-count">0 / 0</span>
                        </div>
                        <button type="button" class="tr-btn tr-btn--ghost tr-btn--sm" id="tr-cancel-scan" style="display: none; margin-top: 8px;">
                            <?php tamrank_e('cancel_scan', 'Cancel'); ?>
                        </button>
                    </div>

                    <!-- Scan Banner (idle) -->
                    <div class="tr-scan-banner" id="tr-scan-banner"<?php if ($scan_running): ?> style="display: none;"<?php endif; ?>>
                        <div>
                            <span class="tr-scan-banner__label"><?php tamrank_e('last_seo_scan', 'Last SEO Scan'); ?></span>
                            <?php if ($last_scan): ?>
                                <?php $scan_time = strtotime($last_scan); ?>
                                <span class="tr-scan-banner__meta">
                                    <?php echo esc_html(gmdate('d-m-Y', $scan_time) . ' ' . sprintf(tamrank__('time_ago', '%s ago'), tamrank_human_time_diff($scan_time, current_time('timestamp')))); ?>
                                </span>
                                <span class="tr-scan-banner__meta">
                                    <?php echo esc_html(sprintf(tamrank__('pages_analyzed_of', '%d pages analyzed of %d total'), $overall_stats['total_pages'], $overall_stats['total_pages'])); ?>
                                </span>
                            <?php else: ?>
                                <span class="tr-scan-banner__meta"><?php tamrank_e('no_scan_yet', 'No scan performed yet'); ?></span>
                            <?php endif; ?>
                        </div>
                        <button type="button" class="tr-btn tr-btn--primary tr-btn--md" id="tr-start-scan">
                            <?php tamrank_e('run_new_scan', 'Run New Scan'); ?> &rarr;
                        </button>
                    </div>

                    <!-- Stats Bar -->
                    <div class="tr-stats-bar">
                        <div class="tr-stat-card">
                            <p class="tr-stat-card__label"><?php tamrank_e('average_score', 'Average Score'); ?></p>
                            <p class="tr-stat-card__value tr-stat-card__value--<?php echo esc_attr(self::get_score_modifier($avg_score)); ?>">
                                <span class="tr-stat-num"><?php echo esc_html($avg_score); ?></span>%
                                <span class="tr-stat-card__status tr-stat-card__status--<?php echo esc_attr(self::get_score_modifier($avg_score)); ?>"><?php echo esc_html(self::get_score_label($avg_score)); ?></span>
                            </p>
                            <div class="tr-score-bar-wrap">
                                <div class="tr-score-bar-fill" style="width: <?php echo esc_attr($avg_score); ?>%"></div>
                            </div>
                            <p class="tr-stat-card__desc">Meta (30%) + Content (70%)</p>
                        </div>

                        <div class="tr-stat-card">
                            <p class="tr-stat-card__label"><?php tamrank_e('excellent', 'Excellent'); ?></p>
                            <p class="tr-stat-card__value tr-stat-card__value--green">
                                <span class="tr-stat-num"><?php echo esc_html($overall_stats['excellent_count']); ?></span>
                                <?php if ($overall_stats['excellent_count'] > 0): ?>
                                    <span class="tr-stat-card__check">&#10003;</span>
                                <?php endif; ?>
                            </p>
                            <p class="tr-stat-card__desc">score 80+</p>
                        </div>

                        <div class="tr-stat-card">
                            <p class="tr-stat-card__label"><?php tamrank_e('good', 'Good'); ?></p>
                            <p class="tr-stat-card__value tr-stat-card__value--teal"><span class="tr-stat-num"><?php echo esc_html($overall_stats['good_count']); ?></span></p>
                            <p class="tr-stat-card__desc">score 60–79</p>
                        </div>

                        <div class="tr-stat-card">
                            <p class="tr-stat-card__label"><?php tamrank_e('needs_attention', 'Needs Attention'); ?></p>
                            <p class="tr-stat-card__value tr-stat-card__value--amber"><span class="tr-stat-num"><?php echo esc_html($overall_stats['poor_count']); ?></span></p>
                            <p class="tr-stat-card__desc"><?php tamrank_e('score_below_60', 'score below 60'); ?></p>
                        </div>
                    </div>

                    <!-- Problems Grid -->
                    <div class="tr-problems-section">
                        <h3 class="tr-problems-title">
                            <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 9V13M12 17H12.01M5.07 19H18.93C20.47 19 21.43 17.33 20.66 16L13.73 4C12.96 2.67 11.04 2.67 10.27 4L3.34 16C2.57 17.33 3.53 19 5.07 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                            <?php tamrank_e('most_common_issues', 'Most Common Issues'); ?>
                        </h3>
                        <div class="tr-problems-grid">
                            <?php if (!empty($overall_stats['common_issues'])): ?>
                                <?php foreach (array_slice($overall_stats['common_issues'], 0, 3) as $issue): ?>
                                    <div class="tr-problem-item">
                                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 9V13M12 17H12.01M5.07 19H18.93C20.47 19 21.43 17.33 20.66 16L13.73 4C12.96 2.67 11.04 2.67 10.27 4L3.34 16C2.57 17.33 3.53 19 5.07 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                                        <span class="tr-problem-item__text"><?php echo esc_html($issue); ?></span>
                                    </div>
                                <?php endforeach; ?>
                            <?php else: ?>
                                <?php // No issues: render the "all clear" message here in the view only.
                                      // The emoji lives in this output string and is never persisted. ?>
                                <div class="tr-problem-item">
                                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 9V13M12 17H12.01M5.07 19H18.93C20.47 19 21.43 17.33 20.66 16L13.73 4C12.96 2.67 11.04 2.67 10.27 4L3.34 16C2.57 17.33 3.53 19 5.07 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                                    <span class="tr-problem-item__text"><?php echo esc_html(tamrank__('no_major_seo_issues', 'No major SEO issues found! 🎉')); ?></span>
                                </div>
                            <?php endif; ?>
                        </div>
                    </div>

                    <!-- Combined Audit Table -->
                    <div class="tr-table-card">
                        <?php
                        // Calculate excluded count for badge
                        $excluded_badge_count = count($excluded_ids_rp);
                        if ($exclude_noindex_rp) {
                            global $wpdb;
                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the {$wpdb->postmeta} table identifier is interpolated; no user values in query; admin-context read, caching not applicable.
                            $noindex_count = (int) $wpdb->get_var(
                                "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}
                                 WHERE meta_key = '_tam_rank_noindex' AND meta_value = '1'"
                            );
                            $excluded_badge_count += $noindex_count;
                        }

                        // Only surface the content-type filter when it is useful:
                        // a pill per type that actually has items, and only when
                        // there are at least two distinct types to choose between
                        // (a single-type site gets no redundant "All / Pages" bar).
                        $distinct_types = array();
                        foreach ($all_items as $present_probe) {
                            if (!empty($present_probe->_tr_is_term)) {
                                $distinct_types['taxonomy'] = true;
                            } elseif (isset($present_probe->_tr_type)) {
                                $distinct_types[$present_probe->_tr_type] = true;
                            }
                        }
                        $present_types = array(
                            'post'     => !empty($distinct_types['post']),
                            'page'     => !empty($distinct_types['page']),
                            'taxonomy' => !empty($distinct_types['taxonomy']),
                        );
                        $show_filter_bar = count($distinct_types) >= 2
                            && ($present_types['post'] || $present_types['page'] || $present_types['taxonomy']);
                        ?>
                        <div class="tr-table-toolbar">
                            <div class="tr-filter-pills">
                                <button class="tr-pill is-active" data-filter="all"><?php tamrank_e('all', 'All'); ?></button>
                                <?php if ($show_filter_bar): ?>
                                <?php if ($present_types['post']): ?>
                                <button class="tr-pill" data-filter="post"><?php tamrank_e('posts', 'Posts'); ?></button>
                                <?php endif; ?>
                                <?php if ($present_types['page']): ?>
                                <?php /* Eigen sleutel, geen 'pages': die staat drie keer in elk taalblok
                                         van class-tamrank-i18n.php en de laatste definitie (de
                                         PageSpeed-sectie, kleine letter voor "0/0 pagina's") wint stil.
                                         Daardoor stond deze chip in kleine letters naast Berichten en
                                         Taxonomieen. */ ?>
                                <button class="tr-pill" data-filter="page"><?php tamrank_e('filter_pages', 'Pages'); ?></button>
                                <?php endif; ?>
                                <?php if ($present_types['taxonomy']): ?>
                                <button class="tr-pill" data-filter="taxonomy"><?php tamrank_e('taxonomies', 'Taxonomies'); ?></button>
                                <?php endif; ?>
                                <?php endif; ?>
                            </div>
                            <div class="tr-table-toolbar__actions">
                                <button type="button" class="tr-btn tr-btn--ghost tr-btn--sm" id="tr-exclusion-btn" data-tr-tooltip="<?php esc_attr_e('Manage exclusions', 'tam-rank'); ?>">
                                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" stroke="currentColor" stroke-width="2"/><path d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z" stroke="currentColor" stroke-width="2"/></svg>
                                    <?php if ($excluded_badge_count > 0): ?>
                                        <span class="tr-exclusion-badge"><?php echo esc_html($excluded_badge_count); ?> <?php tamrank_e('pages_excluded', 'excluded'); ?></span>
                                    <?php endif; ?>
                                </button>
                                <!-- Export button (FREE): CSV download of audit results. -->
                                <button type="button" class="tr-btn tr-btn--ghost tr-btn--sm" id="tr-export-btn">
                                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M21 15V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V15M7 10L12 15M12 15L17 10M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                                    <?php tamrank_e('export', 'Export'); ?>
                                </button>
                            </div>
                        </div>

                        <!-- Collapsible exclusion panel -->
                        <div class="tr-exclusion-panel" id="tr-exclusion-panel" style="display:none;">
                            <div class="tr-exclusion-panel__header">
                                <h4><?php tamrank_e('exclusion_settings', 'Exclusion settings'); ?></h4>
                            </div>
                            <div class="tr-exclusion-panel__body">
                                <p class="tr-exclusion-panel__desc"><?php tamrank_e('exclusion_intro', "Excluded content does not count towards the audit score or the average."); ?></p>

                                <label class="tr-exclusion-toggle">
                                    <input type="checkbox" id="tr-exclude-noindex" class="tr-check" <?php checked($exclude_noindex_rp); ?>>
                                    <span class="tr-check-box"></span>
                                    <span class="tr-exclusion-toggle__text">
                                        <span class="tr-exclusion-toggle__title"><?php tamrank_e('auto_exclude_noindex', "Automatically exclude noindex pages"); ?></span>
                                        <span class="tr-exclusion-toggle__hint"><?php tamrank_e('auto_exclude_noindex_hint', "Pages with a noindex tag are skipped."); ?></span>
                                    </span>
                                </label>

                                <?php if (!empty($audit_taxonomies)) : ?>
                                <div class="tr-exclusion-section">
                                    <p class="tr-exclusion-section__label"><?php tamrank_e('exclude_taxonomies', 'Exclude taxonomies from audit'); ?></p>
                                    <div class="tr-exclusion-grid">
                                        <?php foreach ($audit_taxonomies as $tax_slug => $tax_label) :
                                            $is_excluded = in_array($tax_slug, $excluded_taxonomies_rp, true);
                                        ?>
                                        <label class="tr-exclusion-check">
                                            <input type="checkbox" class="tr-exclude-taxonomy tr-check" data-taxonomy="<?php echo esc_attr($tax_slug); ?>" <?php checked($is_excluded); ?>>
                                            <span class="tr-check-box"></span>
                                            <span><?php echo esc_html($tax_label); ?></span>
                                        </label>
                                        <?php endforeach; ?>
                                    </div>
                                </div>
                                <?php endif; ?>

                                <div class="tr-exclusion-section">
                                    <p class="tr-exclusion-section__label"><?php tamrank_e('exclude_specific_pages', "Exclude specific pages"); ?></p>
                                    <div class="tr-exclusion-search">
                                        <input type="text" id="tr-exclusion-search-input" class="tr-input" placeholder="<?php echo esc_attr(tamrank__('search_pages', "Search pages...")); ?>">
                                    </div>
                                    <div class="tr-exclusion-list" id="tr-exclusion-list">
                                        <!-- Populated via AJAX -->
                                    </div>
                                </div>

                                <div class="tr-exclusion-panel__footer">
                                    <button type="button" class="tr-btn tr-btn--ghost tr-btn--sm" id="tr-cancel-exclusions">
                                        <?php tamrank_e('cancel', 'Cancel'); ?>
                                    </button>
                                    <button type="button" class="tr-btn tr-btn--primary tr-btn--sm" id="tr-save-exclusions">
                                        <?php tamrank_e('save_exclusions', 'Save'); ?>
                                    </button>
                                </div>
                            </div>
                        </div>

                        <?php if (!empty($all_items)): ?>
                            <table class="tr-audit-table tr-audit-table--fixed">
                                <colgroup>
                                    <col class="tr-col-title">
                                    <col class="tr-col-score">
                                    <col class="tr-col-meta">
                                    <col class="tr-col-content">
                                    <col class="tr-col-pagespeed">
                                    <col class="tr-col-schema">
                                    <col class="tr-col-action">
                                </colgroup>
                                <thead>
                                    <tr>
                                        <th><?php tamrank_e('title', 'Title'); ?></th>
                                        <th><?php tamrank_e('col_score', 'Score'); ?></th>
                                        <th><?php tamrank_e('col_meta', 'Meta'); ?></th>
                                        <th><?php tamrank_e('col_content', 'Content'); ?></th>
                                        <th><?php tamrank_e('pagespeed', 'PageSpeed'); ?></th>
                                        <th><?php tamrank_e('schema', 'Schema'); ?></th>
                                        <?php /* Actiekolom: visueel koploos, maar met een naam voor
                                                 schermlezers. Zonder deze tekst kondigde de kolom zich
                                                 als naamloos aan en had de gekleurde bol erin geen label. */ ?>
                                        <th><span class="screen-reader-text"><?php tamrank_e('col_action', 'Action'); ?></span></th>
                                    </tr>
                                </thead>
                                <tbody>
                                    <?php $stripe_i = 0; foreach ($all_items as $item):
                                        $stripe_i++;
                                        $score_data = self::get_quick_score_data($item);
                                        $is_term = !empty($item->_tr_is_term);
                                        $total_score = intval($score_data['total']['score']);
                                        $meta_score = intval($score_data['meta']['score']);
                                        $content_score = intval($score_data['content']['score']);
                                        $total_mod = self::get_score_modifier($total_score);
                                        $meta_mod = self::get_score_modifier($meta_score);
                                        $content_mod = self::get_score_modifier($content_score);
                                        $issues_count = self::get_quick_issues_count($item);
                                        // Skip PageSpeed lookup for terms — they have no post ID in the queue.
                                        // Class lives in the tam-rank-pro add-on (cluster-1 strip); guard for FREE-only.
                                        $ps_data = ($is_term || !class_exists('TamRank_PageSpeed_Queue')) ? null : TamRank_PageSpeed_Queue::get_score($item->ID);
                                        $ps_score = $ps_data ? intval($ps_data['average']) : null;
                                    ?>
                                        <tr<?php echo ($stripe_i % 2 === 0) ? ' class="tr-row--alt"' : ''; ?> data-post-type="<?php echo esc_attr($item->_tr_type); ?>" data-post-id="<?php echo esc_attr($item->ID); ?>">
                                            <td>
                                                <span class="tr-row-title"><?php echo esc_html($item->post_title); ?></span>
                                                <span class="tr-row-type-badge">
                                                    <?php
                                                    if ($is_term) {
                                                        echo esc_html($item->_tr_taxonomy);
                                                    } elseif ($item->_tr_type === 'post') {
                                                        echo 'post';
                                                    } else {
                                                        echo esc_html($item->_tr_type);
                                                    }
                                                    ?>
                                                </span>
                                            </td>
                                            <td>
                                                <span class="tr-metric">
                                                    <strong class="tr-metric__value"><?php echo esc_html($total_score); ?>%</strong>
                                                    <span class="tr-score-dot tr-score-dot--<?php echo esc_attr($total_mod); ?>" aria-hidden="true"></span>
                                                </span>
                                            </td>
                                            <td>
                                                <span class="tr-metric">
                                                    <span class="tr-metric__value"><?php echo esc_html($meta_score); ?>%</span>
                                                    <span class="tr-mini-bar-wrap" aria-hidden="true"><span class="tr-mini-bar-fill tr-mini-bar-fill--<?php echo esc_attr($meta_mod); ?>" style="width: <?php echo esc_attr($meta_score); ?>%"></span></span>
                                                </span>
                                            </td>
                                            <td>
                                                <?php if ($is_term): ?>
                                                    <span class="tr-dash-placeholder">&ndash;</span>
                                                <?php else: ?>
                                                    <span class="tr-metric">
                                                        <span class="tr-metric__value"><?php echo esc_html($content_score); ?>%</span>
                                                        <span class="tr-mini-bar-wrap" aria-hidden="true"><span class="tr-mini-bar-fill tr-mini-bar-fill--<?php echo esc_attr($content_mod); ?>" style="width: <?php echo esc_attr($content_score); ?>%"></span></span>
                                                    </span>
                                                <?php endif; ?>
                                            </td>
                                            <td>
                                                <?php if ($is_term): ?>
                                                    <span class="tr-dash-placeholder">&ndash;</span>
                                                <?php elseif ($ps_score !== null):
                                                    $ps_mod = self::get_score_modifier($ps_score);
                                                ?>
                                                    <span class="tr-metric">
                                                        <span class="tr-metric__value"><?php echo esc_html($ps_score); ?></span>
                                                        <span class="tr-score-dot tr-score-dot--<?php echo esc_attr($ps_mod); ?>" aria-hidden="true"></span>
                                                    </span>
                                                <?php else: ?>
                                                    <span class="tr-dash-placeholder">&ndash;</span>
                                                <?php endif; ?>
                                            </td>
                                            <td>
                                                <?php if ($is_term): ?>
                                                    <span class="tr-dash-placeholder">&ndash;</span>
                                                <?php elseif ($score_data['schema']['status'] === 'active'): ?>
                                                    <span class="tr-schema-active"><span aria-hidden="true">&#10003;</span><?php tamrank_e('active', 'Active'); ?></span>
                                                <?php elseif ($score_data['schema']['status'] === 'auto'): ?>
                                                    <span class="tr-dash-placeholder">Auto</span>
                                                <?php else: ?>
                                                    <span class="tr-dash-placeholder">&ndash;</span>
                                                <?php endif; ?>
                                            </td>
                                            <td>
                                                <?php if ($is_term): ?>
                                                    <a href="<?php echo esc_url(admin_url('edit-tags.php?action=edit&taxonomy=' . urlencode($item->_tr_taxonomy) . '&tag_ID=' . intval($item->ID))); ?>" class="tr-bekijk-btn">
                                                        <?php tamrank_e('edit', 'Edit'); ?>
                                                    </a>
                                                <?php elseif (has_action('tamrank_audit_render_row_action')): ?>
                                                    <?php
                                                    /**
                                                     * Detail-view action cell (PRO). The tam-rank-pro add-on
                                                     * renders the View toggle (or its own upsell when
                                                     * unlicensed) here; FREE falls through to the upsell below.
                                                     */
                                                    do_action('tamrank_audit_render_row_action', $item, $issues_count);
                                                    ?>
                                                <?php else: ?>
                                                    <a href="<?php echo esc_url(tam_rank_get_upgrade_url('audit', 'per_post_detail')); ?>" class="tr-row-upsell" target="_blank" rel="noopener"><?php tamrank_e('view_pro', 'View PRO'); ?> &rarr;</a>
                                                <?php endif; ?>
                                            </td>
                                        </tr>
                                        <?php if (!$is_term): ?>
                                            <?php
                                            /**
                                             * Lazy-loaded detail row (PRO). The tam-rank-pro add-on prints
                                             * the hidden expand row here when seo_audit is licensed.
                                             */
                                            do_action('tamrank_audit_render_detail_row', $item);
                                            ?>
                                        <?php endif; ?>
                                    <?php endforeach; ?>
                                </tbody>
                            </table>
                        <?php else: ?>
                            <div class="tr-info-banner" style="margin: 24px;">
                                <?php tamrank_e('no_posts_found', 'No posts found to analyze.'); ?>
                            </div>
                        <?php endif; ?>
                    </div>

                </div>


                <?php
                /**
                 * Bulk Meta editor tab panel (PRO). The tam-rank-pro add-on prints
                 * the full <div id="tab-bulk-meta"> panel here when seo_audit is
                 * licensed, reusing the already-loaded audit rows.
                 */
                do_action('tamrank_audit_render_panels', $all_items);
                ?>

                <!-- ==================== TAB: ORPHAN PAGES (FREE, F14) ==================== -->
                <div id="tab-orphans" class="tr-tab-panel">
                    <?php
                    $lg_snapshot = class_exists('TamRank_Link_Graph') ? TamRank_Link_Graph::get_snapshot() : null;
                    ?>

                    <div class="tr-scan-action-card">
                        <div>
                            <p class="tr-card-title"><?php tamrank_e('orphan_pages', 'Orphan pages'); ?></p>
                            <p class="tr-stat-card__desc" style="margin: 0;">
                                <?php tamrank_e('orphans_intro', 'This scan counts contextual links: text links in the content of other pages, plus the links in your navigation menus. A link that only lives in a theme template, a widget or a page-builder layout does not count, and neither does a link from a category archive. Pages without one are harder for Google to discover and rank.'); ?>
                                <?php if ($lg_snapshot): ?>
                                    <br><?php echo esc_html(sprintf(tamrank__('orphans_last_scan', 'Last scan: %s'), wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $lg_snapshot['generated_at']))); ?>
                                <?php endif; ?>
                            </p>
                        </div>
                        <button type="button" class="tr-btn tr-btn--primary tr-btn--md" id="tr-orphans-scan">
                            <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 21L15 15M17 10C17 13.87 13.87 17 10 17C6.13 17 3 13.87 3 10C3 6.13 6.13 3 10 3C13.87 3 17 6.13 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                            <?php tamrank_e('orphans_scan_now', 'Scan now'); ?>
                        </button>
                    </div>

                    <?php if (!$lg_snapshot): ?>
                        <div class="tr-table-card">
                            <p class="tr-stat-card__desc" style="padding: 16px; margin: 0;"><?php tamrank_e('orphans_never_scanned', 'Not scanned yet. Run the first scan to find orphan pages.'); ?></p>
                        </div>
                    <?php else:
                        $lg_orphans = isset($lg_snapshot['orphans']) && is_array($lg_snapshot['orphans']) ? $lg_snapshot['orphans'] : array();
                        $lg_weak    = isset($lg_snapshot['weak']) && is_array($lg_snapshot['weak']) ? $lg_snapshot['weak'] : array();
                        $lg_counts  = isset($lg_snapshot['inbound_counts']) && is_array($lg_snapshot['inbound_counts']) ? $lg_snapshot['inbound_counts'] : array();
                        // Read straight from the snapshot, not via get_orphans():
                        // this table still renders the ignored rows (hidden) so
                        // "N ignored — show" can bring them back without a rescan.
                        $lg_ignored_map = array_flip(class_exists('TamRank_Link_Graph') ? TamRank_Link_Graph::get_ignored() : array());
                        $lg_cap         = 200;
                        $lg_active      = array();
                        $lg_muted       = array();
                        // Orphans (0 inbound) first, then weak pages.
                        foreach (array_merge($lg_orphans, $lg_weak) as $lg_candidate) {
                            if (isset($lg_ignored_map[$lg_candidate])) {
                                $lg_muted[] = $lg_candidate;
                            } else {
                                $lg_active[] = $lg_candidate;
                            }
                        }
                        $lg_hidden        = max(0, count($lg_active) - $lg_cap);
                        $lg_active        = array_slice($lg_active, 0, $lg_cap);
                        $lg_muted         = array_slice($lg_muted, 0, $lg_cap);
                        $lg_ignored_shown = count($lg_muted);
                        $lg_row_ids       = array_merge($lg_active, $lg_muted);
                    ?>
                        <?php if (!empty($lg_snapshot['truncated'])): ?>
                            <?php // Cap uit de klasse, niet hardgecodeerd: anders liegt deze zin zodra MAX_POSTS wijzigt. Bereikbaar want $lg_snapshot is alleen gevuld als de klasse bestaat. ?>
                            <p class="tr-stat-card__desc"><?php echo esc_html(sprintf(tamrank__('orphans_truncated', 'Large site: the scan was capped at %d pages; results may be incomplete.'), TamRank_Link_Graph::MAX_POSTS)); ?></p>
                        <?php endif; ?>

                        <?php if (empty($lg_row_ids)): ?>
                            <div class="tr-table-card">
                                <p class="tr-stat-card__desc" style="padding: 16px; margin: 0;"><?php tamrank_e('orphans_none', 'No orphan pages found. All pages have incoming internal links.'); ?></p>
                            </div>
                        <?php else: ?>
                            <div class="tr-table-card">
                                <table class="tr-audit-table">
                                    <thead>
                                        <tr>
                                            <th><?php tamrank_e('orphans_col_page', 'Page'); ?></th>
                                            <th><?php tamrank_e('orphans_col_inbound', 'Incoming links'); ?></th>
                                            <th><?php tamrank_e('orphans_col_modified', 'Last modified'); ?></th>
                                            <th></th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        <?php foreach ($lg_row_ids as $lg_id):
                                            $lg_post = get_post($lg_id);
                                            if (!$lg_post || 'publish' !== $lg_post->post_status) {
                                                continue;
                                            }
                                            $lg_inbound    = isset($lg_counts[$lg_id]) ? (int) $lg_counts[$lg_id] : 0;
                                            $lg_title      = get_the_title($lg_post);
                                            $lg_edit       = get_edit_post_link($lg_id);
                                            $lg_is_ignored = isset($lg_ignored_map[$lg_id]);
                                        ?>
                                            <tr class="tr-orphan-row<?php echo $lg_is_ignored ? ' is-ignored' : ''; ?>"<?php echo $lg_is_ignored ? ' hidden' : ''; ?>>
                                                <td><?php echo esc_html('' !== $lg_title ? $lg_title : '#' . $lg_id); ?></td>
                                                <td>
                                                    <?php echo esc_html($lg_inbound); ?>
                                                    <?php if ($lg_inbound > 0): ?>
                                                        <span class="tr-stat-card__desc">(<?php tamrank_e('orphans_weak', 'weak'); ?>)</span>
                                                    <?php endif; ?>
                                                </td>
                                                <td><?php echo esc_html(mysql2date(get_option('date_format'), $lg_post->post_modified)); ?></td>
                                                <td class="tr-orphan-row__actions">
                                                    <?php if ($lg_edit): ?>
                                                        <a class="tr-btn tr-btn--ghost tr-btn--sm" href="<?php echo esc_url($lg_edit); ?>"><?php tamrank_e('edit', 'Edit'); ?></a>
                                                    <?php endif; ?>
                                                    <button type="button" class="tr-btn tr-btn--ghost tr-btn--sm tr-orphan-ignore"
                                                            data-post-id="<?php echo (int) $lg_id; ?>"
                                                            data-ignored="<?php echo $lg_is_ignored ? '1' : '0'; ?>"
                                                            data-tr-tooltip="<?php echo esc_attr(tamrank__('orphans_ignore_hint', 'Keeps this page out of the orphan list. It stays in the rest of the audit.')); ?>">
                                                        <?php echo esc_html($lg_is_ignored ? tamrank__('orphans_restore', 'Restore') : tamrank__('orphans_ignore', 'Ignore')); ?>
                                                    </button>
                                                </td>
                                            </tr>
                                        <?php endforeach; ?>
                                        <?php // Shown when every row in the table is ignored away; JS keeps it in sync without a reload. ?>
                                        <tr id="tr-orphans-allclear"<?php echo empty($lg_active) ? '' : ' hidden'; ?>>
                                            <td colspan="4" class="tr-stat-card__desc"><?php tamrank_e('orphans_none', 'No orphan pages found. All pages have incoming internal links.'); ?></td>
                                        </tr>
                                    </tbody>
                                </table>
                                <?php if ($lg_hidden > 0): ?>
                                    <p class="tr-stat-card__desc" style="padding: 0 16px 16px;"><?php echo esc_html(sprintf(tamrank__('orphans_more_rows', '+ %d more pages not shown.'), $lg_hidden)); ?></p>
                                <?php endif; ?>
                                <p class="tr-stat-card__desc tr-orphans-ignored-line" id="tr-orphans-ignored-line"<?php echo $lg_ignored_shown > 0 ? '' : ' hidden'; ?>>
                                    <span id="tr-orphans-ignored-count"><?php echo esc_html(sprintf(tamrank__('orphans_ignored_count', '%d ignored'), $lg_ignored_shown)); ?></span>
                                    &mdash;
                                    <button type="button" class="tr-orphans-toggle-ignored" id="tr-orphans-toggle-ignored" aria-expanded="false"><?php tamrank_e('orphans_show_ignored', 'show'); ?></button>
                                </p>
                            </div>
                        <?php endif; ?>
                    <?php endif; ?>

                    <?php
                    // Besluit Sam 11 jul 2026: geen PRO-CTA hier zolang NIELS-01
                    // (AI Internal Links) niet bestaat. Terugzetten zodra die
                    // feature echt gebouwd is: gated kaartje met
                    // tam_rank_get_upgrade_url('orphans', 'ai_internal_links').
                    ?>
                </div>

                <!-- ==================== TAB 3: TOOLS ==================== -->
                <div id="tab-tools" class="tr-tab-panel">

                    <!-- Bulk Scan -->
                    <div class="tr-scan-action-card">
                        <div>
                            <p class="tr-card-title"><?php tamrank_e('start_bulk_audit', 'Bulk Audit'); ?></p>
                            <p class="tr-stat-card__desc" style="margin: 0;"><?php tamrank_e('analyze_all_bulk_desc', 'Analyze all posts and pages at once'); ?></p>
                        </div>
                        <button type="button" class="tr-btn tr-btn--primary tr-btn--md" id="tr-bulk-scan">
                            <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 21L15 15M17 10C17 13.87 13.87 17 10 17C6.13 17 3 13.87 3 10C3 6.13 6.13 3 10 3C13.87 3 17 6.13 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                            <?php tamrank_e('start_bulk_audit_btn', 'Start Bulk Audit'); ?>
                        </button>
                    </div>

                    <!-- Scan progress (hidden by default) -->
                    <div class="tr-progress-banner" id="tr-tools-progress" style="display: none;">
                        <div class="tr-progress-banner__label" id="tr-tools-progress-label">
                            <svg class="tr-icon-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-dashoffset="12"/></svg>
                            <?php tamrank_e('scanning', 'Scanning...'); ?>
                        </div>
                        <div class="tr-progress-row">
                            <div class="tr-progress-bar-wrap">
                                <div class="tr-progress-bar-fill" id="tr-tools-progress-fill" style="width: 0%"></div>
                            </div>
                            <span class="tr-progress-count" id="tr-tools-progress-count">0 / 0</span>
                        </div>
                    </div>

                    <div class="tr-tools-grid">
                        <!-- Export Results (FREE) -->
                        <div class="tr-card">
                            <p class="tr-card-title"><?php tamrank_e('export_results', 'Export Results'); ?></p>
                            <p class="tr-stat-card__desc" style="margin: 0 0 12px 0;"><?php tamrank_e('download_csv_desc', 'Download all audit results as CSV'); ?></p>
                            <button type="button" class="tr-btn tr-btn--ghost tr-btn--sm" id="tr-export-csv">
                                <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V15M7 10L12 15M12 15L17 10M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                                <?php tamrank_e('export_to_csv', 'Export to CSV'); ?>
                            </button>
                        </div>
                        <?php
                        /**
                         * SEO report card. PRO renders the active "Generate Report"
                         * card via this seam; FREE shows a quiet PRO-upsell card.
                         */
                        if (has_action('tamrank_audit_render_tools_cards')) {
                            do_action('tamrank_audit_render_tools_cards');
                        } else {
                            ?>
                            <div class="tr-card tr-card--pro-locked">
                                <p class="tr-card-title"><?php tamrank_e('generate_seo_report', 'Generate SEO Report'); ?> <span class="tr-badge tr-badge--pro">PRO</span></p>
                                <p class="tr-stat-card__desc" style="margin: 0 0 12px 0;"><?php tamrank_e('generate_report_desc', 'Create a comprehensive SEO report'); ?></p>
                                <a href="<?php echo esc_url(tam_rank_get_upgrade_url('audit', 'seo_report')); ?>" class="tr-upsell-link" target="_blank" rel="noopener"><?php tamrank_e('view_pro', 'View PRO'); ?> &rarr;</a>
                            </div>
                            <?php
                        }
                        ?>
                    </div>

                    <!-- Danger Zone -->
                    <div class="tr-danger-separator">
                        <span class="tr-danger-separator__label"><?php tamrank_e('danger_zone', 'Danger Zone'); ?></span>
                    </div>

                    <div class="tr-danger-card">
                        <div>
                            <p class="tr-danger-card__title"><?php tamrank_e('reset_audit_data', 'Reset Audit Data'); ?></p>
                            <p class="tr-danger-card__desc"><?php tamrank_e('delete_all_audit_results', 'Delete all audit results'); ?></p>
                        </div>
                        <button type="button" class="tr-btn tr-btn--destructive tr-btn--md" id="tr-reset-data">
                            <?php tamrank_e('reset_all_data', 'Reset All Data'); ?>
                        </button>
                    </div>

                </div>

            <?php self::render_footer(); ?>
        </div><!-- .wrap .tr-page-wrap -->

        <?php
        // Audit-page JS config passed via wp_add_inline_script (window.tamrankAuditData) instead
        // of an inline <script type="application/json"> data island (wp.org compliance).
        wp_add_inline_script(
            'tamrank-seo-audit',
            'window.tamrankAuditData = ' . wp_json_encode(array(
                'nonce'          => wp_create_nonce('tam_rank_nonce'),
                'ajaxUrl'        => admin_url('admin-ajax.php'),
                'scanStatus'     => $scan_running ? 'running' : 'idle',
                'charLimits'     => array(
                    'title'       => array('min' => 30, 'max' => 60),
                    'description' => array('min' => 70, 'max' => 160),
                ),
                'isPremium'      => (bool) $is_premium,
                'excludedPosts'  => array_values($excluded_ids_rp),
                'excludeNoindex' => (bool) $exclude_noindex_rp,
                'i18n'           => array(
                    'scan_complete'        => tamrank__('scan_complete', 'Scan complete!'),
                    'scan_error'           => tamrank__('scan_error', 'Error during scan'),
                    'network_error'        => tamrank__('network_error', 'Network error'),
                    'loading'              => tamrank__('loading', 'Loading...'),
                    'load_error'           => tamrank__('load_error', 'Error loading'),
                    'no_results'           => tamrank__('no_results', 'No results'),
                    'saving'               => tamrank__('saving', 'Saving...'),
                    'save_error'           => tamrank__('save_error', 'Error — try again'),
                    'scan_already_running' => tamrank__('error_scan_running', 'A scan is already running. Please wait until it finishes.'),
                    'scan_cancelled'       => tamrank__('scan_cancelled', 'Scan cancelled.'),
                    'posts_skipped'        => tamrank__('posts_skipped', 'pages skipped'),
                    'scan_skip_batch'      => tamrank__('scan_skip_batch', 'Batch skipped, scan continues...'),
                    'reset_audit_title'    => tamrank__('reset_audit_title', 'Reset audit data?'),
                    'reset_audit_message'  => tamrank__('reset_audit_message', 'All audit data will be permanently deleted. This action cannot be undone.'),
                    'reset'                => tamrank__('reset', 'Reset'),
                    'audit_reset_success'  => tamrank__('audit_reset_success', 'All audit data has been reset'),
                    'audit_reset_failed'   => tamrank__('audit_reset_failed', 'Reset failed'),
                    'orphans_scanning'     => tamrank__('orphans_scanning', 'Scanning...'),
                    'orphans_scan_failed'  => tamrank__('orphans_scan_failed', 'Scan failed. Try again.'),
                    'orphans_ignore'       => tamrank__('orphans_ignore', 'Ignore'),
                    'orphans_restore'      => tamrank__('orphans_restore', 'Restore'),
                    'orphans_ignored_count' => tamrank__('orphans_ignored_count', '%d ignored'),
                    'orphans_show_ignored' => tamrank__('orphans_show_ignored', 'show'),
                    'orphans_hide_ignored' => tamrank__('orphans_hide_ignored', 'hide'),
                    'orphans_ignore_failed' => tamrank__('orphans_ignore_failed', 'Could not save that. Try again.'),
                ),
            ), JSON_HEX_TAG | JSON_HEX_AMP) . ';',
            'before'
        );
    }

    /**
     * Build a prepared IN clause for the enabled audit post types.
     *
     * Returns an array with:
     *   'placeholders' => '%s, %s' string for use inside IN (...)
     *   'slugs'        => ['post', 'page', ...] array to spread into prepare()
     *
     * Falls back to ['post', 'page'] if config returns empty.
     *
     * @return array{placeholders: string, slugs: array}
     */
    private static function build_post_type_in_clause(): array
    {
        $slugs = TamRank_Content_Type_Config::get_enabled_audit_slugs();
        if (empty($slugs)) {
            $slugs = array('post', 'page'); // fallback safety
        }
        $placeholders = implode(', ', array_fill(0, count($slugs), '%s'));
        return array('placeholders' => $placeholders, 'slugs' => $slugs);
    }

    /**
     * Generic audit data fetcher for any set of post type slugs.
     *
     * Replaces get_posts_audit_data() and get_pages_audit_data().
     * Uses $wpdb->prepare() with array_fill() placeholders — never string concatenation.
     *
     * @param array $types Post type slugs to include, e.g. ['post', 'page', 'product'].
     * @return array Array of stdClass objects with post data + _tr_type set to post_type slug.
     */
    public static function get_audit_data(array $types): array
    {
        if (empty($types)) {
            return [];
        }

        global $wpdb;

        $placeholders = implode(', ', array_fill(0, count($types), '%s'));

        // Build exclusion clauses
        $exclusion_clauses = '';
        $general_settings_gad = tam_rank_cached_option('tam_rank_general_settings', array());
        $exclude_noindex_gad  = isset($general_settings_gad['audit_exclude_noindex']) ? (bool) $general_settings_gad['audit_exclude_noindex'] : true;
        $excluded_ids_gad     = get_option('tam_rank_audit_excluded_posts', array());

        if ($exclude_noindex_gad) {
            $exclusion_clauses .= " AND NOT EXISTS (
                SELECT 1 FROM {$wpdb->postmeta} ni
                WHERE ni.post_id = p.ID AND ni.meta_key = '_tam_rank_noindex' AND ni.meta_value = '1'
            )";
        }
        if (!empty($excluded_ids_gad)) {
            $safe_ids           = implode(',', array_map('intval', $excluded_ids_gad));
            $exclusion_clauses .= " AND p.ID NOT IN ({$safe_ids})";
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers, the runtime-built %s,%s placeholder string and the intval'd ($safe_ids) exclusion-ID list are interpolated; post-type slug values bound via prepare(); $query is already a prepared string when passed to get_results(); admin-context read on core tables, caching not applicable.
        $query = $wpdb->prepare(
            "SELECT p.ID, p.post_title, p.post_date, p.post_name, p.post_type,
                    mt.meta_value as meta_title,
                    md.meta_value as meta_description,
                    fk.meta_value as focus_keyword,
                    cs.meta_value as content_score,
                    ms.meta_value as meta_score,
                    aus.meta_value as audit_score,
                    sb.meta_value as schema_bron,
                    st.meta_value as schema_type
             FROM {$wpdb->posts} p
             LEFT JOIN {$wpdb->postmeta} mt ON p.ID = mt.post_id AND mt.meta_key = '_tam_rank_meta_title'
             LEFT JOIN {$wpdb->postmeta} md ON p.ID = md.post_id AND md.meta_key = '_tam_rank_meta_description'
             LEFT JOIN {$wpdb->postmeta} fk ON p.ID = fk.post_id AND fk.meta_key = '_tam_rank_focus_keyword'
             LEFT JOIN {$wpdb->postmeta} cs ON p.ID = cs.post_id AND cs.meta_key = '_tam_rank_content_score'
             LEFT JOIN {$wpdb->postmeta} ms ON p.ID = ms.post_id AND ms.meta_key = '_tam_rank_meta_score'
             LEFT JOIN {$wpdb->postmeta} aus ON p.ID = aus.post_id AND aus.meta_key = '_tam_rank_audit_score'
             LEFT JOIN {$wpdb->postmeta} sb ON p.ID = sb.post_id AND sb.meta_key = 'tamrank_schema_bron'
             LEFT JOIN {$wpdb->postmeta} st ON p.ID = st.post_id AND st.meta_key = '_tamrank_schema_auto_type'
             WHERE p.post_type IN ({$placeholders})
             AND p.post_status = 'publish'
             {$exclusion_clauses}
             ORDER BY p.post_date DESC
             LIMIT 200",
            ...$types
        );

        $results = $wpdb->get_results($query);
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (empty($results)) {
            return [];
        }

        // Set _tr_type from post_type column to preserve existing contract
        foreach ($results as $row) {
            $row->_tr_type = $row->post_type;
        }

        return $results;
    }

    /**
     * Fetch audit data for taxonomy terms from enabled taxonomies.
     *
     * Returns stdClass objects normalized to match the post row contract.
     * Terms have no content/schema score — those columns show a dash in the UI.
     *
     * @return array Array of stdClass objects with term data.
     */
    private static function get_terms_audit_data(): array
    {
        $taxonomy_slugs = TamRank_Content_Type_Config::get_enabled_audit_taxonomy_slugs();

        // Filter out excluded taxonomies
        $general_settings = tam_rank_cached_option('tam_rank_general_settings', array());
        $excluded_taxonomies = isset($general_settings['audit_excluded_taxonomies']) ? (array) $general_settings['audit_excluded_taxonomies'] : array();
        if (!empty($excluded_taxonomies)) {
            $taxonomy_slugs = array_diff($taxonomy_slugs, $excluded_taxonomies);
        }

        if (empty($taxonomy_slugs)) {
            return [];
        }

        global $wpdb;

        $placeholders = implode(', ', array_fill(0, count($taxonomy_slugs), '%s'));

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers and the runtime-built %s,%s placeholder string are interpolated; taxonomy slug values bound via prepare(); $query is already a prepared string when passed to get_results(); admin-context read on core tables, caching not applicable.
        $query = $wpdb->prepare(
            "SELECT t.term_id, t.name, t.slug, tt.taxonomy,
                    MAX(CASE WHEN tm.meta_key = '_tam_rank_meta_title'       THEN tm.meta_value END) AS meta_title,
                    MAX(CASE WHEN tm.meta_key = '_tam_rank_meta_description' THEN tm.meta_value END) AS meta_description,
                    MAX(CASE WHEN tm.meta_key = '_tam_rank_focus_keyword'    THEN tm.meta_value END) AS focus_keyword
             FROM {$wpdb->terms} t
             INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
             LEFT JOIN  {$wpdb->termmeta}      tm ON t.term_id = tm.term_id
                    AND tm.meta_key IN ('_tam_rank_meta_title', '_tam_rank_meta_description', '_tam_rank_focus_keyword')
             WHERE tt.taxonomy IN ({$placeholders})
               AND tt.count > 0
             GROUP BY t.term_id, t.name, t.slug, tt.taxonomy
             ORDER BY t.name ASC
             LIMIT 200",
            ...$taxonomy_slugs
        );

        $raw = $wpdb->get_results($query);
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $items = [];
        foreach ($raw as $row) {
            $item                    = new stdClass();
            $item->ID                = intval($row->term_id);
            $item->post_title        = $row->name;
            $item->post_name         = $row->slug;
            $item->post_date         = null;
            $item->post_type         = $row->taxonomy;
            $item->_tr_type          = 'taxonomy'; // generic — used for filter pill matching
            $item->_tr_is_term       = true;
            $item->_tr_taxonomy      = $row->taxonomy; // real slug for edit URL and type badge
            $item->meta_title        = $row->meta_title ?? '';
            $item->meta_description  = $row->meta_description ?? '';
            $item->focus_keyword     = $row->focus_keyword ?? '';
            $item->content_score     = null;
            $item->schema_bron       = null;
            $item->schema_type       = null;
            $items[] = $item;
        }

        return $items;
    }

    /**
     * Bereken meta score snel (zonder extra database calls)
     */
    private static function calculate_quick_meta_score($post_data)
    {
        if (!is_object($post_data)) {
            return 50; // Default score
        }

        $score = 0;
        $max_score = 50;

        // PHP 8.1+ null-safe: cast alle waarden naar string
        $post_title = isset($post_data->post_title) ? (string) $post_data->post_title : '';
        $meta_title = !empty($post_data->meta_title) ? (string) $post_data->meta_title : $post_title;
        $meta_description = isset($post_data->meta_description) ? (string) $post_data->meta_description : '';
        $focus_keyword = isset($post_data->focus_keyword) ? (string) $post_data->focus_keyword : '';

        // Check Meta Title (15 points max)
        if (!empty($meta_title)) {
            $title_length = strlen($meta_title);
            if ($title_length >= 30 && $title_length <= 60) {
                $score += 10;
            } elseif ($title_length > 0 && $title_length < 30) {
                $score += 3;
            } elseif ($title_length > 60) {
                $score += 5;
            }
            if ($focus_keyword && TamRank_Meta_Calculator::keyword_exists($meta_title, $focus_keyword)) {
                $score += 5;
            }
        }

        // Check Meta Description (15 points max)
        if (!empty($meta_description)) {
            $desc_length = strlen($meta_description);
            if ($desc_length >= 120 && $desc_length <= 160) {
                $score += 10;
            } elseif ($desc_length > 0 && $desc_length < 120) {
                $score += 3;
            } elseif ($desc_length > 160) {
                $score += 5;
            }
            if ($focus_keyword && TamRank_Meta_Calculator::keyword_exists($meta_description, $focus_keyword)) {
                $score += 5;
            }
        }

        // Check Focus Keyword (10 points)
        if (!empty($focus_keyword)) {
            $score += 10;
        }

        // Check Slug (10 points)
        $post_name = isset($post_data->post_name) ? (string) $post_data->post_name : '';
        if (!empty($post_name)) {
            if (strlen($post_name) <= 60) {
                $score += 5;
            }
            if ($focus_keyword) {
                $slug_keyword = strtolower(str_replace(' ', '-', $focus_keyword));
                if (TamRank_Meta_Calculator::keyword_exists($post_name, $slug_keyword)) {
                    $score += 5;
                }
            }
        }

        return min(100, max(0, round(($score / $max_score) * 100)));
    }

    /**
     * Get score data snel uit query resultaat (zonder extra database calls)
     */
    public static function get_quick_score_data($post_data)
    {
        if (!is_object($post_data)) {
            return array(
                'total' => array('score' => 50, 'color' => '#f59e0b', 'label' => tamrank__('needs_attention', 'Needs Attention')),
                'meta' => array('score' => 50, 'color' => '#f59e0b'),
                'content' => array('score' => 50, 'color' => '#f59e0b'),
                'schema' => array('status' => 'auto'),
            );
        }

        $is_term_row = !empty($post_data->_tr_is_term);

        // Stored values written by TamRank_Score_Service (single source of truth).
        // Prefer them so audit rows match the dashboard SEO card and the meta-box
        // live score exactly. Fall back to the lightweight calc only for rows that
        // have never been scored (e.g. freshly imported posts) — those are also
        // absent from the dashboard average, so the fallback is display-only.
        $stored_meta    = isset($post_data->meta_score) && $post_data->meta_score !== '' ? intval($post_data->meta_score) : null;
        $stored_audit   = isset($post_data->audit_score) && $post_data->audit_score !== '' ? intval($post_data->audit_score) : null;
        $stored_content = isset($post_data->content_score) && $post_data->content_score !== '' ? intval($post_data->content_score) : null;

        // Meta score: stored value, else lightweight calc from query data
        $meta_score = $stored_meta !== null ? $stored_meta : self::calculate_quick_meta_score($post_data);

        if ($is_term_row) {
            // Terms have no content score — total equals meta score (100% weight)
            $content_score = null;
            $total_score   = $stored_audit !== null ? $stored_audit : $meta_score;
        } else {
            if ($stored_audit !== null) {
                // Use the persisted total verbatim (matches dashboard + meta-box)
                $total_score = $stored_audit;
            } elseif ($stored_content !== null) {
                // Mirror Score Service weighting: 30% meta + 70% content
                $total_score = round($meta_score * 0.30 + $stored_content * 0.70);
            } else {
                // No content score yet → 100% meta (same fallback as Score Service)
                $total_score = $meta_score;
            }
            // Display value for the content column (cosmetic fallback when unscored)
            $content_score = $stored_content !== null ? $stored_content : 50;
        }

        // Schema status snel bepalen
        $schema_bron = !empty($post_data->schema_bron) ? $post_data->schema_bron : 'automatisch';
        $schema_type = isset($post_data->schema_type) ? $post_data->schema_type : '';

        if ($schema_bron === 'geen') {
            $schema_status = 'none';
        } elseif (!empty($schema_type) || $schema_bron === 'template') {
            $schema_status = 'active';
        } else {
            $schema_status = 'auto'; // BreadcrumbList wordt altijd gegenereerd
        }

        return array(
            'total' => array(
                'score' => $total_score,
                'color' => self::get_quick_score_color($total_score),
                'label' => self::get_quick_score_label($total_score),
            ),
            'meta' => array(
                'score' => $meta_score,
                'color' => self::get_quick_score_color($meta_score),
            ),
            'content' => array(
                'score' => $is_term_row ? 0 : $content_score,
                'color' => self::get_quick_score_color($is_term_row ? 0 : $content_score),
            ),
            'schema' => array(
                'status' => $is_term_row ? 'none' : $schema_status,
            ),
        );
    }

    /**
     * Kleur voor score (standalone functie)
     */
    private static function get_quick_score_color($score)
    {
        if ($score >= 80)
            return '#10b981'; // Groen
        if ($score >= 60)
            return 'var(--tr-color-info)'; // Blauw
        if ($score >= 40)
            return '#f59e0b'; // Oranje
        return '#f43f5e'; // Roze
    }

    /**
     * Label voor score (standalone functie)
     */
    private static function get_quick_score_label($score)
    {
        if ($score >= 80)
            return tamrank__('excellent', 'Excellent');
        if ($score >= 60)
            return tamrank__('good', 'Good');
        if ($score >= 40)
            return tamrank__('needs_attention', 'Needs Attention');
        return tamrank__('needs_improvement', 'Needs Improvement');
    }

    /**
     * Get quick issues count (zonder zware analyse)
     */
    public static function get_quick_issues_count($post_data)
    {
        if (!is_object($post_data)) {
            return 0;
        }

        $count = 0;

        $meta_title = isset($post_data->meta_title) ? $post_data->meta_title : '';
        $meta_description = isset($post_data->meta_description) ? $post_data->meta_description : '';
        $focus_keyword = isset($post_data->focus_keyword) ? $post_data->focus_keyword : '';

        // Meta issues
        if (empty($meta_title))
            $count++;
        if (empty($meta_description))
            $count++;
        if (empty($focus_keyword))
            $count++;

        // Length issues
        if (!empty($meta_title)) {
            $len = strlen($meta_title);
            if ($len < 30 || $len > 60)
                $count++;
        }
        if (!empty($meta_description)) {
            $len = strlen($meta_description);
            if ($len < 120 || $len > 160)
                $count++;
        }

        return $count;
    }

    /**
     * Get overall statistics (Cached)
     *
     * Uses wp_options 'tam_rank_audit_overall_stats' to avoid heavy DB queries on page load.
     */
    /**
     * Get overall statistics (Cached with SQL Fallback)
     *
     * Uses wp_options 'tam_rank_audit_overall_stats'.
     * If cache is empty, performs a fast SQL aggregation on '_tam_rank_audit_score' meta
     * to populate the dashboard without a full PHP loop.
     */
    private static function get_overall_stats()
    {
        $stats = get_option('tam_rank_audit_overall_stats', false);

        // Required structure
        $default_stats = array(
            'average_score' => 0,
            'audited_pages' => 0,
            'total_pages' => 0, // Will be updated
            'excellent_count' => 0,
            'good_count' => 0,
            'poor_count' => 0,
            'common_issues' => array(),
            'calculated_at' => 0,
            'version' => '0.0.0'
        );

        // 1. Return cached stats if available
        if ($stats && is_array($stats) && isset($stats['stats'])) {
            $cached_stats = wp_parse_args($stats['stats'], $default_stats);
            // Always reflect the live "Most Common Issues" — these are three cheap COUNT
            // queries. Missing meta is a live fact that changes the moment a user edits a
            // page, so this panel must never be served from a stale cache: a user who just
            // fixed their meta expects it to update on the next load. This also makes the
            // panel self-heal if the cached blob ever failed to persist.
            $cached_stats['common_issues'] = self::get_common_issues();
            // Add taxonomy term count to total (scores remain post-only)
            $tax_stats = get_option('tam_rank_audit_taxonomy_stats', ['total_count' => 0]);
            $cached_stats['total_pages'] += intval($tax_stats['total_count'] ?? 0);
            return $cached_stats;
        }

        // 2. Fallback: Fast SQL Aggregation over stored _tam_rank_audit_score
        //    (Fixes "0%" dashboard when data exists but cache is empty.)
        //    Uses the same single-source-of-truth helper as the audit run, so the
        //    fallback can never diverge from the cached value or the dashboard.
        $fallback_stats = self::compute_post_score_stats();

        if ($fallback_stats['audited_pages'] > 0) {
            $fallback_stats['common_issues'] = self::get_common_issues(); // Lightweight query

            // Auto-populate cache to speed up next load even more
            update_option('tam_rank_audit_overall_stats', array(
                'stats' => $fallback_stats,
                'post_count' => $fallback_stats['total_pages'],
                'calculated_at' => time(),
                'version' => defined('TAM_RANK_VERSION') ? TAM_RANK_VERSION : '1.0.0'
            ), false); // autoload=no: admin-only stats, keep out of the alloptions cache

            // Add taxonomy term count to total (scores remain post-only)
            $tax_stats = get_option('tam_rank_audit_taxonomy_stats', ['total_count' => 0]);
            $fallback_stats['total_pages'] += intval($tax_stats['total_count'] ?? 0);

            return $fallback_stats;
        }

        // 3. No data found
        $default_stats['total_pages'] = $fallback_stats['total_pages'];
        $default_stats['common_issues'] = self::get_common_issues();

        // Add taxonomy term count to total (scores remain post-only)
        $tax_stats = get_option('tam_rank_audit_taxonomy_stats', ['total_count' => 0]);
        $default_stats['total_pages'] += intval($tax_stats['total_count'] ?? 0);

        return $default_stats;
    }

    /**
     * Build the WHERE-exclusion SQL shared by all audit stat queries.
     *
     * Mirrors the row-list exclusions (see get_audit_data): noindex posts
     * (opt-in, default on) and manually excluded post IDs. Assumes the posts
     * table is aliased as `p` in the surrounding query.
     *
     * @return string SQL fragment beginning with " AND ..." (or empty string)
     */
    private static function get_stats_exclusion_clauses(): string
    {
        // Delegates to the shared helper so every audit/dashboard counter excludes
        // the same set (noindex + audit-excluded posts). Queries here alias posts as `p`.
        return tam_rank_audit_exclusion_clauses('p');
    }

    /**
     * Compute overall post-score stats from the stored _tam_rank_audit_score meta.
     *
     * SINGLE SOURCE OF TRUTH for the audit average: it averages exactly the values
     * TamRank_Score_Service persisted — the same values the dashboard SEO card and
     * the meta-box live score read. No second score formula runs here, so the audit
     * average can no longer diverge from the dashboard.
     *
     * @return array {average_score, audited_pages, total_pages, excellent_count, good_count, poor_count}
     */
    private static function compute_post_score_stats(): array
    {
        global $wpdb;

        $in_clause = self::build_post_type_in_clause();
        $excl      = self::get_stats_exclusion_clauses();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers, the runtime-built %s,%s placeholder string and the $excl clause (built from intval'd IDs in get_stats_exclusion_clauses) are interpolated; post-type slug values bound via prepare(); admin-context read on core tables, caching not applicable.
        $total_pages = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(p.ID) FROM {$wpdb->posts} p
                 WHERE p.post_type IN ({$in_clause['placeholders']}) AND p.post_status = 'publish'
                 {$excl}",
                ...$in_clause['slugs']
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers, the runtime-built %s,%s placeholder string and the $excl clause (built from intval'd IDs in get_stats_exclusion_clauses) are interpolated; post-type slug values bound via prepare(); admin-context read on core tables, caching not applicable.
        $scores = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT pm.meta_value FROM {$wpdb->postmeta} pm
                 JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = '_tam_rank_audit_score'
                   AND p.post_status = 'publish'
                   AND p.post_type IN ({$in_clause['placeholders']})
                   {$excl}",
                ...$in_clause['slugs']
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $stats = array(
            'average_score'   => 0,
            'audited_pages'   => 0,
            'total_pages'     => $total_pages,
            'excellent_count' => 0,
            'good_count'      => 0,
            'poor_count'      => 0,
        );

        $sum = 0;
        foreach ($scores as $score) {
            $score = (int) $score;
            $sum  += $score;
            if ($score >= 80) {
                $stats['excellent_count']++;
            } elseif ($score >= 60) {
                $stats['good_count']++;
            } else {
                $stats['poor_count']++;
            }
        }

        $scored_count           = count($scores);
        $stats['audited_pages'] = $scored_count;
        $stats['average_score'] = $scored_count > 0 ? (int) round($sum / $scored_count) : 0;

        return $stats;
    }

    /**
     * Calculate and cache overall statistics.
     *
     * Reads the stored _tam_rank_audit_score values (written by Score Service during
     * the audit run). It does NOT recompute scores — doing so previously used a second
     * meta-score formula (TamRank_Total_Score) and made the cached audit average
     * diverge from the dashboard SEO card.
     *
     * @return array Calculated stats
     */
    private static function calculate_and_cache_stats()
    {
        global $wpdb;

        $stats = self::compute_post_score_stats();
        $stats['common_issues'] = self::get_common_issues();

        // Cache the result.
        $cache_payload = array(
            'stats'         => $stats,
            'post_count'    => $stats['total_pages'],
            'calculated_at' => time(),
            'version'       => defined('TAM_RANK_VERSION') ? TAM_RANK_VERSION : '1.0.0',
        );
        update_option('tam_rank_audit_overall_stats', $cache_payload, false); // autoload=no: admin-only stats, keep out of the alloptions cache

        // Verify the write actually persisted. On databases whose wp_options column is
        // legacy utf8 (not utf8mb4), a stored value containing a 4-byte character would
        // be rejected and silently leave an outdated cache in place — the audit summary
        // would then stay stuck on its previous numbers no matter how often the user
        // re-scans. If the read-back does not match what we just wrote, drop the stale
        // option so get_overall_stats() recomputes fresh instead of serving old data.
        $persisted = get_option('tam_rank_audit_overall_stats');
        if (!is_array($persisted) || (int) ($persisted['calculated_at'] ?? 0) !== (int) $cache_payload['calculated_at']) {
            delete_option('tam_rank_audit_overall_stats');
        }

        // Bust the dashboard's 10-minute data transient so its headline gauge +
        // category cards reflect this fresh audit immediately instead of lagging.
        delete_transient('tam_rank_dashboard_data');

        // Calculate and cache taxonomy term stats separately
        $tax_slugs = TamRank_Content_Type_Config::get_enabled_audit_taxonomy_slugs();
        $tax_stats = [
            'total_count'     => 0,
            'average_score'   => 0,
            'excellent_count' => 0,
            'good_count'      => 0,
            'poor_count'      => 0,
        ];

        if (!empty($tax_slugs)) {
            $tax_ph = implode(', ', array_fill(0, count($tax_slugs), '%s'));
            // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers and the runtime-built %s,%s placeholder string are interpolated; taxonomy slug values bound via prepare(); admin-context read on core tables, caching not applicable.
            $tax_stats['total_count'] = intval($wpdb->get_var(
                $wpdb->prepare(
                    "SELECT COUNT(t.term_id)
                     FROM {$wpdb->terms} t
                     INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
                     WHERE tt.taxonomy IN ({$tax_ph})
                     AND tt.count > 0",
                    ...$tax_slugs
                )
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter
        }

        update_option('tam_rank_audit_taxonomy_stats', $tax_stats, false); // autoload=no: admin-only stats

        // Also update legacy timestamp for compatibility
        update_option('tam_rank_last_scan', current_time('mysql'));

        return $stats;
    }

    /**
     * AJAX: Run stats scan (Recalculate aggregates)
     */
    public function ajax_run_stats_scan()
    {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        // Concurrency lock
        if (get_option('tam_rank_audit_scan_running')) {
            // Treat the lock as stale once its heartbeat times out.
            $lock_time = get_option('tam_rank_audit_scan_start_time', 0);
            if (time() - $lock_time < self::SCAN_HEARTBEAT_TIMEOUT) {
                wp_send_json_error(array('message' => tamrank__('error_scan_running', 'A scan is already running. Please wait until it finishes.'), 'code' => 'conflict'));
            }
        }

        // Set lock
        update_option('tam_rank_audit_scan_running', true);
        update_option('tam_rank_audit_scan_start_time', time());

        try {
            $stats = self::calculate_and_cache_stats();

            // Release lock
            delete_option('tam_rank_audit_scan_running');
            delete_option('tam_rank_audit_scan_start_time');

            wp_send_json_success(array('stats' => $stats));
        } catch (Exception $e) {
            // Release lock on error
            delete_option('tam_rank_audit_scan_running');
            delete_option('tam_rank_audit_scan_start_time');

            wp_send_json_error(array('message' => tamrank__('error_generic', 'Something went wrong. Please try again.'), 'code' => 'request_failed'));
        }
    }

    /**
     * Get common SEO issues
     */
    private static function get_common_issues()
    {
        global $wpdb;

        $issues = array();
        $in_clause = self::build_post_type_in_clause();
        $excl      = self::get_stats_exclusion_clauses();

        // Posts without meta title
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers, the runtime-built %s,%s placeholder string and the $excl clause (built from intval'd IDs in get_stats_exclusion_clauses) are interpolated; post-type slug values bound via prepare(); admin-context read on core tables, caching not applicable.
        $no_meta_title = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*)
                 FROM {$wpdb->posts} p
                 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_tam_rank_meta_title'
                 WHERE p.post_type IN ({$in_clause['placeholders']})
                 AND p.post_status = 'publish'
                 {$excl}
                 AND (pm.meta_value IS NULL OR pm.meta_value = '')",
                ...$in_clause['slugs']
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ($no_meta_title > 0) {
            $issues[] = $no_meta_title . ' ' . tamrank__('pages_missing_meta_title', 'pages are missing a meta title');
        }

        // Posts without meta description
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers, the runtime-built %s,%s placeholder string and the $excl clause (built from intval'd IDs in get_stats_exclusion_clauses) are interpolated; post-type slug values bound via prepare(); admin-context read on core tables, caching not applicable.
        $no_meta_desc = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*)
                 FROM {$wpdb->posts} p
                 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_tam_rank_meta_description'
                 WHERE p.post_type IN ({$in_clause['placeholders']})
                 AND p.post_status = 'publish'
                 {$excl}
                 AND (pm.meta_value IS NULL OR pm.meta_value = '')",
                ...$in_clause['slugs']
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ($no_meta_desc > 0) {
            $issues[] = $no_meta_desc . ' ' . tamrank__('pages_missing_meta_description', 'pages are missing a meta description');
        }

        // Posts without focus keyword
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only $wpdb table identifiers, the runtime-built %s,%s placeholder string and the $excl clause (built from intval'd IDs in get_stats_exclusion_clauses) are interpolated; post-type slug values bound via prepare(); admin-context read on core tables, caching not applicable.
        $no_focus_keyword = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*)
                 FROM {$wpdb->posts} p
                 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_tam_rank_focus_keyword'
                 WHERE p.post_type IN ({$in_clause['placeholders']})
                 AND p.post_status = 'publish'
                 {$excl}
                 AND (pm.meta_value IS NULL OR pm.meta_value = '')",
                ...$in_clause['slugs']
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ($no_focus_keyword > 0) {
            $issues[] = $no_focus_keyword . ' ' . tamrank__('pages_missing_focus_keyword', 'pages are missing a focus keyword');
        }

        // Return the issues as-is (possibly empty). The friendly "all clear" message
        // — which contains a 4-byte emoji — is rendered in the view layer only, never
        // stored. Persisting a 4-byte character would make the stats-option write fail
        // on databases whose wp_options column is legacy utf8 (3-byte) instead of
        // utf8mb4, leaving the audit summary permanently stuck on its last value.
        // See get_overall_stats() (live recompute) and calculate_and_cache_stats()
        // (write-verify + self-heal).
        return $issues;
    }


    /**
     * Get score class for styling
     * Drempels: 80+ Uitstekend, 60-79 Goed, 40-59 Aandacht nodig, 0-39 Verbeteren
     */
    private static function get_score_class($score)
    {
        if ($score >= 80)
            return 'tam-score-excellent';
        if ($score >= 60)
            return 'tam-score-good';
        if ($score >= 40)
            return 'tam-score-average';
        return 'tam-score-poor';
    }

    /**
     * Get score color
     * Drempels: 80+ Groen, 60-79 Blauw, 40-59 Oranje, 0-39 Roze
     */
    private static function get_score_color($score)
    {
        if ($score >= 80)
            return '#10b981'; // Groen - Uitstekend
        if ($score >= 60)
            return 'var(--tr-color-info)'; // Blauw - Goed
        if ($score >= 40)
            return '#f59e0b'; // Oranje - Aandacht nodig
        return '#f43f5e'; // Roze - Verbeteren
    }


    /**
     * Render footer
     */
    private static function render_footer()
    {
        tam_rank_render_footer();
    }

    /**
     * AJAX: Bulk audit (with batch processing support)
     */
    public function ajax_bulk_audit()
    {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        $type = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : 'all';
        $offset = isset($_POST['offset']) ? max(0, intval($_POST['offset'])) : 0;
        $batch_size = 5; // small batches to avoid timeouts

        $post_types = array();
        switch ($type) {
            case 'posts':
                $post_types = array('post');
                break;
            case 'pages':
                $post_types = array('page');
                break;
            case 'all':
            default:
                $post_types = TamRank_Content_Type_Config::get_enabled_audit_slugs();
                if (empty($post_types)) {
                    $post_types = array('post', 'page');
                }
                break;
        }

        // Lightweight total count
        global $wpdb;
        $placeholders = implode(',', array_fill(0, count($post_types), '%s'));
        $query_args = array_merge($post_types, array('publish'));
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the {$wpdb->posts} table identifier and the runtime-built %s,%s placeholder string are interpolated; post-type slug values and post_status bound via prepare(); admin-context read on core table, caching not applicable.
        $total = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ({$placeholders}) AND post_status = %s",
                ...$query_args
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter
        if ($total === 0) {
            wp_send_json_success(array(
                'processed' => 0,
                'errors'    => 0,
                'total'     => 0,
                'offset'    => 0,
                'complete'  => true,
            ));
        }

        // Concurrency lock
        if ($offset === 0) {
            $lock_time = (int) get_option('tam_rank_audit_scan_start_time', 0);
            if (get_option('tam_rank_audit_scan_running') && (time() - $lock_time < self::SCAN_HEARTBEAT_TIMEOUT)) {
                wp_send_json_error(array('message' => tamrank__('error_scan_running', 'A scan is already running. Please wait until it finishes.'), 'code' => 'conflict'));
            }
            update_option('tam_rank_audit_scan_running', true, false);
            update_option('tam_rank_audit_scan_start_time', time(), false);
        }

        // Refresh lock heartbeat so it doesn't expire during long scans
        update_option('tam_rank_audit_scan_start_time', time(), false);

        // Get batch of posts
        $posts = get_posts(array(
            'numberposts'  => $batch_size,
            'offset'       => $offset,
            'post_status'  => 'publish',
            'post_type'    => $post_types,
            'orderby'      => 'ID',
            'order'        => 'ASC',
            'no_found_rows' => true,
            'cache_results' => false,
        ));

        // Posts to skip (already failed in previous batches)
        $skip_ids = array();
        if (!empty($_POST['skip_ids'])) {
            $skip_ids = array_map('intval', (array) $_POST['skip_ids']);
        }

        $processed = 0;
        $errors = 0;
        $failed_ids = array();
        $score_service = TamRank_Score_Service::get_instance();
        $batch_start = microtime(true);
        $max_batch_time = 25; // seconds — stay under PHP 30s default

        foreach ($posts as $post) {
            // Skip known problematic posts
            if (in_array($post->ID, $skip_ids, true)) {
                $processed++;
                continue;
            }

            // Bail if batch is taking too long
            if ((microtime(true) - $batch_start) > $max_batch_time) {
                break;
            }

            try {
                // The free build computes only the score it actually displays.
                // The granular per-post detail/recommendations breakdown is a
                // PRO feature, computed + stored by the tam-rank-pro add-on, so
                // the free scan no longer produces _tam_rank_audit_details.
                $score_service->refresh_post_score($post->ID);
                update_post_meta($post->ID, '_tam_rank_last_audit', current_time('mysql'));

                $processed++;
            } catch (Exception $e) {
                $errors++;
                $processed++;
                $failed_ids[] = $post->ID;
            }
        }

        // Offset advances by however many posts we actually went through
        $new_offset = $offset + $processed;
        $is_complete = ($new_offset >= $total) || (count($posts) < $batch_size && $processed >= count($posts));

        // Finalize
        if ($is_complete) {
            self::calculate_and_cache_stats();
            update_option('tam_rank_last_scan', current_time('mysql'));
            delete_option('tam_rank_audit_scan_running');
            delete_option('tam_rank_audit_scan_start_time');
        }

        wp_send_json_success(array(
            'processed'  => $processed,
            'errors'     => $errors,
            'total'      => $total,
            'offset'     => $new_offset,
            'complete'   => $is_complete,
            'failed_ids' => $failed_ids,
        ));
    }

    /**
     * AJAX: Reset scan lock (called when JS detects stall or max retries).
     */
    public function ajax_reset_scan_lock()
    {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error('permission_denied');
        }

        delete_option('tam_rank_audit_scan_running');
        delete_option('tam_rank_audit_scan_start_time');

        wp_send_json_success();
    }

}

// AJAX handlers for additional audit functions
add_action('wp_ajax_tam_rank_reset_audit_data', 'tam_rank_reset_audit_data_ajax');

function tam_rank_reset_audit_data_ajax()
{
    check_ajax_referer('tam_rank_nonce', 'nonce');

    if (!current_user_can('manage_options')) {
        wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
    }

    global $wpdb;

    // Delete all audit meta data
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the {$wpdb->postmeta} table identifier is interpolated; meta keys are hardcoded literals, no user values; admin-context write operation, caching not applicable.
    $deleted = $wpdb->query(
        "DELETE FROM {$wpdb->postmeta}
         WHERE meta_key IN ('_tam_rank_audit_score', '_tam_rank_audit_details', '_tam_rank_last_audit')"
    );

    // Clear stats cache and timestamp
    delete_option('tam_rank_audit_overall_stats');
    delete_option('tam_rank_last_scan');
    delete_option('tam_rank_audit_scan_running'); // Reset lock just in case

    if ($deleted !== false) {
        wp_send_json_success('Audit data reset successful. Deleted ' . $deleted . ' records.');
    } else {
        wp_send_json_error(array('message' => tamrank__('error_reset_failed', 'Reset failed. Please try again.'), 'code' => 'save_failed'));
    }
}

