<?php

/**
 * Accessibility Checks Module
 *
 * Performs automated WCAG 2.2 AA accessibility checks on site content
 *
 * @package ProRank\SEO\Modules\Tools
 * @since   1.0.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\Tools;

use ProRank\SEO\Modules\BaseModule;

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


/**
 * Accessibility class
 */


class AccessibilityModule extends BaseModule {

    /**
     * Module slug
     *
     * @var string
     */
    protected string $slug = 'accessibility';

    /**
     * Module name
     *
     * @var string
     */
    protected string $name = 'Accessibility Audit';

    /**
     * Module description
     *
     * @var string
     */
    protected string $description = 'Automated WCAG 2.2 AA accessibility checking and reporting';

    /**
     * Required tier
     *
     * @var string
     */
    protected string $feature_tier = 'free';

    /**
     * Parent module
     *
     * @var string|null
     */
    protected ?string $parent_slug = 'site-audit';

    /**
     * Database table name
     *
     * @var string
     */
    private string $table_name;

    /**
     * Action Scheduler actions
     */
    private const ACTION_SCAN_SITE = 'prorank_accessibility_scan';
    private const ACTION_SCAN_PAGE = 'prorank_accessibility_scan_page';

    /**
     * WCAG criteria we can check server-side
     */
    private const WCAG_CHECKS = [
        'images_alt_text' => [
            'level' => 'A',
            'guideline' => '1.1.1',
            'description' => 'Non-text Content',
            'auto_fix' => true,
        ],
        'heading_hierarchy' => [
            'level' => 'A',
            'guideline' => '1.3.1',
            'description' => 'Info and Relationships',
            'auto_fix' => false,
        ],
        'link_text' => [
            'level' => 'A',
            'guideline' => '2.4.4',
            'description' => 'Link Purpose',
            'auto_fix' => false,
        ],
        'language_attribute' => [
            'level' => 'A',
            'guideline' => '3.1.1',
            'description' => 'Language of Page',
            'auto_fix' => false,
        ],
        'page_title' => [
            'level' => 'A',
            'guideline' => '2.4.2',
            'description' => 'Page Titled',
            'auto_fix' => true,
        ],
        'form_labels' => [
            'level' => 'A',
            'guideline' => '3.3.2',
            'description' => 'Labels or Instructions',
            'auto_fix' => false,
        ],
        'table_headers' => [
            'level' => 'A',
            'guideline' => '1.3.1',
            'description' => 'Info and Relationships',
            'auto_fix' => false,
        ],
        'duplicate_ids' => [
            'level' => 'A',
            'guideline' => '4.1.2',
            'description' => 'Name, Role, Value',
            'auto_fix' => false,
        ],
        'empty_buttons' => [
            'level' => 'A',
            'guideline' => '4.1.2',
            'description' => 'Name, Role, Value',
            'auto_fix' => false,
        ],
        'color_contrast' => [
            'level' => 'AA',
            'guideline' => '1.4.3',
            'description' => 'Contrast (Minimum)',
            'auto_fix' => false,
        ],
        // New enhanced checks
        'aria_attributes' => [
            'level' => 'A',
            'guideline' => '4.1.2',
            'description' => 'ARIA Attributes',
            'auto_fix' => false,
        ],
        'focus_visible' => [
            'level' => 'AA',
            'guideline' => '2.4.7',
            'description' => 'Focus Visible',
            'auto_fix' => false,
        ],
        'skip_navigation' => [
            'level' => 'A',
            'guideline' => '2.4.1',
            'description' => 'Bypass Blocks',
            'auto_fix' => false,
        ],
        'video_captions' => [
            'level' => 'A',
            'guideline' => '1.2.2',
            'description' => 'Captions (Prerecorded)',
            'auto_fix' => false,
        ],
        'audio_descriptions' => [
            'level' => 'AA',
            'guideline' => '1.2.5',
            'description' => 'Audio Description',
            'auto_fix' => false,
        ],
        'landmark_regions' => [
            'level' => 'A',
            'guideline' => '1.3.1',
            'description' => 'Landmark Regions',
            'auto_fix' => false,
        ],
        'focus_order' => [
            'level' => 'A',
            'guideline' => '2.4.3',
            'description' => 'Focus Order',
            'auto_fix' => false,
        ],
        'focus_not_obscured' => [
            'level' => 'AA',
            'guideline' => '2.4.11',
            'description' => 'Focus Not Obscured (Minimum)',
            'auto_fix' => false,
        ],
        'dragging_movements' => [
            'level' => 'AA',
            'guideline' => '2.5.7',
            'description' => 'Dragging Movements',
            'auto_fix' => false,
        ],
        'target_size' => [
            'level' => 'AA',
            'guideline' => '2.5.8',
            'description' => 'Target Size (Minimum)',
            'auto_fix' => false,
        ],
        'consistent_help' => [
            'level' => 'A',
            'guideline' => '3.2.6',
            'description' => 'Consistent Help',
            'auto_fix' => false,
        ],
        'redundant_entry' => [
            'level' => 'A',
            'guideline' => '3.3.7',
            'description' => 'Redundant Entry',
            'auto_fix' => false,
        ],
        'accessible_authentication' => [
            'level' => 'AA',
            'guideline' => '3.3.8',
            'description' => 'Accessible Authentication (Minimum)',
            'auto_fix' => false,
        ],
    ];

    /**
     * Initialize module
     */
    public function __construct() {
        global $wpdb;
        $this->table_name = $wpdb->prefix . 'prorank_accessibility_issues';
        // Parent constructor not needed
    }

    /**
     * Initialize module hooks
     *
     * @return void
     */
    public function init_hooks(): void {
        // Always register REST endpoints so the UI doesn't break
        // License check will be done in the permission callbacks
        add_action('rest_api_init', [$this, 'register_rest_routes']);

        // Add admin scripts for Node.js bridge
        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);

        // Only proceed with full initialization if enabled
        if (!$this->is_enabled()) {
            return;
        }

