<?php

/**
 * Accessibility Checks Module
 *
 * Performs WCAG 2.2 AA+ compliance 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 = 'WCAG 2.2 AA+ compliance 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' => true,
        ],
        '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.1',
            'description' => 'Parsing',
            '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' => true,
        ],
        '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' => true,
        ],
        'focus_order' => [
            'level' => 'A',
            'guideline' => '2.4.3',
            'description' => 'Focus Order',
            '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.7.0',
            ]),
            '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);
        }
        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['aria_attributes'])) {
            $this->check_aria_attributes($content_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);
        }
        
        // 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 array_merge($this->get_default_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, $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');
        unset($post_types['attachment']);

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

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

    /**
     * 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): void {
        $xpath = new \DOMXPath($dom);
        $images = $xpath->query('//img');
        
        foreach ($images as $img) {
            $alt = $img->getAttribute('alt');
            $src = $img->getAttribute('src');
            
            // Skip decorative images with empty alt
            $role = $img->getAttribute('role');
            if ($role === 'presentation' && $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') {
                $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)]
                );
            }
        }
    }

    /**
     * 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) {
            $text = trim($link->textContent);
            $href = $link->getAttribute('href');
            
            // Check for empty link text
            if (empty($text)) {
                // Check for image inside link
                $images = $xpath->query('.//img', $link);
                if ($images->length === 0) {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'link_text',
                        'error',
                        __('Link has no text content', '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]
                );
            }
        }
    }
    
    /**
     * 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='{$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) {
                foreach ($elements as $element) {
                    $this->add_issue(
                        $post_id,
                        $url,
                        $scan_id,
                        'duplicate_ids',
                        'error',
/* translators: %1$: value */
                        sprintf(__('Duplicate ID "%1$s" found', 'prorank-seo'), $id),
                        $this->get_element_html($element),
                        ['id' => $id, 'count' => count($elements)]
                    );
                }
            }
        }
    }
    
    /**
     * 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) {
            $text = '';
            
            if ($button->tagName === 'button') {
                $text = trim($button->textContent);
            } else {
                $text = $button->getAttribute('value');
            }
            
            // Check for aria-label
            if (empty($text) && $button->hasAttribute('aria-label')) {
                $text = $button->getAttribute('aria-label');
            }
            
            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':
                // Auto-generate alt text for images
                $fixed = $this->auto_fix_image_alt($post, $content);
                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) {
            // Mark issue as fixed
            $wpdb->update(
                $this->table_name,
                ['severity' => 'fixed'],
                ['id' => $issue_id],
                ['%s'],
                ['%d']
            );
        }
        
        return $fixed;
    }
    
    /**
     * 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', $this->get_default_settings());
        
        return rest_ensure_response([
            'success' => true,
            'data' => $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', 'language_attribute', 'page_title', 'skip_navigation', 'landmark_regions'],
            'exclude_post_types' => [],
            'exclude_post_ids' => [],
            'axe_core_enabled' => false,
            'real_time_checking' => false,
        ];
    }
    
    /**
     * 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 $valid;
    }
    
    /**
     * 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);
        
        // 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) {
            return;
        }

        foreach ($elements_with_aria as $element) {
            foreach ($element->attributes as $attr) {
                if (strpos($attr->name, 'aria-') === 0) {
                    // Check for empty ARIA attributes
                    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)
                        );
                    }
                    
                    // Check for invalid ARIA roles
                    if ($attr->name === 'aria-role') {
                        $valid_roles = ['alert', 'button', 'checkbox', 'dialog', 'link', 'menu', 'menuitem', 'navigation', 'progressbar', 'radio', 'search', 'tab', 'tabpanel', 'textbox', 'toolbar'];
                        if (!in_array($attr->value, $valid_roles)) {
                            $this->add_issue(
                                $post_id,
                                $url,
                                $scan_id,
                                'aria_attributes',
                                'error',
/* translators: %1$: value */
                                sprintf(__('Invalid ARIA role: %1$s', 'prorank-seo'), $attr->value),
                                $this->get_element_html($element)
                            );
                        }
                    }
                }
            }
        }
    }
    
    /**
     * 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')]
            );
        }
    }
}