        add_action('init', [$this, 'maybe_create_table']);
        add_action(self::ACTION_SCAN_SITE, [$this, 'scan_site']);
        add_action(self::ACTION_SCAN_PAGE, [$this, 'scan_page'], 10, 2);
    }

    /**
     * Create database table if it doesn't exist
     *
     * @return void
     */
    public function maybe_create_table(): void {
        global $wpdb;

        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS {$this->table_name} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            post_id bigint(20) unsigned NOT NULL,
            url varchar(255) NOT NULL,
            scan_id varchar(32) NOT NULL,
            check_type varchar(50) NOT NULL,
            guideline varchar(10) NOT NULL,
            level varchar(5) NOT NULL,
            severity enum('error','warning','notice') NOT NULL DEFAULT 'warning',
            message text NOT NULL,
            element text,
            context text,
            timestamp datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY post_id (post_id),
            KEY scan_id (scan_id),
            KEY check_type (check_type),
            KEY severity (severity),
            KEY timestamp (timestamp)
        ) $charset_collate;";

        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        dbDelta($sql);
    }

    /**
     * Register REST routes
     *
     * @return void
     */
    public function register_rest_routes(): void {
        // Start accessibility scan
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/scan',
            [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'rest_start_scan'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                    'args' => [
                        'post_id' => [
                            'validate_callback' => function($param) {
                                return is_numeric($param);
                            }
                        ],
                    ],
                ],
            ]
        );

        // Get scan status
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/status',
            [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'rest_get_status'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                ],
            ]
        );

        // Get accessibility issues
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/issues',
            [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'rest_get_issues'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                    'args' => [
                        'post_id' => [
                            'validate_callback' => function($param) {
                                return empty($param) || is_numeric($param);
                            }
                        ],
                        'severity' => [
                            'default' => 'all',
                            'validate_callback' => function($param) {
                                return in_array($param, ['all', 'error', 'warning', 'notice']);
                            }
                        ],
                        'page' => [
                            'default' => 1,
                            'validate_callback' => function($param) {
                                return is_numeric($param) && $param > 0;
                            }
                        ],
                        'per_page' => [
                            'default' => 50,
                            'validate_callback' => function($param) {
                                return is_numeric($param) && $param > 0 && $param <= 100;
                            }
                        ],
                    ],
                ],
            ]
        );

        // Get issue summary
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/summary',
            [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'rest_get_summary'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                ],
            ]
        );

        // Export accessibility report
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/export',
            [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'rest_export_report'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                    'args' => [
                        'format' => [
                            'default' => 'csv',
                            'validate_callback' => function($param) {
                                return in_array($param, ['csv', 'json', 'pdf']);
                            }
                        ],
                    ],
                ],
            ]
        );

        // Auto-fix issues
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/auto-fix',
            [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'rest_auto_fix'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                    'args' => [
                        'issue_ids' => [
                            'required' => true,
                            'validate_callback' => function($param) {
                                return is_array($param);
                            }
                        ],
                    ],
                ],
            ]
        );

        // Get/Set accessibility settings
        register_rest_route(
            'prorank-seo/v1',
            '/accessibility/settings',
            [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'rest_get_settings'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                ],
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'rest_update_settings'],
                    'permission_callback' => [$this, 'rest_permission_check'],
                ],
            ]
        );
    }

    /**
     * REST permission check
     *
     * @return bool|\WP_Error
     */
    public function rest_permission_check() {
        if (!current_user_can('edit_posts')) {
            return new \WP_Error(
                'rest_forbidden',
                __('You do not have permission to access this resource.', 'prorank-seo'),
                ['status' => 403]
            );
        }

        return true;
    }

    /**
     * Enqueue admin scripts
     *
     * @param string $hook Hook suffix
     * @return void
     */
    public function enqueue_admin_scripts(string $hook): void {
        // Only load on our pages
        if (strpos($hook, 'prorank-') === false) {
            return;
        }

        // Add inline script for Node.js bridge info
        wp_add_inline_script('prorank-seo-admin',
            'window.prorankAccessibility = ' . json_encode([
                'nodebridge_available' => $this->is_node_bridge_available(),
                'axe_version' => '4.11.4',
            ]),
            'before'
        );
    }

    /**
     * Check if Node.js bridge is available
     *
     * @return bool
     */
    private function is_node_bridge_available(): bool {
        // Check if Node.js bridge service is running
        // In a real implementation, this would check for the service
        return false; // For now, we'll use PHP-only checks
    }

    /**
     * Start site-wide accessibility scan
     *
     * @return void
     */
    public function scan_site(): string {
        $settings = $this->get_scan_settings();

        // Generate scan ID
        $scan_id = wp_generate_password(32, false);

        // Store scan info
        update_option('prorank_accessibility_current_scan', [
            'scan_id' => $scan_id,
            'status' => 'running',
            'started_at' => time(),
            'total_pages' => 0,
            'scanned_pages' => 0,
        ]);

        // Get pages to scan
        $post_types = $this->get_scannable_post_types($settings);
        $args = [
            'post_type' => !empty($post_types) ? $post_types : ['post', 'page'],
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'fields' => 'ids',
            'orderby' => 'ID',
            'order' => 'ASC',
        ];

        if (!empty($settings['exclude_post_ids']) && is_array($settings['exclude_post_ids'])) {
            $args['post__not_in'] = array_map('intval', $settings['exclude_post_ids']);
        }

        $posts = get_posts($args);

        // Update total pages
        $scan_info = get_option('prorank_accessibility_current_scan');
        $scan_info['total_pages'] = count($posts);
        update_option('prorank_accessibility_current_scan', $scan_info);

        if (empty($posts)) {
            $scan_info['status'] = 'completed';
            $scan_info['completed_at'] = time();
            $scan_info['message'] = __('Scan completed', 'prorank-seo');
            update_option('prorank_accessibility_current_scan', $scan_info);
            $this->store_last_completed_scan_info($scan_info);
            return $scan_id;
        }

        // Schedule scanning for each page
        foreach ($posts as $post_id) {
            as_enqueue_async_action(
                self::ACTION_SCAN_PAGE,
                ['post_id' => $post_id, 'scan_id' => $scan_id]
            );
        }

        update_option('prorank_accessibility_last_progress', [
            'scan_id' => $scan_id,
            'scanned_pages' => 0,
            'time' => time(),
        ], false);

        return $scan_id;
    }

    /**
     * Scan single page
     *
     * @param int $post_id Post ID
     * @param string $scan_id Scan ID
     * @return void
     */
    public function scan_page(int $post_id, string $scan_id = ''): void {
        // Get post content
        $post = get_post($post_id);
        if (!$post) {
            return;
        }

        $settings = $this->get_scan_settings();
        if ($this->is_post_excluded($post, $settings)) {
            return;
        }

        // Get the URL
        $url = get_permalink($post_id);
        if (empty($url)) {
            return;
        }

        $page_dom = $this->create_dom_document($this->get_rendered_page_html($post, $url));
        $content_dom = $this->create_dom_document($this->get_content_fragment_html($post));
        $enabled_checks = array_fill_keys($settings['check_types'] ?? array_keys(self::WCAG_CHECKS), true);

        if (!empty($enabled_checks['page_title'])) {
            $this->check_page_title($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['language_attribute'])) {
            $this->check_language_attribute($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['images_alt_text'])) {
            $this->check_images_alt_text($content_dom, $post_id, $url, $scan_id, $page_dom);
        }
        if (!empty($enabled_checks['heading_hierarchy'])) {
            $this->check_heading_hierarchy($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['link_text'])) {
            $this->check_link_text($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['form_labels'])) {
            $this->check_form_labels($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['table_headers'])) {
            $this->check_table_headers($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['duplicate_ids'])) {
            $this->check_duplicate_ids($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['empty_buttons'])) {
            $this->check_empty_buttons($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['color_contrast'])) {
            $this->check_color_contrast($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['aria_attributes'])) {
            $this->check_aria_attributes($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['focus_visible'])) {
            $this->check_focus_visible($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['landmark_regions'])) {
            $this->check_landmark_regions($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['focus_order'])) {
            $this->check_focus_order($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['focus_not_obscured'])) {
            $this->check_focus_not_obscured($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['skip_navigation'])) {
            $this->check_skip_navigation($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['video_captions'])) {
            $this->check_video_captions($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['audio_descriptions'])) {
            $this->check_audio_descriptions($content_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['dragging_movements'])) {
            $this->check_dragging_movements($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['target_size'])) {
            $this->check_target_size_minimum($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['consistent_help'])) {
            $this->check_consistent_help($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['redundant_entry'])) {
            $this->check_redundant_entry($page_dom, $post_id, $url, $scan_id);
        }
        if (!empty($enabled_checks['accessible_authentication'])) {
            $this->check_accessible_authentication($page_dom, $post_id, $url, $scan_id);
        }

        // Update scan progress (only if not already completed)
        if (!empty($scan_id)) {
            $this->update_scan_progress($scan_id);
        }
    }

    /**
     * Get normalized scan settings.
     *
     * @return array
     */
    private function get_scan_settings(): array {
        $settings = get_option('prorank_accessibility_settings', []);
        if (!is_array($settings)) {
            $settings = [];
        }

        return $this->normalize_settings($settings);
    }

    /**
     * Update scan progress for the current run.
     *
     * @param string $scan_id Scan ID.
     * @return void
     */
    private function update_scan_progress(string $scan_id): void {
        $scan_info = $this->get_current_scan_info(false);
        if (empty($scan_info['scan_id']) || $scan_info['scan_id'] !== $scan_id || ($scan_info['status'] ?? '') === 'completed') {
            return;
        }

        $total_pages = max(0, (int) ($scan_info['total_pages'] ?? 0));
        $remaining = $this->count_remaining_scan_actions($scan_id);
        $current_scanned = max(0, (int) ($scan_info['scanned_pages'] ?? 0));
        $derived_scanned = $total_pages > 0 ? max(0, $total_pages - $remaining) : $current_scanned;

        $scan_info['scanned_pages'] = $total_pages > 0
            ? min($total_pages, max($current_scanned + 1, $derived_scanned))
            : ($current_scanned + 1);

        if ($total_pages > 0 && $remaining <= 1 && $scan_info['scanned_pages'] >= $total_pages) {
            $scan_info['scanned_pages'] = $total_pages;
            $scan_info['status'] = 'completed';
            $scan_info['completed_at'] = time();
            $scan_info['message'] = __('Scan completed', 'prorank-seo');
            $this->store_last_completed_scan_info($scan_info);
        }

        update_option('prorank_accessibility_current_scan', $scan_info);
        update_option('prorank_accessibility_last_progress', [
            'scan_id' => $scan_id,
            'scanned_pages' => (int) $scan_info['scanned_pages'],
            'time' => time(),
        ], false);
    }

    /**
     * Get current scan info, optionally refreshing status from the queue.
     *
     * @param bool $refresh Whether to refresh queue-backed progress.
     * @return array
     */
    private function get_current_scan_info(bool $refresh = true): array {
        $scan_info = get_option('prorank_accessibility_current_scan', []);
        if (!is_array($scan_info)) {
            return [];
        }

        return $refresh ? $this->refresh_scan_info($scan_info) : $scan_info;
    }

    /**
     * Refresh stored scan info using Action Scheduler state.
     *
     * @param array $scan_info Current scan info.
     * @return array
     */
    private function refresh_scan_info(array $scan_info): array {
        $scan_id = (string) ($scan_info['scan_id'] ?? '');
        $status = (string) ($scan_info['status'] ?? '');
        $total_pages = max(0, (int) ($scan_info['total_pages'] ?? 0));

        if ($scan_id === '' || $total_pages <= 0 || $status === 'completed') {
            return $scan_info;
        }

        $remaining = $this->count_remaining_scan_actions($scan_id);
        $derived_scanned = max(0, $total_pages - $remaining);
        $current_scanned = max(0, (int) ($scan_info['scanned_pages'] ?? 0));
        $new_scanned = min($total_pages, max($current_scanned, $derived_scanned));

        $changed = false;
        if ($new_scanned !== $current_scanned) {
            $scan_info['scanned_pages'] = $new_scanned;
            $changed = true;
        }

        if ($remaining <= 0) {
            $scan_info['scanned_pages'] = $total_pages;
            $scan_info['status'] = 'completed';
            $scan_info['completed_at'] = (int) ($scan_info['completed_at'] ?? time());
            $scan_info['message'] = __('Scan completed', 'prorank-seo');
            $this->store_last_completed_scan_info($scan_info);
            $changed = true;
        }

        if ($changed) {
            update_option('prorank_accessibility_current_scan', $scan_info);
        }

        return $scan_info;
    }

    /**
     * Persist the last completed scan info for reporting while a new scan runs.
     *
     * @param array $scan_info Completed scan info.
     * @return void
     */
    private function store_last_completed_scan_info(array $scan_info): void {
        if (empty($scan_info['scan_id']) || ($scan_info['status'] ?? '') !== 'completed') {
            return;
        }

        update_option('prorank_accessibility_last_completed_scan', [
            'scan_id' => (string) $scan_info['scan_id'],
            'status' => 'completed',
            'completed_at' => (int) ($scan_info['completed_at'] ?? time()),
            'total_pages' => (int) ($scan_info['total_pages'] ?? 0),
            'scanned_pages' => (int) ($scan_info['scanned_pages'] ?? 0),
            'message' => (string) ($scan_info['message'] ?? __('Scan completed', 'prorank-seo')),
        ]);
    }

    /**
     * Get the most recent completed scan info.
     *
     * @return array
     */
    private function get_last_completed_scan_info(): array {
        $current_scan = get_option('prorank_accessibility_current_scan', []);
        if (is_array($current_scan) && !empty($current_scan['scan_id']) && (($current_scan['status'] ?? '') === 'completed')) {
            $this->store_last_completed_scan_info($current_scan);
            return $current_scan;
        }

        $stored = get_option('prorank_accessibility_last_completed_scan', []);
        if (is_array($stored) && !empty($stored['scan_id'])) {
            return $stored;
        }

        global $wpdb;
        $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$this->table_name}'") === $this->table_name;
        if (!$table_exists) {
            return [];
        }

        $row = $wpdb->get_row(
            "SELECT scan_id, MAX(timestamp) AS completed_at
            FROM {$this->table_name}
            WHERE scan_id <> ''
            GROUP BY scan_id
            ORDER BY MAX(timestamp) DESC
            LIMIT 1"
        );

        if (!$row || empty($row->scan_id)) {
            return [];
        }

        return [
            'scan_id' => (string) $row->scan_id,
            'status' => 'completed',
            'completed_at' => strtotime((string) $row->completed_at) ?: 0,
        ];
    }

    /**
     * Get the scan info that should power report data.
     *
     * @return array
     */
    private function get_report_scan_info(): array {
        $current_scan = $this->get_current_scan_info();
        if (!empty($current_scan['scan_id']) && (($current_scan['status'] ?? '') === 'completed')) {
            return $current_scan;
        }

        return $this->get_last_completed_scan_info();
    }

    /**
     * Count remaining queued scan actions for a scan.
     *
     * @param string $scan_id Scan ID.
     * @return int
     */
    private function count_remaining_scan_actions(string $scan_id): int {
        if ($scan_id === '') {
            return 0;
        }

        global $wpdb;
        $actions_table = $wpdb->prefix . 'actionscheduler_actions';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$actions_table}'") !== $actions_table) {
            return 0;
        }

        $args_pattern = '%' . $wpdb->esc_like('"scan_id":"' . $scan_id . '"') . '%';

        return (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$actions_table}
            WHERE hook = %s
              AND status IN ('pending', 'in-progress')
              AND args LIKE %s",
            self::ACTION_SCAN_PAGE,
            $args_pattern
        ));
    }

    /**
     * Cancel stale pending accessibility scan actions before starting a new run.
     *
     * @return void
     */
    private function cancel_pending_scan_actions(): void {
        global $wpdb;

        $actions_table = $wpdb->prefix . 'actionscheduler_actions';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$actions_table}'") !== $actions_table) {
            return;
        }

        $wpdb->query($wpdb->prepare(
            "UPDATE {$actions_table}
            SET status = %s
            WHERE hook IN (%s, %s)
              AND status = %s",
            'canceled',
            self::ACTION_SCAN_SITE,
            self::ACTION_SCAN_PAGE,
            'pending'
        ));
    }

    /**
     * Get pending action IDs for the current scan.
     *
     * @param string $scan_id Scan ID.
     * @param int    $limit   Max actions.
     * @return int[]
     */
    private function get_pending_scan_action_ids(string $scan_id, int $limit = 5): array {
        if ($scan_id === '' || $limit < 1) {
            return [];
        }

        global $wpdb;
        $actions_table = $wpdb->prefix . 'actionscheduler_actions';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$actions_table}'") !== $actions_table) {
            return [];
        }

        $args_pattern = '%' . $wpdb->esc_like('"scan_id":"' . $scan_id . '"') . '%';

        return array_map('intval', $wpdb->get_col($wpdb->prepare(
            "SELECT action_id FROM {$actions_table}
            WHERE hook = %s
              AND status = %s
              AND args LIKE %s
            ORDER BY action_id ASC
            LIMIT %d",
            self::ACTION_SCAN_PAGE,
            'pending',
            $args_pattern,
            $limit
        )));
    }

    /**
     * Process a small inline batch when background workers are not advancing.
     *
     * @param string $scan_id Scan ID.
     * @param int    $limit   Batch size.
     * @return void
     */
    private function process_inline_scan_batch(string $scan_id, int $limit = 5): void {
        if ($scan_id === '' || !class_exists('\ActionScheduler')) {
            return;
        }

        $store = \ActionScheduler::store();
        $action_ids = $this->get_pending_scan_action_ids($scan_id, $limit);

        foreach ($action_ids as $action_id) {
            try {
                $action = $store->fetch_action($action_id);
                if (!$action || !method_exists($action, 'get_args')) {
                    $store->cancel_action($action_id);
                    continue;
                }

                $args = $action->get_args();
                $post_id = isset($args['post_id']) ? (int) $args['post_id'] : 0;
                if ($post_id < 1) {
                    $store->cancel_action($action_id);
                    continue;
                }

                $this->scan_page($post_id, $scan_id);
                $store->mark_complete($action_id);
            } catch (\Throwable $e) {
                // Leave the action pending if the inline fallback fails.
            }
        }
    }

    /**
     * Run inline fallback processing when queue progress is stalled.
     *
     * @param string $scan_id Scan ID.
     * @return void
     */
    private function maybe_run_inline_processing(string $scan_id): void {
        if ($scan_id === '') {
            return;
        }

        $scan_info = $this->get_current_scan_info(false);
        if (($scan_info['status'] ?? '') !== 'running') {
            return;
        }

        $last_progress = get_option('prorank_accessibility_last_progress', []);
        $last_scan_id = is_array($last_progress) ? (string) ($last_progress['scan_id'] ?? '') : '';
        $last_time = is_array($last_progress) ? (int) ($last_progress['time'] ?? 0) : 0;

        $should_inline = (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) || !function_exists('as_enqueue_async_action');

        if (!$should_inline) {
            $should_inline = ($last_scan_id !== $scan_id) || $last_time === 0 || (time() - $last_time) >= 10;
        }

        if (!$should_inline) {
            return;
        }

        $this->process_inline_scan_batch($scan_id, 5);
        $this->refresh_scan_info($this->get_current_scan_info(false));
    }

    /**
     * Determine if a post is excluded from scanning.
     *
     * @param \WP_Post $post Post object.
     * @param array    $settings Scan settings.
     * @return bool
     */
    private function is_post_excluded(\WP_Post $post, array $settings): bool {
        $excluded_post_ids = array_map('intval', $settings['exclude_post_ids'] ?? []);
        if (in_array((int) $post->ID, $excluded_post_ids, true)) {
            return true;
        }

        $excluded_post_types = array_map('strval', $settings['exclude_post_types'] ?? []);
        if (in_array((string) $post->post_type, $this->get_system_excluded_post_types(), true)) {
            return true;
        }

        if (in_array((string) $post->post_type, $excluded_post_types, true)) {
            return true;
        }

        return false;
    }

    /**
     * Get public post types that should be scanned.
     *
     * @param array $settings Scan settings.
     * @return array
     */
    private function get_scannable_post_types(array $settings): array {
        $post_types = get_post_types(['publicly_queryable' => true], 'names');
        foreach ($this->get_system_excluded_post_types() as $post_type) {
            unset($post_types[$post_type]);
        }

        $excluded_post_types = array_map('strval', $settings['exclude_post_types'] ?? []);

        return array_values(array_diff($post_types, $excluded_post_types));
    }

    /**
     * Post types that are not real public content pages for accessibility crawls.
     *
     * @return string[]
     */
    private function get_system_excluded_post_types(): array {
        return [
            'attachment',
            'elementor_library',
            'wp_template',
            'wp_template_part',
            'wp_global_styles',
            'wp_navigation',
            'nav_menu_item',
            'revision',
            'custom_css',
            'customize_changeset',
            'acf-field',
            'acf-field-group',
            'oembed_cache',
            'user_request',
        ];
    }

    /**
     * Get the rendered page HTML for a post, with a safe fallback.
     *
     * @param \WP_Post $post Post object.
     * @param string   $url Public URL.
     * @return string
     */
    private function get_rendered_page_html(\WP_Post $post, string $url): string {
        $request_args = [
            'timeout' => 15,
            'redirection' => 5,
            'headers' => [
                'User-Agent' => 'ProRank Accessibility Scanner/1.0',
            ],
        ];

        for ($attempt = 0; $attempt < 2; $attempt++) {
            $response = wp_remote_get($url, $request_args);
            if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) < 400) {
                $html = (string) wp_remote_retrieve_body($response);
                if ($html !== '') {
                    return $html;
                }
            }
        }

        return $this->build_fallback_document_html($post);
    }

    /**
     * Build a fallback HTML document from the post content.
     *
     * @param \WP_Post $post Post object.
     * @return string
     */
    private function build_fallback_document_html(\WP_Post $post): string {
        $title = $post->post_title ?: __('Untitled Page', 'prorank-seo');
        $content = $this->get_content_fragment_html($post);

        return sprintf(
            '<!DOCTYPE html><html lang="%1$s"><head><meta charset="utf-8"><title>%2$s</title></head><body>%3$s</body></html>',
            esc_attr(get_bloginfo('language')),
            esc_html($title),
            $content
        );
    }

    /**
     * Get the post content fragment used for content-level checks.
     *
     * @param \WP_Post $post Post object.
     * @return string
     */
    private function get_content_fragment_html(\WP_Post $post): string {
        return (string) apply_filters('the_content', $post->post_content);
    }

    /**
     * Create a DOM document from HTML.
     *
     * @param string $html HTML markup.
     * @return \DOMDocument
     */
    private function create_dom_document(string $html): \DOMDocument {
        $dom = new \DOMDocument();

        if ($html === '') {
            $html = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>';
        }

        $previous = libxml_use_internal_errors(true);
        @$dom->loadHTML($html);
        libxml_clear_errors();
        libxml_use_internal_errors($previous);

        return $dom;
    }

    /**
     * Check images for alt text
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_images_alt_text(\DOMDocument $dom, int $post_id, string $url, string $scan_id, ?\DOMDocument $rendered_dom = null): void {
        $xpath = new \DOMXPath($dom);
        $images = $xpath->query('//img');
        $rendered_alt_by_src = $rendered_dom instanceof \DOMDocument ? $this->get_rendered_image_alt_map($rendered_dom) : [];

        foreach ($images as $img) {
            $alt = $img->getAttribute('alt');
            $src = $img->getAttribute('src');
            $role = strtolower($img->getAttribute('role'));

            if ($this->should_skip_decorative_or_placeholder_image($img, $src, $alt)) {
                continue;
            }

            $normalized_src = $this->normalize_image_src($src);
            if ($normalized_src !== '' && isset($rendered_alt_by_src[$normalized_src]) && trim($rendered_alt_by_src[$normalized_src]) !== '') {
                continue;
            }

            // Skip decorative images with empty alt
            if (($role === 'presentation' || $role === 'none') && $alt === '') {
                continue;
            }

            // Check for missing alt text
            if (!$img->hasAttribute('alt')) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'images_alt_text',
                    'error',
                    __('Image missing alt attribute', 'prorank-seo'),
                    $this->get_element_html($img),
                    ['src' => $src]
                );
            } elseif (trim($alt) === '' && $role !== 'presentation' && $role !== 'none') {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'images_alt_text',
                    'warning',
                    __('Image has empty alt text', 'prorank-seo'),
                    $this->get_element_html($img),
                    ['src' => $src]
                );
            } elseif (strlen($alt) > 125) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'images_alt_text',
                    'notice',
                    __('Image alt text is too long (over 125 characters)', 'prorank-seo'),
                    $this->get_element_html($img),
                    ['alt' => $alt, 'length' => strlen($alt)]
                );
            }
        }
    }

    /**
     * Build a src => alt map from rendered frontend HTML.
     *
     * @param \DOMDocument $dom Rendered DOM.
     * @return array<string, string>
     */
    private function get_rendered_image_alt_map(\DOMDocument $dom): array {
        $xpath = new \DOMXPath($dom);
        $images = $xpath->query('//img[@src]');
        $map = [];

        foreach ($images as $image) {
            if (!$image instanceof \DOMElement) {
                continue;
            }

            $src = $this->normalize_image_src($image->getAttribute('src'));
            if ($src === '') {
                continue;
            }

            $map[$src] = $image->getAttribute('alt');
        }

        return $map;
    }

    /**
     * Normalize image URLs enough to compare content markup with rendered markup.
     *
     * @param string $src Image source.
     * @return string
     */
    private function normalize_image_src(string $src): string {
        $src = trim(html_entity_decode($src));
        if ($src === '') {
            return '';
        }

        $src = strtok($src, '?') ?: $src;
        $src = preg_replace('/-\d+x\d+(?=\.(?:jpe?g|png|gif|webp|avif|svg)$)/i', '', $src) ?: $src;

        $parts = wp_parse_url($src);
        if (is_array($parts) && !empty($parts['path'])) {
            return strtolower((string) $parts['path']);
        }

        return strtolower($src);
    }

    /**
     * Skip images that are explicitly decorative or known non-content placeholders.
     *
     * @param \DOMElement $img Image element.
     * @param string      $src Image source.
     * @param string      $alt Alt text.
     * @return bool
     */
    private function should_skip_decorative_or_placeholder_image(\DOMElement $img, string $src, string $alt): bool {
        $role = strtolower($img->getAttribute('role'));
        $aria_hidden = strtolower($img->getAttribute('aria-hidden'));

        if (($role === 'presentation' || $role === 'none' || $aria_hidden === 'true') && trim($alt) === '') {
            return true;
        }

        $src_lower = strtolower($src);
        if ($src_lower === '') {
            return false;
        }

        return (
            strpos($src_lower, 'elementor/assets/images/placeholder') !== false ||
            strpos($src_lower, 'placeholder.png') !== false ||
            strpos($src_lower, 'data:image/gif') === 0 ||
            preg_match('/(?:spacer|tracking|pixel)\.(?:gif|png|webp)(?:\?|$)/i', $src_lower) === 1
        );
    }

    /**
     * Check page title.
     *
     * @param \DOMDocument $dom DOM document.
     * @param int          $post_id Post ID.
     * @param string       $url URL.
     * @param string       $scan_id Scan ID.
     * @return void
     */
    private function check_page_title(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $titles = $xpath->query('//head/title');

        if (!$titles instanceof \DOMNodeList || $titles->length === 0) {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'page_title',
                'error',
                __('Page is missing a title element', 'prorank-seo')
            );

            return;
        }

        $title_element = $titles->item(0);
        $title = trim($title_element ? $title_element->textContent : '');

        if ($title === '') {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'page_title',
                'error',
                __('Page title is empty', 'prorank-seo'),
                $title_element instanceof \DOMElement ? $this->get_element_html($title_element) : ''
            );
        }
    }

    /**
     * Check language attribute on the html element.
     *
     * @param \DOMDocument $dom DOM document.
     * @param int          $post_id Post ID.
     * @param string       $url URL.
     * @param string       $scan_id Scan ID.
     * @return void
     */
    private function check_language_attribute(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $html = $dom->getElementsByTagName('html')->item(0);
        if (!$html instanceof \DOMElement) {
            return;
        }

        if (trim($html->getAttribute('lang')) === '') {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'language_attribute',
                'error',
                __('Page is missing a language attribute on the html element', 'prorank-seo'),
                $this->get_element_html($html)
            );
        }
    }

    /**
     * Check heading hierarchy
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_heading_hierarchy(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $headings = [];

        // Collect all headings
        for ($i = 1; $i <= 6; $i++) {
            $h_tags = $xpath->query('//h' . $i);
            foreach ($h_tags as $heading) {
                $headings[] = [
                    'level' => $i,
                    'text' => trim($heading->textContent),
                    'element' => $heading,
                ];
            }
        }

        // Check for issues
        if (empty($headings)) {
            return; // No headings to check
        }

        // Check for skipped levels
        $prev_level = 0;
        foreach ($headings as $heading) {
            if ($heading['level'] > $prev_level + 1 && $prev_level > 0) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'heading_hierarchy',
                    'error',
                    sprintf(
/* translators: %1$: value, %2$: value */
                        __('Heading hierarchy issue: H%1$d follows H%2$d', 'prorank-seo'),
                        $heading['level'],
                        $prev_level
                    ),
                    $this->get_element_html($heading['element']),
                    ['skipped_levels' => $heading['level'] - $prev_level - 1]
                );
            }
            $prev_level = $heading['level'];
        }

        // Check for empty headings
        foreach ($headings as $heading) {
            if (empty($heading['text'])) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'heading_hierarchy',
                    'error',
/* translators: %1$: value */
                    sprintf(__('Empty H%1$d heading', 'prorank-seo'), $heading['level']),
                    $this->get_element_html($heading['element'])
                );
            }
        }
    }

    /**
     * Check link text
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_link_text(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $links = $xpath->query('//a[@href]');

        $generic_texts = [
            'click here',
            'read more',
            'learn more',
            'more',
            'here',
            'link',
            'this',
        ];

        foreach ($links as $link) {
            if (!$link instanceof \DOMElement) {
                continue;
            }

            $text = $this->get_accessible_name($link, $xpath);
            $href = $link->getAttribute('href');

            // Check for empty link text
            if (empty($text)) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'link_text',
                    'error',
                    __('Link has no accessible name', 'prorank-seo'),
                    $this->get_element_html($link),
                    ['href' => $href]
                );
            } elseif (in_array(strtolower($text), $generic_texts)) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'link_text',
                    'warning',
                    __('Link uses generic text that doesn\'t describe its purpose', 'prorank-seo'),
                    $this->get_element_html($link),
                    ['text' => $text, 'href' => $href]
                );
            }
        }
    }

    /**
     * Get a practical accessible name for simple server-side checks.
     *
     * This is not a complete Accessible Name Computation implementation, but it
     * covers common WordPress output: aria-label, aria-labelledby, visible text,
     * title attributes, image alt text, and SVG title elements.
     *
     * @param \DOMElement $element Element to inspect.
     * @param \DOMXPath   $xpath XPath instance for the same document.
     * @return string
     */
    private function get_accessible_name(\DOMElement $element, \DOMXPath $xpath): string {
        $aria_label = trim($element->getAttribute('aria-label'));
        if ($aria_label !== '') {
            return $this->normalize_accessible_text($aria_label);
        }

        $labelledby = trim($element->getAttribute('aria-labelledby'));
        if ($labelledby !== '') {
            $label = $this->get_text_from_idrefs($labelledby, $xpath);
            if ($label !== '') {
                return $label;
            }
        }

        $text = $this->normalize_accessible_text($element->textContent);
        if ($text !== '') {
            return $text;
        }

        $title = trim($element->getAttribute('title'));
        if ($title !== '') {
            return $this->normalize_accessible_text($title);
        }

        $images = $xpath->query('.//img[@alt]', $element);
        foreach ($images as $image) {
            if ($image instanceof \DOMElement && trim($image->getAttribute('alt')) !== '') {
                return $this->normalize_accessible_text($image->getAttribute('alt'));
            }
        }

        $svg_titles = $xpath->query('.//*[local-name()="svg"]/*[local-name()="title"]', $element);
        foreach ($svg_titles as $svg_title) {
            $name = $this->normalize_accessible_text($svg_title->textContent);
            if ($name !== '') {
                return $name;
            }
        }

        return '';
    }

    /**
     * Resolve aria-labelledby style ID references.
     *
     * @param string    $idrefs Space-separated ID references.
     * @param \DOMXPath $xpath XPath instance.
     * @return string
     */
    private function get_text_from_idrefs(string $idrefs, \DOMXPath $xpath): string {
        $parts = preg_split('/\s+/', trim($idrefs)) ?: [];
        $texts = [];

        foreach ($parts as $id) {
            $id = trim($id);
            if ($id === '') {
                continue;
            }

            $nodes = $xpath->query('//*[@id=' . $this->xpath_literal($id) . ']');
            if ($nodes instanceof \DOMNodeList && $nodes->length > 0) {
                $texts[] = $nodes->item(0)->textContent;
            }
        }

        return $this->normalize_accessible_text(implode(' ', $texts));
    }

    /**
     * Normalize text used in accessibility checks.
     *
     * @param string $text Raw text.
     * @return string
     */
    private function normalize_accessible_text(string $text): string {
        return trim((string) preg_replace('/\s+/', ' ', html_entity_decode($text)));
    }

    /**
     * Escape a string as an XPath literal.
     *
     * @param string $value Value.
     * @return string
     */
    private function xpath_literal(string $value): string {
        if (strpos($value, '"') === false) {
            return '"' . $value . '"';
        }

        if (strpos($value, "'") === false) {
            return "'" . $value . "'";
        }

        return 'concat("' . str_replace('"', '", \'"\', "', $value) . '")';
    }

    /**
     * Check form labels
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_form_labels(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);

        // Check input elements
        $inputs = $xpath->query('//input[@type!="hidden" and @type!="submit" and @type!="button"]|//textarea|//select');

        foreach ($inputs as $input) {
            $id = $input->getAttribute('id');
            $name = $input->getAttribute('name');
            $type = $input->getAttribute('type') ?: $input->tagName;

            // Check for associated label
            $has_label = false;

            // Check for label with for attribute
            if (!empty($id)) {
                $labels = $xpath->query('//label[@for=' . $this->xpath_literal($id) . ']');
                if ($labels->length > 0) {
                    $has_label = true;
                }
            }

            // Check if input is wrapped in label
            if (!$has_label) {
                $parent = $input->parentNode;
                while ($parent && $parent->nodeType === XML_ELEMENT_NODE) {
                    if ($parent->tagName === 'label') {
                        $has_label = true;
                        break;
                    }
                    $parent = $parent->parentNode;
                }
            }

            // Check for aria-label or aria-labelledby
            if (!$has_label) {
                if ($input->hasAttribute('aria-label') || $input->hasAttribute('aria-labelledby')) {
                    $has_label = true;
                }
            }

            if (!$has_label) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'form_labels',
                    'error',
/* translators: %1$: value */
                    sprintf(__('Form field (%1$s) missing label', 'prorank-seo'), $type),
                    $this->get_element_html($input),
                    ['name' => $name, 'type' => $type]
                );
            }
        }
    }

    /**
     * Check table headers
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_table_headers(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $tables = $xpath->query('//table');

        foreach ($tables as $table) {
            // Skip tables used for layout (role="presentation")
            if ($table->getAttribute('role') === 'presentation') {
                continue;
            }

            // Check for th elements
            $headers = $xpath->query('.//th', $table);
            $cells = $xpath->query('.//td', $table);

            if ($headers->length === 0 && $cells->length > 0) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'table_headers',
                    'error',
                    __('Data table missing header cells', 'prorank-seo'),
                    $this->get_element_html($table)
                );
            }

            // Check for scope attributes on headers
            foreach ($headers as $header) {
                if (!$header->hasAttribute('scope')) {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'table_headers',
                        'warning',
                        __('Table header missing scope attribute', 'prorank-seo'),
                        $this->get_element_html($header)
                    );
                }
            }
        }
    }

    /**
     * Check for duplicate IDs
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_duplicate_ids(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $elements_with_id = $xpath->query('//*[@id]');

        $ids = [];
        foreach ($elements_with_id as $element) {
            $id = $element->getAttribute('id');
            if (!isset($ids[$id])) {
                $ids[$id] = [];
            }
            $ids[$id][] = $element;
        }

        // Check for duplicates
        foreach ($ids as $id => $elements) {
            if (count($elements) > 1) {
                $is_accessibility_relevant = $this->is_duplicate_id_accessibility_relevant($id, $elements, $xpath);
                $severity = $is_accessibility_relevant ? 'error' : 'notice';
                $message = $is_accessibility_relevant
                    ? sprintf(__('Duplicate referenced ID "%1$s" can break accessible names or relationships', 'prorank-seo'), $id)
                    : sprintf(__('Duplicate ID "%1$s" found (best-practice cleanup)', 'prorank-seo'), $id);

                foreach ($elements as $element) {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'duplicate_ids',
                        $severity,
                        $message,
                        $this->get_element_html($element),
                        ['id' => $id, 'count' => count($elements), 'wcag_relevant' => $is_accessibility_relevant]
                    );
                }
            }
        }
    }

    /**
     * Determine whether duplicate IDs affect accessibility relationships.
     *
     * WCAG 2.2 removed 4.1.1 Parsing, so generic duplicate IDs are not treated
     * as WCAG failures. They are errors only when referenced by accessibility
     * relationships or used on interactive elements.
     *
     * @param string $id Duplicate ID.
     * @param array  $elements Elements sharing the ID.
     * @param \DOMXPath $xpath XPath instance.
     * @return bool
     */
    private function is_duplicate_id_accessibility_relevant(string $id, array $elements, \DOMXPath $xpath): bool {
        $literal = $this->xpath_literal($id);
        $reference_query = sprintf(
            '//*[contains(concat(" ", normalize-space(@aria-labelledby), " "), concat(" ", %1$s, " ")) or contains(concat(" ", normalize-space(@aria-describedby), " "), concat(" ", %1$s, " ")) or @for=%1$s or contains(concat(" ", normalize-space(@headers), " "), concat(" ", %1$s, " "))]',
            $literal
        );

        $references = $xpath->query($reference_query);
        if ($references instanceof \DOMNodeList && $references->length > 0) {
            return true;
        }

        foreach ($elements as $element) {
            if ($element instanceof \DOMElement && $this->is_interactive_element($element)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check whether an element is an interactive/focusable control.
     *
     * @param \DOMElement $element Element.
     * @return bool
     */
    private function is_interactive_element(\DOMElement $element): bool {
        $tag = strtolower($element->tagName);
        $role = strtolower($element->getAttribute('role'));

        if (in_array($tag, ['a', 'button', 'input', 'select', 'textarea', 'summary'], true)) {
            return $tag !== 'a' || $element->hasAttribute('href');
        }

        if ($element->hasAttribute('tabindex')) {
            return true;
        }

        return in_array($role, ['button', 'checkbox', 'combobox', 'link', 'menuitem', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox'], true);
    }

    /**
     * Check for empty buttons
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_empty_buttons(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $buttons = $xpath->query('//button|//input[@type="button" or @type="submit"]');

        foreach ($buttons as $button) {
            if (!$button instanceof \DOMElement) {
                continue;
            }

            $text = $button->tagName === 'button'
                ? $this->get_accessible_name($button, $xpath)
                : $this->normalize_accessible_text($button->getAttribute('value') ?: $this->get_accessible_name($button, $xpath));

            if (empty($text)) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'empty_buttons',
                    'error',
                    __('Button has no accessible text', 'prorank-seo'),
                    $this->get_element_html($button)
                );
            }
        }
    }

    /**
     * Add accessibility issue to database
     *
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @param string $check_type Check type
     * @param string $severity Severity
     * @param string $message Message
     * @param string $element Element HTML
     * @param array $context Additional context
     * @return void
     */
    private function add_issue(
        int $post_id,
        string $url,
        string $scan_id,
        string $check_type,
        string $severity,
        string $message,
        string $element = '',
        array $context = []
    ): void {
        global $wpdb;

        // Check if table exists, create if not
        $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$this->table_name}'");
        if (!$table_exists) {
            $this->maybe_create_table();
        }

        $check_info = self::WCAG_CHECKS[$check_type] ?? [];

        $result = $wpdb->insert(
            $this->table_name,
            [
                'post_id' => $post_id,
                'url' => $url,
                'scan_id' => $scan_id,
                'check_type' => $check_type,
                'guideline' => $check_info['guideline'] ?? '',
                'level' => $check_info['level'] ?? 'A',
                'severity' => $severity,
                'message' => $message,
                'element' => $element,
                'context' => !empty($context) ? wp_json_encode($context) : null,
            ],
            ['%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
        );

        // Log any database errors
        if ($result === false && $wpdb->last_error) {
            prorank_log('[ProRank Accessibility] Database insert failed: ' . $wpdb->last_error);
        }
    }

    /**
     * Get element HTML for context
     *
     * @param \DOMElement $element Element
     * @return string HTML
     */
    private function get_element_html(\DOMElement $element): string {
        $doc = new \DOMDocument();
        $doc->appendChild($doc->importNode($element, true));
        $html = $doc->saveHTML();

        // Clean up and truncate if too long
        $html = trim(str_replace(['<html><body>', '</body></html>'], '', $html));
        if (strlen($html) > 500) {
            $html = substr($html, 0, 497) . '...';
        }

        return $html;
    }

    /**
     * Get accessibility issues
     *
     * @param array $filters Filters
     * @return array Issues data
     */
    public function get_issues(array $filters = []): array {
        global $wpdb;

        $this->maybe_create_table();

        $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$this->table_name}'") === $this->table_name;
        if (!$table_exists) {
            return [
                'issues' => [],
                'total' => 0,
                'pages' => 0,
            ];
        }

        $where_clauses = ['1=1'];
        $where_values = [];

        // Add filters
        if (!empty($filters['post_id'])) {
            $where_clauses[] = 'post_id = %d';
            $where_values[] = $filters['post_id'];
        }

        if (!empty($filters['severity']) && $filters['severity'] !== 'all') {
            $where_clauses[] = 'severity = %s';
            $where_values[] = $filters['severity'];
        }

        if (!empty($filters['scan_id'])) {
            $where_clauses[] = 'scan_id = %s';
            $where_values[] = $filters['scan_id'];
        } else {
            $report_scan = $this->get_report_scan_info();
            $latest_scan_id = is_array($report_scan) ? ($report_scan['scan_id'] ?? '') : '';
            if (!empty($latest_scan_id)) {
                $where_clauses[] = 'scan_id = %s';
                $where_values[] = $latest_scan_id;
            }
        }

        $where_sql = implode(' AND ', $where_clauses);

        // Get total count
        $total = $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$this->table_name} WHERE {$where_sql}",
            ...$where_values
        ));

        // Get issues
        $page = $filters['page'] ?? 1;
        $per_page = $filters['per_page'] ?? 50;
        $offset = ($page - 1) * $per_page;

        $issues = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM {$this->table_name}
            WHERE {$where_sql}
            ORDER BY
                FIELD(severity, 'error', 'warning', 'notice'),
                timestamp DESC
            LIMIT %d OFFSET %d",
            array_merge($where_values, [$per_page, $offset])
        ));

        // Format issues
        $formatted_issues = array_map(function($issue) {
            return [
                'id' => $issue->id,
                'post_id' => $issue->post_id,
                'url' => $issue->url,
                'check_type' => $issue->check_type,
                'guideline' => $issue->guideline,
                'level' => $issue->level,
                'severity' => $issue->severity,
                'message' => $issue->message,
                'element' => $issue->element,
                'context' => $issue->context ? json_decode($issue->context, true) : null,
                'timestamp' => $issue->timestamp,
                'wcag_info' => self::WCAG_CHECKS[$issue->check_type] ?? null,
            ];
        }, $issues);

        return [
            'issues' => $formatted_issues,
            'total' => (int) $total,
            'pages' => ceil($total / $per_page),
        ];
    }

    /**
     * Get issues summary
     *
     * @return array Summary data
     */
    public function get_summary(): array {
        global $wpdb;

        $this->maybe_create_table();

        // Get current scan info
        $scan_info = $this->get_current_scan_info();
        $report_scan = $this->get_report_scan_info();

        $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$this->table_name}'") === $this->table_name;
        $latest_scan_id = is_array($report_scan) ? ($report_scan['scan_id'] ?? '') : '';
        $has_completed_scan = !empty($latest_scan_id);

        if (!$table_exists || !$has_completed_scan) {
            return [
                'has_scan' => false,
                'scan_status' => $scan_info['status'] ?? 'idle',
                'last_scan' => null,
                'total_issues' => 0,
                'severity_counts' => [
                    'error' => 0,
                    'warning' => 0,
                    'notice' => 0,
                ],
                'type_counts' => [],
                'affected_pages' => 0,
                'compliance_score' => 0,
            ];
        }

        // Get issue counts by severity
        $severity_counts = $wpdb->get_results($wpdb->prepare(
            "SELECT severity, COUNT(*) as count
            FROM {$this->table_name}
            WHERE scan_id = %s
            GROUP BY severity"
            ,
            $latest_scan_id
        ));

        $counts = [
            'error' => 0,
            'warning' => 0,
            'notice' => 0,
        ];

        foreach ($severity_counts as $row) {
            $counts[$row->severity] = (int) $row->count;
        }

        // Get issue counts by check type
        $type_counts = $wpdb->get_results($wpdb->prepare(
            "SELECT check_type, COUNT(*) as count
            FROM {$this->table_name}
            WHERE scan_id = %s
            GROUP BY check_type
            ORDER BY count DESC"
            ,
            $latest_scan_id
        ));

        // Get affected pages count
        $affected_pages = $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(DISTINCT post_id) FROM {$this->table_name} WHERE scan_id = %s",
            $latest_scan_id
        ));

        return [
            'has_scan' => true,
            'scan_status' => $scan_info['status'] ?? 'idle',
            'last_scan' => $report_scan['completed_at'] ?? null,
            'total_issues' => array_sum($counts),
            'severity_counts' => $counts,
            'type_counts' => $type_counts,
            'affected_pages' => (int) $affected_pages,
            'compliance_score' => $this->calculate_compliance_score($counts, (int) ($report_scan['total_pages'] ?? $scan_info['total_pages'] ?? 1)),
        ];
    }

    /**
     * Calculate compliance score
     *
     * @param array $counts Issue counts by severity
     * @return int Score 0-100
     */
    private function calculate_compliance_score(array $counts, int $total_pages = 1): int {
        $page_count = max(1, $total_pages);

        // Normalize by scan size so larger sites do not collapse to 0 immediately.
        $weighted_issues = ($counts['error'] * 3.0) + ($counts['warning'] * 1.5) + ($counts['notice'] * 0.5);
        $issues_per_page = $weighted_issues / $page_count;

        return (int) max(0, min(100, round(100 * exp(-0.22 * $issues_per_page))));
    }

    /**
     * REST: Start scan
     *
     * @param \WP_REST_Request $request Request object
     * @return \WP_REST_Response
     */
    public function rest_start_scan(\WP_REST_Request $request): \WP_REST_Response {
        try {
            $post_id = $request->get_param('post_id');

            if ($post_id) {
                // Check if database table exists
                global $wpdb;
                $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$this->table_name}'") === $this->table_name;

                if (!$table_exists) {
                    // Create table first
                    $this->maybe_create_table();
                }

                // Scan single page immediately
                $scan_id = wp_generate_password(32, false);

                // Set scan status
                update_option('prorank_accessibility_current_scan', [
                    'scan_id' => $scan_id,
                    'status' => 'running',
                    'started_at' => time(),
                    'total_pages' => 1,
                    'scanned_pages' => 0,
                    'message' => __('Scanning page...', 'prorank-seo'),
                ]);

                // Run the scan
                $this->scan_page((int) $post_id, $scan_id);

                // Mark as completed
                update_option('prorank_accessibility_current_scan', [
                    'scan_id' => $scan_id,
                    'status' => 'completed',
                    'completed_at' => time(),
                    'total_pages' => 1,
                    'scanned_pages' => 1,
                    'message' => __('Page scan completed', 'prorank-seo'),
                ]);
                $this->store_last_completed_scan_info([
                    'scan_id' => $scan_id,
                    'status' => 'completed',
                    'completed_at' => time(),
                    'total_pages' => 1,
                    'scanned_pages' => 1,
                    'message' => __('Page scan completed', 'prorank-seo'),
                ]);

                return rest_ensure_response([
                    'success' => true,
                    'message' => __('Page scan completed', 'prorank-seo'),
                ]);
            } else {
                // Check if Action Scheduler is available
                if (!function_exists('as_enqueue_async_action')) {
                    // Fallback: Run scan immediately for limited pages
                    $scan_id = wp_generate_password(32, false);

                    // Get a limited set of pages to scan
                    $args = [
                        'post_type' => ['page', 'post'],
                        'post_status' => 'publish',
                        'posts_per_page' => 10, // Limit to 10 pages for immediate scan
                        'fields' => 'ids',
                    ];

                    $posts = get_posts($args);
                    $total = count($posts);

                    // Set initial scan status
                    update_option('prorank_accessibility_current_scan', [
                        'scan_id' => $scan_id,
                        'status' => 'running',
                        'started_at' => time(),
                        'total_pages' => $total,
                        'scanned_pages' => 0,
                        'message' => __('Scanning...', 'prorank-seo'),
                    ]);

                    // Scan each page
                    foreach ($posts as $index => $post_id) {
                        $this->scan_page($post_id, $scan_id);

                        // Update progress
                        $scan_info = get_option('prorank_accessibility_current_scan');
                        $scan_info['scanned_pages'] = $index + 1;
                        update_option('prorank_accessibility_current_scan', $scan_info);
                    }

                    // Mark as completed
                    update_option('prorank_accessibility_current_scan', [
                        'scan_id' => $scan_id,
                        'status' => 'completed',
                        'completed_at' => time(),
                        'total_pages' => $total,
                        'scanned_pages' => $total,
/* translators: %1$: value */
                        'message' => sprintf(__('Scanned %1$d pages', 'prorank-seo'), $total),
                    ]);

                    return rest_ensure_response([
                        'success' => true,
/* translators: %1$: value */
                        'message' => sprintf(__('Accessibility scan completed (%1$d pages)', 'prorank-seo'), $total),
                    ]);
                }

                $this->cancel_pending_scan_actions();
                $this->maybe_create_table();
                $scan_id = $this->scan_site();
                $this->maybe_run_inline_processing($scan_id);

                return rest_ensure_response([
                    'success' => true,
                    'message' => __('Site accessibility scan started', 'prorank-seo'),
                ]);
            }
        } catch (\Exception $e) {
            return rest_ensure_response([
                'success' => false,
/* translators: %1$: value */
                'message' => sprintf(__('Scan failed: %1$s', 'prorank-seo'), $e->getMessage()),
            ]);
        }
    }

    /**
     * REST: Get status
     *
     * @return \WP_REST_Response
     */
    public function rest_get_status(): \WP_REST_Response {
        $scan_info = $this->get_current_scan_info(false);
        if (($scan_info['status'] ?? '') === 'running' && !empty($scan_info['scan_id'])) {
            $this->maybe_run_inline_processing((string) $scan_info['scan_id']);
        }
        $scan_info = $this->get_current_scan_info();

        return rest_ensure_response([
            'success' => true,
            'data' => $scan_info,
        ]);
    }

    /**
     * REST: Get issues
     *
     * @param \WP_REST_Request $request Request object
     * @return \WP_REST_Response
     */
    public function rest_get_issues(\WP_REST_Request $request): \WP_REST_Response {
        $filters = [
            'post_id' => $request->get_param('post_id'),
            'severity' => $request->get_param('severity'),
            'page' => (int) $request->get_param('page'),
            'per_page' => (int) $request->get_param('per_page'),
        ];

        $result = $this->get_issues($filters);

        return rest_ensure_response([
            'success' => true,
            'data' => $result,
        ]);
    }

    /**
     * REST: Get summary
     *
     * @return \WP_REST_Response
     */
    public function rest_get_summary(): \WP_REST_Response {
        $summary = $this->get_summary();

        return rest_ensure_response([
            'success' => true,
            'data' => $summary,
        ]);
    }

    /**
     * REST: Export report
     *
     * @param \WP_REST_Request $request Request object
     * @return \WP_REST_Response
     */
    public function rest_export_report(\WP_REST_Request $request): \WP_REST_Response {
        $format = $request->get_param('format');
        $issues = $this->get_issues(['per_page' => 9999]);

        switch ($format) {
            case 'csv':
                $csv_data = $this->generate_csv_report($issues['issues']);
                return rest_ensure_response([
                    'success' => true,
                    'data' => base64_encode($csv_data),
                    'filename' => 'accessibility-report-' . wp_date('Y-m-d') . '.csv',
                    'mime_type' => 'text/csv',
                ]);

            case 'json':
                return rest_ensure_response([
                    'success' => true,
                    'data' => $issues,
                    'filename' => 'accessibility-report-' . wp_date('Y-m-d') . '.json',
                    'mime_type' => 'application/json',
                ]);

            case 'pdf':
                // PDF generation would require additional library
                return rest_ensure_response([
                    'success' => false,
                    'message' => __('PDF export coming soon', 'prorank-seo'),
                ]);

            default:
                return rest_ensure_response([
                    'success' => false,
                    'message' => __('Invalid export format', 'prorank-seo'),
                ]);
        }
    }

    /**
     * Generate CSV report
     *
     * @param array $issues Issues array
     * @return string CSV data
     */
    private function generate_csv_report(array $issues): string {
        $csv = "URL,Severity,Issue,WCAG Level,Guideline,Description,Element\n";

        foreach ($issues as $issue) {
            $csv .= sprintf(
                '"%s","%s","%s","%s","%s","%s","%s"' . "\n",
                $issue['url'],
                $issue['severity'],
                $issue['message'],
                $issue['level'],
                $issue['guideline'],
                $issue['wcag_info']['description'] ?? '',
                str_replace('"', '""', $issue['element'] ?? '')
            );
        }

        return $csv;
    }

    /**
     * REST: Auto-fix issues
     *
     * @param \WP_REST_Request $request Request object
     * @return \WP_REST_Response
     */
    public function rest_auto_fix(\WP_REST_Request $request): \WP_REST_Response {
        $issue_ids = $request->get_param('issue_ids');
        $fixed_count = 0;
        $failed_count = 0;

        foreach ($issue_ids as $issue_id) {
            if ($this->auto_fix_issue($issue_id)) {
                $fixed_count++;
            } else {
                $failed_count++;
            }
        }

        return rest_ensure_response([
            'success' => true,
            'data' => [
                'fixed' => $fixed_count,
                'failed' => $failed_count,
            ],
            'message' => sprintf(
/* translators: %1$: value, %2$: value */
                __('%1$d issues fixed, %2$d failed', 'prorank-seo'),
                $fixed_count,
                $failed_count
            ),
        ]);
    }

    /**
     * Auto-fix single issue
     *
     * @param int $issue_id Issue ID
     * @return bool Success
     */
    private function auto_fix_issue(int $issue_id): bool {
        global $wpdb;

        // Get issue details
        $issue = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$this->table_name} WHERE id = %d",
            $issue_id
        ));

        if (!$issue) {
            return false;
        }

        // Check if auto-fix is available for this check type
        $check_info = self::WCAG_CHECKS[$issue->check_type] ?? [];
        if (empty($check_info['auto_fix'])) {
            return false;
        }

        // Get the post
        $post = get_post($issue->post_id);
        if (!$post) {
            return false;
        }

        $fixed = false;
        $content = $post->post_content;

        switch ($issue->check_type) {
            case 'images_alt_text':
                $fixed = $this->auto_fix_image_alt_issue($issue);
                break;

            case 'language_attribute':
                // Add language attribute to HTML
                update_post_meta($issue->post_id, '_prorank_page_language', get_locale());
                $fixed = true;
                break;

            case 'page_title':
                // Generate page title if missing
                if (empty($post->post_title)) {
                    wp_update_post([
                        'ID' => $issue->post_id,
                        'post_title' => $this->generate_title_from_content($content),
                    ]);
                    $fixed = true;
                }
                break;

            case 'skip_navigation':
                // Add skip navigation link
                $skip_link = '<a class="skip-link screen-reader-text" href="#content">' .
                           __('Skip to content', 'prorank-seo') . '</a>';
                update_post_meta($issue->post_id, '_prorank_skip_navigation', $skip_link);
                $fixed = true;
                break;

            case 'landmark_regions':
                // Add ARIA landmarks
                $fixed = $this->auto_fix_landmarks($post, $content);
                break;
        }

        if ($fixed) {
            // Remove the stale scan issue. A follow-up scan will recreate it if
            // the fix did not change the rendered output.
            $wpdb->delete(
                $this->table_name,
                ['id' => $issue_id],
                ['%d']
            );
        }

        return $fixed;
    }

    /**
     * Fix an image-alt issue by updating attachment alt metadata.
     *
     * Avoid rewriting post_content because block comments, Elementor data, and
     * page-builder markup can be damaged by DOM round-tripping.
     *
     * @param object $issue Issue row.
     * @return bool
     */
    private function auto_fix_image_alt_issue(object $issue): bool {
        $context = !empty($issue->context) ? json_decode((string) $issue->context, true) : [];
        $src = is_array($context) ? (string) ($context['src'] ?? '') : '';

        if ($src === '' && !empty($issue->element) && preg_match('/\bsrc\s*=\s*["\']([^"\']+)["\']/i', (string) $issue->element, $matches)) {
            $src = $matches[1];
        }

        if ($src === '') {
            return false;
        }

        $attachment_id = attachment_url_to_postid($src);
        if ($attachment_id <= 0) {
            $clean_src = preg_replace('/-\d+x\d+(?=\.\w+$)/', '', $src);
            if (is_string($clean_src) && $clean_src !== $src) {
                $attachment_id = attachment_url_to_postid($clean_src);
            }
        }

        if ($attachment_id <= 0) {
            return false;
        }

        $current_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
        $alt = is_string($current_alt) ? trim($current_alt) : '';

        if ($alt === '') {
            $attachment = get_post($attachment_id);
            $alt = $attachment instanceof \WP_Post ? trim((string) $attachment->post_title) : '';
        }

        if ($alt === '') {
            $alt = $this->generate_alt_text_from_filename($src);
        }

        $alt = $this->trim_alt_text($alt);
        if ($alt === '') {
            return false;
        }

        update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt);

        return true;
    }

    /**
     * Generate fallback alt text from an image filename.
     *
     * @param string $src Image source.
     * @return string
     */
    private function generate_alt_text_from_filename(string $src): string {
        $path = (string) (wp_parse_url($src, PHP_URL_PATH) ?: $src);
        $filename = pathinfo($path, PATHINFO_FILENAME);
        $filename = preg_replace('/-\d+x\d+$/', '', $filename) ?: $filename;
        $filename = preg_replace('/[_-]+/', ' ', $filename) ?: $filename;
        $filename = preg_replace('/\s+/', ' ', $filename) ?: $filename;

        return trim(ucwords($filename));
    }

    /**
     * Keep generated alt text in a practical short range.
     *
     * @param string $alt Alt text.
     * @return string
     */
    private function trim_alt_text(string $alt): string {
        $alt = $this->normalize_accessible_text(wp_strip_all_tags($alt));
        if (strlen($alt) <= 125) {
            return $alt;
        }

        $trimmed = substr($alt, 0, 122);
        $last_space = strrpos($trimmed, ' ');
        if ($last_space !== false && $last_space > 40) {
            $trimmed = substr($trimmed, 0, $last_space);
        }

        return rtrim($trimmed, " \t\n\r\0\x0B,.;:-") . '...';
    }

    /**
     * Auto-fix image alt text
     *
     * @param \WP_Post $post Post object
     * @param string $content Content
     * @return bool Success
     */
    private function auto_fix_image_alt(\WP_Post $post, string &$content): bool {
        $dom = new \DOMDocument();
        @$dom->loadHTML('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>' . $content . '</body></html>');

        $xpath = new \DOMXPath($dom);
        $images = $xpath->query('//img[not(@alt)]');
        $fixed = false;

        foreach ($images as $img) {
            $src = $img->getAttribute('src');
            $filename = basename($src);
            $alt_text = str_replace(['-', '_', '.jpg', '.png', '.gif', '.webp'], ' ', $filename);
            $alt_text = ucwords(trim($alt_text));

            $img->setAttribute('alt', $alt_text);
            $fixed = true;
        }

        if ($fixed) {
            $body = $dom->getElementsByTagName('body')->item(0);
            $new_content = '';
            foreach ($body->childNodes as $child) {
                $new_content .= $dom->saveHTML($child);
            }

            wp_update_post([
                'ID' => $post->ID,
                'post_content' => $new_content,
            ]);
        }

        return $fixed;
    }

    /**
     * Auto-fix landmark regions
     *
     * @param \WP_Post $post Post object
     * @param string $content Content
     * @return bool Success
     */
    private function auto_fix_landmarks(\WP_Post $post, string &$content): bool {
        // Add ARIA landmarks to main content areas
        $patterns = [
            '/<header(?![^>]*role)/i' => '<header role="banner"',
            '/<nav(?![^>]*role)/i' => '<nav role="navigation"',
            '/<main(?![^>]*role)/i' => '<main role="main"',
            '/<footer(?![^>]*role)/i' => '<footer role="contentinfo"',
            '/<aside(?![^>]*role)/i' => '<aside role="complementary"',
        ];

        $new_content = preg_replace(array_keys($patterns), array_values($patterns), $content);

        if ($new_content !== $content) {
            wp_update_post([
                'ID' => $post->ID,
                'post_content' => $new_content,
            ]);
            return true;
        }

        return false;
    }

    /**
     * Generate title from content
     *
     * @param string $content Content
     * @return string Generated title
     */
    private function generate_title_from_content(string $content): string {
        // Extract first heading or first sentence
        $dom = new \DOMDocument();
        @$dom->loadHTML('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>' . $content . '</body></html>');

        // Try to find first heading
        for ($i = 1; $i <= 6; $i++) {
            $headings = $dom->getElementsByTagName('h' . $i);
            if ($headings->length > 0) {
                return trim($headings->item(0)->textContent);
            }
        }

        // Fall back to first sentence
        $text = wp_strip_all_tags($content);
        $sentences = preg_split('/[.!?]/', $text);
        if (!empty($sentences[0])) {
            return substr(trim($sentences[0]), 0, 60);
        }

        return __('Untitled Page', 'prorank-seo');
    }

    /**
     * REST: Get settings
     *
     * @return \WP_REST_Response
     */
    public function rest_get_settings(): \WP_REST_Response {
        $settings = get_option('prorank_accessibility_settings', []);
        if (!is_array($settings)) {
            $settings = [];
        }

        return rest_ensure_response([
            'success' => true,
            'data' => $this->normalize_settings($settings),
        ]);
    }

    /**
     * REST: Update settings
     *
     * @param \WP_REST_Request $request Request object
     * @return \WP_REST_Response
     */
    public function rest_update_settings(\WP_REST_Request $request): \WP_REST_Response {
        $settings = $request->get_params();

        // Validate settings
        $valid_settings = $this->validate_settings($settings);

        // Save settings
        update_option('prorank_accessibility_settings', $valid_settings);

        return rest_ensure_response([
            'success' => true,
            'data' => $valid_settings,
            'message' => __('Accessibility settings updated', 'prorank-seo'),
        ]);
    }

    /**
     * Get default settings
     *
     * @return array Default settings
     */
    private function get_default_settings(): array {
        return [
            'auto_scan' => false,
            'scan_frequency' => 'weekly',
            'email_reports' => false,
            'email_recipients' => get_option('admin_email'),
            'wcag_level' => 'AA',
            'check_types' => array_keys(self::WCAG_CHECKS),
            'auto_fix_enabled' => true,
            'auto_fix_types' => ['images_alt_text', 'page_title'],
            'exclude_post_types' => [],
            'exclude_post_ids' => [],
            'axe_core_enabled' => false,
            'real_time_checking' => false,
        ];
    }

    /**
     * Normalize saved settings while preserving deliberate disabled checks.
     *
     * @param array $settings Raw settings
     * @return array Normalized settings
     */
    private function normalize_settings(array $settings): array {
        $defaults = $this->get_default_settings();
        $settings = array_merge($defaults, $settings);
        $all_check_types = array_keys(self::WCAG_CHECKS);
        $legacy_default_check_types = [
            'images_alt_text',
            'heading_hierarchy',
            'link_text',
            'language_attribute',
            'page_title',
            'form_labels',
            'table_headers',
            'duplicate_ids',
            'empty_buttons',
            'color_contrast',
            'aria_attributes',
            'focus_visible',
            'skip_navigation',
            'video_captions',
            'audio_descriptions',
            'landmark_regions',
            'focus_order',
        ];

        $settings['check_types'] = isset($settings['check_types']) && is_array($settings['check_types'])
            ? array_values(array_intersect($settings['check_types'], $all_check_types))
            : $defaults['check_types'];

        if (empty(array_diff($legacy_default_check_types, $settings['check_types']))) {
            $settings['check_types'] = array_values(array_unique(array_merge($settings['check_types'], $defaults['check_types'])));
        }

        $auto_fixable_types = array_keys(array_filter(self::WCAG_CHECKS, static fn($check) => !empty($check['auto_fix'])));
        $settings['auto_fix_types'] = isset($settings['auto_fix_types']) && is_array($settings['auto_fix_types'])
            ? array_values(array_intersect($settings['auto_fix_types'], $auto_fixable_types))
            : $defaults['auto_fix_types'];

        return $settings;
    }

    /**
     * Validate settings
     *
     * @param array $settings Settings to validate
     * @return array Valid settings
     */
    private function validate_settings(array $settings): array {
        $defaults = $this->get_default_settings();
        $valid = [];

        // Validate each setting
        $valid['auto_scan'] = isset($settings['auto_scan']) ? (bool) $settings['auto_scan'] : $defaults['auto_scan'];
        $valid['scan_frequency'] = in_array($settings['scan_frequency'] ?? '', ['daily', 'weekly', 'monthly'])
            ? $settings['scan_frequency']
            : $defaults['scan_frequency'];
        $valid['email_reports'] = isset($settings['email_reports']) ? (bool) $settings['email_reports'] : $defaults['email_reports'];
        $valid['email_recipients'] = isset($settings['email_recipients']) ? sanitize_text_field($settings['email_recipients']) : $defaults['email_recipients'];
        $valid['wcag_level'] = in_array($settings['wcag_level'] ?? '', ['A', 'AA', 'AAA'])
            ? $settings['wcag_level']
            : $defaults['wcag_level'];
        $valid['check_types'] = isset($settings['check_types']) && is_array($settings['check_types'])
            ? array_intersect($settings['check_types'], array_keys(self::WCAG_CHECKS))
            : $defaults['check_types'];
        $valid['auto_fix_enabled'] = isset($settings['auto_fix_enabled']) ? (bool) $settings['auto_fix_enabled'] : $defaults['auto_fix_enabled'];
        $valid['auto_fix_types'] = isset($settings['auto_fix_types']) && is_array($settings['auto_fix_types'])
            ? array_intersect($settings['auto_fix_types'], array_keys(array_filter(self::WCAG_CHECKS, fn($c) => $c['auto_fix'])))
            : $defaults['auto_fix_types'];
        $valid['exclude_post_types'] = isset($settings['exclude_post_types']) && is_array($settings['exclude_post_types'])
            ? $settings['exclude_post_types']
            : $defaults['exclude_post_types'];
        $valid['exclude_post_ids'] = isset($settings['exclude_post_ids']) && is_array($settings['exclude_post_ids'])
            ? array_map('intval', $settings['exclude_post_ids'])
            : $defaults['exclude_post_ids'];
        $valid['axe_core_enabled'] = isset($settings['axe_core_enabled']) ? (bool) $settings['axe_core_enabled'] : $defaults['axe_core_enabled'];
        $valid['real_time_checking'] = isset($settings['real_time_checking']) ? (bool) $settings['real_time_checking'] : $defaults['real_time_checking'];

        return $this->normalize_settings($valid);
    }

    /**
     * Check text color contrast where colors are statically discoverable.
     *
     * Full contrast evaluation requires browser-computed styles. This server-side
     * pass covers inline styles, presentational attributes, style blocks, and
     * simple stylesheet selectors.
     *
     * @param \DOMDocument $dom DOM document.
     * @param int          $post_id Post ID.
     * @param string       $url URL.
     * @param string       $scan_id Scan ID.
     * @return void
     */
    private function check_color_contrast(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $rules = $this->index_color_style_rules($this->extract_color_style_rules($dom, $url));
        $elements = $xpath->query('//*[not(self::script) and not(self::style) and not(self::noscript) and not(self::svg)]');
        $reported = 0;
        $checked = 0;
        $started_at = microtime(true);

        foreach ($elements as $element) {
            if (!$element instanceof \DOMElement || $this->is_element_hidden($element) || !$this->has_direct_visible_text($element)) {
                continue;
            }

            $checked++;
            if ($checked > 600 || (microtime(true) - $started_at) > 8) {
                return;
            }

            $styles = $this->resolve_color_styles($element, $rules);
            $has_explicit_color = !empty($styles['color']);
            $has_explicit_background = !empty($styles['background-color']) || !empty($styles['background']);

            if (!$has_explicit_color && !$has_explicit_background) {
                continue;
            }

            $foreground = $this->parse_css_color($styles['color'] ?? '#000');
            $background = $this->parse_css_color($styles['background-color'] ?? ($styles['background'] ?? '#fff'));

            if ($foreground === null || $background === null) {
                continue;
            }

            $ratio = $this->calculate_contrast_ratio($foreground, $background);
            $font_size = $this->extract_font_size_px($styles['font-size'] ?? '');
            $font_weight = $this->extract_font_weight($styles['font-weight'] ?? '');
            $is_large_text = $font_size >= 24 || ($font_size >= 18.66 && $font_weight >= 700);
            $threshold = $is_large_text ? 3.0 : 4.5;
            $background_depth = (int) ($styles['__background_depth'] ?? 999);

            if ($background_depth > 4) {
                continue;
            }

            if ($ratio < $threshold) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'color_contrast',
                    'error',
                    sprintf(__('Text color contrast ratio %.2f:1 is below the WCAG AA threshold of %.1f:1', 'prorank-seo'), $ratio, $threshold),
                    $this->get_element_html($element),
                    [
                        'ratio' => round($ratio, 2),
                        'required' => $threshold,
                        'foreground' => $styles['color'] ?? '#000',
                        'background' => $styles['background-color'] ?? ($styles['background'] ?? '#fff'),
                    ]
                );

                $reported++;
                if ($reported >= 25) {
                    return;
                }
            }
        }
    }

    /**
     * Extract color-related CSS rules from rendered HTML.
     *
     * @param \DOMDocument $dom DOM document.
     * @param string       $page_url Page URL.
     * @return array<int, array{selector:string,declarations:array<string,string>}>
     */
    private function extract_color_style_rules(\DOMDocument $dom, string $page_url): array {
        $xpath = new \DOMXPath($dom);
        $css = '';

        $styles = $xpath->query('//style');
        foreach ($styles as $style) {
            $css .= "\n" . $style->textContent;
        }

        $links = $xpath->query('//link[contains(concat(" ", normalize-space(@rel), " "), " stylesheet ")][@href]');
        $loaded = 0;
        foreach ($links as $link) {
            if (!$link instanceof \DOMElement || $loaded >= 6) {
                continue;
            }

            $href = $this->resolve_url($link->getAttribute('href'), $page_url);
            if ($href === '' || !$this->is_same_site_url($href, $page_url)) {
                continue;
            }

            $sheet = $this->get_cached_stylesheet($href);
            if ($sheet !== '') {
                $css .= "\n" . $sheet;
                $loaded++;
            }
        }

        return $this->parse_color_css_rules($css);
    }

    /**
     * Fetch stylesheet content with a transient cache.
     *
     * @param string $url Stylesheet URL.
     * @return string
     */
    private function get_cached_stylesheet(string $url): string {
        $cache_key = 'prorank_a11y_css_' . md5($url);
        $cached = get_transient($cache_key);
        if (is_string($cached)) {
            return $cached;
        }

        $response = wp_remote_get($url, [
            'timeout' => 5,
            'redirection' => 2,
            'headers' => [
                'User-Agent' => 'ProRank Accessibility Scanner/1.0',
            ],
        ]);

        if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) >= 400) {
            set_transient($cache_key, '', HOUR_IN_SECONDS);
            return '';
        }

        $body = (string) wp_remote_retrieve_body($response);
        if (strlen($body) > 500000) {
            $body = substr($body, 0, 500000);
        }

        set_transient($cache_key, $body, 12 * HOUR_IN_SECONDS);

        return $body;
    }

    /**
     * Parse simple CSS rules that can influence text contrast.
     *
     * @param string $css CSS.
     * @return array<int, array{selector:string,declarations:array<string,string>}>
     */
    private function parse_color_css_rules(string $css): array {
        if (strlen($css) > 250000) {
            $css = substr($css, 0, 250000);
        }

        $css = preg_replace('/\/\*.*?\*\//s', '', $css) ?: '';
        $rules = [];

        if (!preg_match_all('/([^{}]+)\{([^{}]+)\}/', $css, $matches, PREG_SET_ORDER)) {
            return [];
        }

        foreach ($matches as $match) {
            if (strpos($match[1], '@') !== false) {
                continue;
            }

            $declarations = $this->parse_css_declarations($match[2]);
            $declarations = array_intersect_key($declarations, array_flip(['color', 'background', 'background-color', 'font-size', 'font-weight']));
            if (empty($declarations)) {
                continue;
            }

            foreach (explode(',', $match[1]) as $selector) {
                $selector = $this->normalize_simple_selector($selector);
                if ($selector === '') {
                    continue;
                }

                $rules[] = [
                    'selector' => $selector,
                    'declarations' => $declarations,
                ];

                if (count($rules) >= 750) {
                    return $rules;
                }
            }
        }

        return $rules;
    }

    /**
     * Build a small selector index so contrast checks do not scan every CSS
     * rule for every text node.
     *
     * @param array $rules CSS rules.
     * @return array<string,array>
     */
    private function index_color_style_rules(array $rules): array {
        $index = [
            'universal' => [],
            'tag' => [],
            'id' => [],
            'class' => [],
        ];

        foreach ($rules as $rule) {
            $selector = (string) ($rule['selector'] ?? '');
            if ($selector === '*') {
                $index['universal'][] = $rule;
                continue;
            }

            if (preg_match('/#([A-Za-z0-9_-]+)/', $selector, $id_match)) {
                $index['id'][$id_match[1]][] = $rule;
            }

            if (preg_match_all('/\.([A-Za-z0-9_-]+)/', $selector, $class_matches)) {
                foreach ($class_matches[1] as $class_name) {
                    $index['class'][$class_name][] = $rule;
                }
            }

            $tag = strtolower(preg_replace('/[#.].*$/', '', $selector) ?: '');
            if ($tag !== '') {
                $index['tag'][$tag][] = $rule;
            }
        }

        return $index;
    }

    /**
     * Parse a CSS declaration block.
     *
     * @param string $style CSS declarations.
     * @return array<string,string>
     */
    private function parse_css_declarations(string $style): array {
        $declarations = [];
        foreach (explode(';', $style) as $declaration) {
            if (strpos($declaration, ':') === false) {
                continue;
            }

            [$property, $value] = array_map('trim', explode(':', $declaration, 2));
            if ($property === '' || $value === '') {
                continue;
            }

            $declarations[strtolower($property)] = trim($value);
        }

        return $declarations;
    }

    /**
     * Resolve color-relevant styles for an element with simple inheritance.
     *
     * @param \DOMElement $element Element.
     * @param array       $rules CSS rule index.
     * @return array<string,string>
     */
    private function resolve_color_styles(\DOMElement $element, array $rules): array {
        $resolved = [];
        $current = $element;
        $depth = 0;

        while ($current instanceof \DOMElement) {
            $declared = $this->get_declared_styles_for_element($current, $rules);

            foreach (['color', 'font-size', 'font-weight'] as $property) {
                if (!isset($resolved[$property]) && !empty($declared[$property])) {
                    $resolved[$property] = $declared[$property];
                    $resolved['__' . $property . '_depth'] = $depth;
                }
            }

            foreach (['background-color', 'background'] as $property) {
                if (!isset($resolved['background-color']) && !empty($declared[$property])) {
                    $resolved['background-color'] = $declared[$property];
                    $resolved['__background_depth'] = $depth;
                }
            }

            if (isset($resolved['color'], $resolved['background-color'], $resolved['font-size'], $resolved['font-weight'])) {
                break;
            }

            $current = $current->parentNode instanceof \DOMElement ? $current->parentNode : null;
            $depth++;
        }

        return $resolved;
    }

    /**
     * Get declared styles for a single element.
     *
     * @param \DOMElement $element Element.
     * @param array       $rules CSS rule index.
     * @return array<string,string>
     */
    private function get_declared_styles_for_element(\DOMElement $element, array $rules): array {
        $styles = [];
        $candidate_rules = $rules['universal'] ?? [];
        $id = $element->getAttribute('id');
        if ($id !== '' && !empty($rules['id'][$id])) {
            $candidate_rules = array_merge($candidate_rules, $rules['id'][$id]);
        }

        $class_attr = trim($element->getAttribute('class'));
        if ($class_attr !== '') {
            foreach (preg_split('/\s+/', $class_attr) ?: [] as $class_name) {
                if ($class_name !== '' && !empty($rules['class'][$class_name])) {
                    $candidate_rules = array_merge($candidate_rules, $rules['class'][$class_name]);
                }
            }
        }

        $tag = strtolower($element->tagName);
        if (!empty($rules['tag'][$tag])) {
            $candidate_rules = array_merge($candidate_rules, $rules['tag'][$tag]);
        }

        $seen = [];

        foreach ($candidate_rules as $rule) {
            $key = ($rule['selector'] ?? '') . '|' . md5(wp_json_encode($rule['declarations'] ?? []));
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;

            if ($this->element_matches_simple_selector($element, $rule['selector'])) {
                $styles = array_merge($styles, $rule['declarations']);
            }
        }

        if ($element->hasAttribute('color')) {
            $styles['color'] = $element->getAttribute('color');
        }
        if ($element->hasAttribute('bgcolor')) {
            $styles['background-color'] = $element->getAttribute('bgcolor');
        }

        $inline = $this->parse_css_declarations($element->getAttribute('style'));
        if (!empty($inline)) {
            $styles = array_merge($styles, $inline);
        }

        return $styles;
    }

    private function normalize_simple_selector(string $selector): string {
        $selector = trim(preg_replace('/:{1,2}[a-zA-Z-]+(?:\([^)]*\))?/', '', $selector) ?: '');

        if ($selector === '' || preg_match('/[\s>+~\[\]=]/', $selector)) {
            return '';
        }

        return $selector;
    }

    private function element_matches_simple_selector(\DOMElement $element, string $selector): bool {
        if ($selector === '*') {
            return true;
        }

        $id = null;
        if (preg_match('/#([A-Za-z0-9_-]+)/', $selector, $matches)) {
            $id = $matches[1];
            if ($element->getAttribute('id') !== $id) {
                return false;
            }
        }

        if (preg_match_all('/\.([A-Za-z0-9_-]+)/', $selector, $matches)) {
            $classes = preg_split('/\s+/', trim($element->getAttribute('class'))) ?: [];
            foreach ($matches[1] as $class) {
                if (!in_array($class, $classes, true)) {
                    return false;
                }
            }
        }

        $tag = preg_replace('/[#.].*$/', '', $selector) ?: '';
        if ($tag !== '' && strtolower($element->tagName) !== strtolower($tag)) {
            return false;
        }

        return $id !== null || $tag !== '' || strpos($selector, '.') !== false;
    }

    /**
     * Parse common CSS color values.
     *
     * @param string $value CSS color value.
     * @return array{r:int,g:int,b:int}|null
     */
    private function parse_css_color(string $value): ?array {
        $value = strtolower(trim($value));
        if ($value === '' || $value === 'transparent' || strpos($value, 'gradient(') !== false) {
            return null;
        }

        if (preg_match('/#([0-9a-f]{3,8})\b/i', $value, $matches)) {
            $hex = $matches[1];
            if (strlen($hex) === 3 || strlen($hex) === 4) {
                return [
                    'r' => hexdec(str_repeat($hex[0], 2)),
                    'g' => hexdec(str_repeat($hex[1], 2)),
                    'b' => hexdec(str_repeat($hex[2], 2)),
                ];
            }

            if (strlen($hex) === 6 || strlen($hex) === 8) {
                return [
                    'r' => hexdec(substr($hex, 0, 2)),
                    'g' => hexdec(substr($hex, 2, 2)),
                    'b' => hexdec(substr($hex, 4, 2)),
                ];
            }
        }

        if (preg_match('/rgba?\(([^)]+)\)/', $value, $matches)) {
            $parts = preg_split('/[\s,\/]+/', trim($matches[1])) ?: [];
            if (count($parts) >= 3) {
                $alpha = isset($parts[3]) ? (float) $parts[3] : 1.0;
                if ($alpha < 1) {
                    return null;
                }

                return [
                    'r' => $this->parse_color_channel($parts[0]),
                    'g' => $this->parse_color_channel($parts[1]),
                    'b' => $this->parse_color_channel($parts[2]),
                ];
            }
        }

        $named = [
            'black' => [0, 0, 0],
            'white' => [255, 255, 255],
            'red' => [255, 0, 0],
            'green' => [0, 128, 0],
            'blue' => [0, 0, 255],
            'gray' => [128, 128, 128],
            'grey' => [128, 128, 128],
            'silver' => [192, 192, 192],
            'maroon' => [128, 0, 0],
            'purple' => [128, 0, 128],
            'fuchsia' => [255, 0, 255],
            'lime' => [0, 255, 0],
            'olive' => [128, 128, 0],
            'yellow' => [255, 255, 0],
            'navy' => [0, 0, 128],
            'teal' => [0, 128, 128],
            'aqua' => [0, 255, 255],
            'orange' => [255, 165, 0],
        ];

        foreach ($named as $name => $rgb) {
            if (preg_match('/(^|[^\w-])' . preg_quote($name, '/') . '($|[^\w-])/', $value)) {
                return ['r' => $rgb[0], 'g' => $rgb[1], 'b' => $rgb[2]];
            }
        }

        return null;
    }

    private function parse_color_channel(string $value): int {
        $value = trim($value);
        if (str_ends_with($value, '%')) {
            return (int) max(0, min(255, round(((float) $value / 100) * 255)));
        }

        return (int) max(0, min(255, round((float) $value)));
    }

    private function calculate_contrast_ratio(array $foreground, array $background): float {
        $l1 = $this->relative_luminance($foreground);
        $l2 = $this->relative_luminance($background);
        $lighter = max($l1, $l2);
        $darker = min($l1, $l2);

        return ($lighter + 0.05) / ($darker + 0.05);
    }

    private function relative_luminance(array $rgb): float {
        $channels = [];
        foreach (['r', 'g', 'b'] as $channel) {
            $value = ($rgb[$channel] ?? 0) / 255;
            $channels[$channel] = $value <= 0.03928
                ? $value / 12.92
                : (($value + 0.055) / 1.055) ** 2.4;
        }

        return (0.2126 * $channels['r']) + (0.7152 * $channels['g']) + (0.0722 * $channels['b']);
    }

    private function extract_font_size_px(string $value): float {
        $value = strtolower(trim($value));
        if (preg_match('/(\d+(?:\.\d+)?)px/', $value, $matches)) {
            return (float) $matches[1];
        }
        if (preg_match('/(\d+(?:\.\d+)?)rem/', $value, $matches)) {
            return (float) $matches[1] * 16;
        }
        if (preg_match('/(\d+(?:\.\d+)?)em/', $value, $matches)) {
            return (float) $matches[1] * 16;
        }

        return 16.0;
    }

    private function extract_font_weight(string $value): int {
        $value = strtolower(trim($value));
        if (is_numeric($value)) {
            return (int) $value;
        }

        return in_array($value, ['bold', 'bolder'], true) ? 700 : 400;
    }

    private function has_direct_visible_text(\DOMElement $element): bool {
        foreach ($element->childNodes as $child) {
            if ($child->nodeType === XML_TEXT_NODE && trim($child->textContent) !== '') {
                return true;
            }
        }

        return false;
    }

    private function is_element_hidden(\DOMElement $element): bool {
        if ($element->hasAttribute('hidden') || strtolower($element->getAttribute('aria-hidden')) === 'true') {
            return true;
        }

        $style = strtolower($element->getAttribute('style'));
        return strpos($style, 'display:none') !== false || strpos($style, 'display: none') !== false || strpos($style, 'visibility:hidden') !== false || strpos($style, 'visibility: hidden') !== false;
    }

    private function resolve_url(string $href, string $base): string {
        $href = trim(html_entity_decode($href));
        if ($href === '' || strpos($href, 'data:') === 0) {
            return '';
        }

        if (preg_match('#^https?://#i', $href)) {
            return $href;
        }

        $base_parts = wp_parse_url($base);
        if (!is_array($base_parts) || empty($base_parts['scheme']) || empty($base_parts['host'])) {
            return '';
        }

        if (strpos($href, '//') === 0) {
            return $base_parts['scheme'] . ':' . $href;
        }

        $root = $base_parts['scheme'] . '://' . $base_parts['host'];
        if (!empty($base_parts['port'])) {
            $root .= ':' . $base_parts['port'];
        }

        if (strpos($href, '/') === 0) {
            return $root . $href;
        }

        $path = isset($base_parts['path']) ? dirname($base_parts['path']) : '';
        return rtrim($root . '/' . trim($path, '/'), '/') . '/' . $href;
    }

    private function is_same_site_url(string $url, string $base): bool {
        $url_host = wp_parse_url($url, PHP_URL_HOST);
        $base_host = wp_parse_url($base, PHP_URL_HOST);

        return is_string($url_host) && is_string($base_host) && strtolower($url_host) === strtolower($base_host);
    }

    /**
     * Check for ARIA attributes
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_aria_attributes(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $valid_roles = [
            'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
            'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog',
            'directory', 'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading',
            'img', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu',
            'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note',
            'option', 'presentation', 'progressbar', 'radio', 'radiogroup', 'region', 'row',
            'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider',
            'spinbutton', 'status', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term',
            'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem',
        ];

        // Check for invalid ARIA attributes.
        // XPath does not support wildcard attribute names like @aria-* directly.
        $elements_with_aria = $xpath->query('//*[@*[starts-with(name(), "aria-")]]');
        if ($elements_with_aria instanceof \DOMNodeList) {
            foreach ($elements_with_aria as $element) {
                if (!$element instanceof \DOMElement) {
                    continue;
                }

                foreach ($element->attributes as $attr) {
                    if (strpos($attr->name, 'aria-') === 0) {
                        if (empty($attr->value)) {
                            $this->add_issue(
                                $post_id,
                                $url,
                                $scan_id,
                                'aria_attributes',
                                'error',
/* translators: %1$: value */
                                sprintf(__('Empty ARIA attribute: %1$s', 'prorank-seo'), $attr->name),
                                $this->get_element_html($element)
                            );
                        }

                        if (in_array($attr->name, ['aria-labelledby', 'aria-describedby'], true)) {
                            foreach (preg_split('/\s+/', trim($attr->value)) ?: [] as $idref) {
                                if ($idref === '') {
                                    continue;
                                }

                                $targets = $xpath->query('//*[@id=' . $this->xpath_literal($idref) . ']');
                                if (!$targets instanceof \DOMNodeList || $targets->length === 0) {
                                    $this->add_issue(
                                        $post_id,
                                        $url,
                                        $scan_id,
                                        'aria_attributes',
                                        'error',
                                        sprintf(__('ARIA reference points to missing ID: %1$s', 'prorank-seo'), $idref),
                                        $this->get_element_html($element)
                                    );
                                }
                            }
                        }
                    }
                }
            }
        }

        $elements_with_role = $xpath->query('//*[@role]');
        if (!$elements_with_role instanceof \DOMNodeList) {
            return;
        }

        foreach ($elements_with_role as $element) {
            if (!$element instanceof \DOMElement) {
                continue;
            }

            foreach (preg_split('/\s+/', trim($element->getAttribute('role'))) ?: [] as $role) {
                if ($role === '') {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'aria_attributes',
                        'error',
                        __('Empty role attribute', 'prorank-seo'),
                        $this->get_element_html($element)
                    );
                    continue;
                }

                if (!in_array($role, $valid_roles, true)) {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'aria_attributes',
                        'error',
/* translators: %1$: value */
                        sprintf(__('Invalid ARIA role: %1$s', 'prorank-seo'), $role),
                        $this->get_element_html($element)
                    );
                }
            }
        }
    }

    private function check_focus_visible(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $focusable = $xpath->query('//a[@href] | //button | //input[not(@type="hidden")] | //select | //textarea | //*[@tabindex]');

        foreach ($focusable as $element) {
            if (!$element instanceof \DOMElement) {
                continue;
            }

            $style = strtolower($element->getAttribute('style'));
            if ($style !== '' && preg_match('/outline\s*:\s*(?:0|none)/', $style) && !preg_match('/box-shadow|border|background/', $style)) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'focus_visible',
                    'warning',
                    __('Focusable element suppresses outline without an obvious replacement focus indicator', 'prorank-seo'),
                    $this->get_element_html($element)
                );
            }
        }
    }

    private function check_landmark_regions(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $main = $xpath->query('//main | //*[@role="main"]');

        if (!$main instanceof \DOMNodeList || $main->length === 0) {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'landmark_regions',
                'warning',
                __('Page has no main landmark region', 'prorank-seo')
            );
        } elseif ($main->length > 1) {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'landmark_regions',
                'warning',
                __('Page has multiple main landmark regions', 'prorank-seo')
            );
        }
    }

    private function check_focus_order(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $positive_tabindex = $xpath->query('//*[@tabindex and number(@tabindex) > 0]');

        foreach ($positive_tabindex as $element) {
            if (!$element instanceof \DOMElement) {
                continue;
            }

            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'focus_order',
                'warning',
                __('Positive tabindex can create an unexpected keyboard focus order', 'prorank-seo'),
                $this->get_element_html($element),
                ['tabindex' => $element->getAttribute('tabindex')]
            );
        }
    }

    /**
     * Flag fixed/sticky overlays that can hide keyboard focus.
     *
     * Full WCAG 2.4.11 evaluation needs browser focus traversal. This static
     * check conservatively identifies likely overlays for manual review.
     *
     * @param \DOMDocument $dom DOM document.
     * @param int          $post_id Post ID.
     * @param string       $url URL.
     * @param string       $scan_id Scan ID.
     * @return void
     */
    private function check_focus_not_obscured(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $elements = $xpath->query('//*[@style]');
        $reported = 0;

        foreach ($elements as $element) {
            if (!$element instanceof \DOMElement || $this->is_element_hidden($element)) {
                continue;
            }

            $style = strtolower($element->getAttribute('style'));
            if (!preg_match('/position\s*:\s*(fixed|sticky)/', $style, $position_match)) {
                continue;
            }

            if (!preg_match('/\b(?:top|bottom|left|right)\s*:/', $style)) {
                continue;
            }

            $identity = strtolower(trim($element->getAttribute('id') . ' ' . $element->getAttribute('class')));
            $looks_like_overlay = preg_match('/cookie|banner|bar|sticky|fixed|modal|popup|chat|notice|header|footer|toolbar/', $identity);
            $z_index = 0;
            if (preg_match('/z-index\s*:\s*(-?\d+)/', $style, $z_match)) {
                $z_index = (int) $z_match[1];
            }

            if (!$looks_like_overlay && $z_index < 10) {
                continue;
            }

            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'focus_not_obscured',
                'notice',
                __('Fixed or sticky overlay may obscure keyboard focus; verify focused controls remain at least partially visible', 'prorank-seo'),
                $this->get_element_html($element),
                [
                    'position' => $position_match[1],
                    'z_index' => $z_index,
                ]
            );

            $reported++;
            if ($reported >= 10) {
                return;
            }
        }
    }

    /**
     * Check for skip navigation links
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_skip_navigation(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);

        // Look for skip navigation links
        $skip_links = $xpath->query('//a[contains(@class, "skip-link") or contains(@class, "skip-nav") or contains(text(), "Skip to")]');

        if ($skip_links->length === 0) {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'skip_navigation',
                'warning',
                __('No skip navigation link found', 'prorank-seo'),
                '',
                ['description' => __('Add a skip navigation link to help keyboard users bypass repetitive content', 'prorank-seo')]
            );
        }
    }

    /**
     * Check for video captions
     *
     * @param \DOMDocument $dom DOM document
     * @param int $post_id Post ID
     * @param string $url URL
     * @param string $scan_id Scan ID
     * @return void
     */
    private function check_video_captions(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);

        // Check video elements
        $videos = $xpath->query('//video');

        foreach ($videos as $video) {
            $has_track = false;
            $tracks = $xpath->query('.//track[@kind="captions" or @kind="subtitles"]', $video);

            if ($tracks->length > 0) {
                $has_track = true;
            }

            if (!$has_track) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'video_captions',
                    'error',
                    __('Video missing captions', 'prorank-seo'),
                    $this->get_element_html($video),
                    ['src' => $video->getAttribute('src')]
                );
            }
        }

        // Check for YouTube/Vimeo embeds
        $iframes = $xpath->query('//iframe[contains(@src, "youtube.com") or contains(@src, "vimeo.com")]');

        foreach ($iframes as $iframe) {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'video_captions',
                'warning',
                __('Embedded video - verify captions are available', 'prorank-seo'),
                $this->get_element_html($iframe),
                ['src' => $iframe->getAttribute('src')]
            );
        }
    }

    private function check_audio_descriptions(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $videos = $xpath->query('//video');

        foreach ($videos as $video) {
            if (!$video instanceof \DOMElement) {
                continue;
            }

            $tracks = $xpath->query('.//track[@kind="descriptions"]', $video);
            if ($tracks instanceof \DOMNodeList && $tracks->length > 0) {
                continue;
            }

            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'audio_descriptions',
                'notice',
                __('Video has no audio description track; verify visual-only information is available another way', 'prorank-seo'),
                $this->get_element_html($video)
            );
        }
    }

    private function check_dragging_movements(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $drag_elements = $xpath->query('//*[@draggable="true" or @ondragstart or @ondrop or contains(concat(" ", normalize-space(@class), " "), " draggable ") or contains(concat(" ", normalize-space(@class), " "), " sortable ")]');

        foreach ($drag_elements as $element) {
            if (!$element instanceof \DOMElement) {
                continue;
            }

            $has_keyboard_hint = $element->hasAttribute('aria-describedby') || $element->hasAttribute('aria-label') || $element->hasAttribute('tabindex');
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'dragging_movements',
                $has_keyboard_hint ? 'notice' : 'warning',
                __('Drag interaction detected; verify there is a single-pointer non-drag alternative', 'prorank-seo'),
                $this->get_element_html($element)
            );
        }
    }

    private function check_target_size_minimum(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $targets = $xpath->query('//a[@href] | //button | //input[not(@type="hidden")] | //select | //textarea | //*[@role="button" or @role="link"]');

        foreach ($targets as $target) {
            if (!$target instanceof \DOMElement) {
                continue;
            }

            $dimensions = $this->get_explicit_element_dimensions($target);
            if (empty($dimensions)) {
                continue;
            }

            $width = $dimensions['width'] ?? null;
            $height = $dimensions['height'] ?? null;
            if (($width !== null && $width < 24) || ($height !== null && $height < 24)) {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'target_size',
                    'warning',
                    __('Interactive target has explicit dimensions below 24 CSS pixels', 'prorank-seo'),
                    $this->get_element_html($target),
                    $dimensions
                );
            }
        }
    }

    private function get_explicit_element_dimensions(\DOMElement $element): array {
        $dimensions = [];
        foreach (['width', 'height'] as $dimension) {
            $value = $element->getAttribute($dimension);
            if ($value !== '' && is_numeric($value)) {
                $dimensions[$dimension] = (float) $value;
            }
        }

        $style = $element->getAttribute('style');
        foreach (['width', 'height'] as $dimension) {
            if (preg_match('/(?:^|;)\s*' . $dimension . '\s*:\s*(\d+(?:\.\d+)?)px/i', $style, $matches)) {
                $dimensions[$dimension] = (float) $matches[1];
            }
        }

        return $dimensions;
    }

    private function check_consistent_help(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        // WCAG 2.2 SC 3.2.6 is a cross-page consistency check. A reliable
        // result needs template-level comparison, so avoid noisy per-page
        // findings from the server-side scanner.
    }

    private function check_redundant_entry(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $forms = $xpath->query('//form');

        foreach ($forms as $form) {
            if (!$form instanceof \DOMElement) {
                continue;
            }

            $names = [];
            $inputs = $xpath->query('.//input[not(@type="hidden") and not(@type="submit") and not(@type="button")] | .//textarea | .//select', $form);
            foreach ($inputs as $input) {
                if (!$input instanceof \DOMElement) {
                    continue;
                }

                $name = strtolower(trim($input->getAttribute('name') ?: $input->getAttribute('id')));
                if ($name === '') {
                    continue;
                }

                $names[$name] = ($names[$name] ?? 0) + 1;
            }

            foreach ($names as $name => $count) {
                if ($count > 1) {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'redundant_entry',
                        'notice',
                        sprintf(__('Form asks for "%1$s" more than once; verify repeated entry is avoided or auto-populated', 'prorank-seo'), $name),
                        $this->get_element_html($form)
                    );
                }
            }
        }
    }

    private function check_accessible_authentication(\DOMDocument $dom, int $post_id, string $url, string $scan_id): void {
        $xpath = new \DOMXPath($dom);
        $passwords = $xpath->query('//input[@type="password"]');

        foreach ($passwords as $password) {
            if (!$password instanceof \DOMElement) {
                continue;
            }

            if (trim($password->getAttribute('autocomplete')) === '') {
                $this->add_issue(
                    $post_id,
                    $url,
                    $scan_id,
                    'accessible_authentication',
                    'warning',
                    __('Password field lacks autocomplete support for password managers', 'prorank-seo'),
                    $this->get_element_html($password)
                );
            }
        }

        $captcha = $xpath->query('//*[contains(translate(@class, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "captcha") or contains(translate(@id, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "captcha")]');
        if ($captcha instanceof \DOMNodeList && $captcha->length > 0) {
            $this->add_issue(
                $post_id,
                $url,
                $scan_id,
                'accessible_authentication',
                'notice',
                __('CAPTCHA/authentication challenge detected; verify a non-cognitive accessible alternative is available', 'prorank-seo')
            );
        }
    }
}
