<?php

namespace WPSN\Modules\SchemaValidator\Admin;

use WPSN\Core\Container;
use WPSN\Core\Capabilities;
use WPSN\Core\Logger;
use WPSN\Core\ModuleRegistry;
use WPSN\Core\Settings;
use WPSN\Admin\SettingsRepository;
use WPSN\Modules\SchemaValidator\SchemaIssueRepository;
use WPSN\Modules\SchemaValidator\SchemaScanRepository;
use WPSN\Modules\SchemaValidator\SchemaValidationService;
use WPSN\Modules\SchemaValidator\SchemaValidatorJobs;
use WPSN\Modules\SearchConsole\Admin\SearchConsoleAdmin;
use WPSN\SEO\UserMetaKeys;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;

defined('ABSPATH') || exit;

final class SchemaValidatorAdmin
{
    public const PAGE_SLUG = 'wpsn-schema-validator';
    public const AJAX_NONCE_ACTION = 'wpsn_schema_validator_nonce';

    private Container $container;
    private Settings $settings;
    private Logger $logger;

    private ?string $hookSuffix = null;

    private SchemaScanRepository $scans;
    private SchemaIssueRepository $issues;
    private SchemaValidationService $validator;

    private ?RevisionsRepository $revRepo = null;

    /**
     * Default chunk size for batched "safe fixes".
     * Keep modest to avoid timeouts on shared hosting.
     */
    private int $fixChunkSize = 50;

    public function __construct(Container $container)
    {
        $this->container = $container;
        $this->settings = $container->get('settings');
        $this->logger = $container->get('logger');

        $this->scans = new SchemaScanRepository();
        $this->issues = new SchemaIssueRepository();

        /** @var \WPSN\SEO\SeoMetaRepository $metaRepo */
        $metaRepo = $container->get('meta_repo');
        /** @var \WPSN\SEO\SchemaGraphBuilder $schemaBuilder */
        $schemaBuilder = $container->get('schema_builder');

        $this->validator = new SchemaValidationService(
            $metaRepo,
            $schemaBuilder,
            $this->settings,
            $this->logger,
            $container->get('schema_custom_json_ld')
        );
    }

    public function register(): void
    {
        add_action('admin_menu', [$this, 'addMenu']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);

        // Non-AJAX actions (preferred on hosts where admin-ajax is blocked/503).
        add_action('admin_post_wpsn_schema_scan_start', [$this, 'handleScanStart']);
        add_action('admin_post_wpsn_schema_scan_cancel', [$this, 'handleScanCancel']);
        add_action('admin_post_wpsn_schema_clear_all', [$this, 'handleClearAll']);
        add_action('admin_post_wpsn_schema_fix_safe_start', [$this, 'handleFixSafeStart']);
        add_action('admin_post_wpsn_schema_fix_safe_cancel', [$this, 'handleFixSafeCancel']);

        add_action('wp_ajax_wpsn_schema_scan_start', [$this, 'ajaxScanStart']);
        add_action('wp_ajax_wpsn_schema_scan_step', [$this, 'ajaxScanStep']);
        add_action('wp_ajax_wpsn_schema_scan_cancel', [$this, 'ajaxScanCancel']);
        add_action('wp_ajax_wpsn_schema_clear_all', [$this, 'ajaxClearAll']);
        add_action('wp_ajax_wpsn_schema_quick_fix', [$this, 'ajaxQuickFix']);
        // Back-compat: older UI used a single endpoint for safe fixes.
        add_action('wp_ajax_wpsn_schema_fix_safe', [$this, 'ajaxFixSafe']);
        // New batched workflow with progress.
        add_action('wp_ajax_wpsn_schema_fix_safe_start', [$this, 'ajaxFixSafeStart']);
        add_action('wp_ajax_wpsn_schema_fix_safe_step', [$this, 'ajaxFixSafeStep']);
        add_action('wp_ajax_wpsn_schema_fix_safe_cancel', [$this, 'ajaxFixSafeCancel']);
        add_action('admin_post_wpsn_schema_export_csv', [$this, 'handleExportCsv']);
    }

    public function addMenu(): void
    {
        $this->hookSuffix = add_management_page(
            __('Schema Validator', 'wp-seo-next'),
            __('Schema Validator', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            self::PAGE_SLUG,
            [$this, 'render']
        );
    }

    public function enqueueAssets(string $hookSuffix): void
    {
        if ($this->hookSuffix === null || $hookSuffix !== $this->hookSuffix) {
            return;
        }

        // Needed for quick-fixes (media picker).
        wp_enqueue_media();

        wp_enqueue_style(
            'wpsn-schema-validator-admin',
            WPSN_PLUGIN_URL . 'assets/admin/schema-validator.css',
            [],
            WPSN_VERSION
        );

        wp_enqueue_script(
            'wpsn-schema-validator-admin',
            WPSN_PLUGIN_URL . 'assets/admin/schema-validator.js',
            [],
            WPSN_VERSION,
            true
        );

        wp_localize_script('wpsn-schema-validator-admin', 'WPSN_SCHEMA_VALIDATOR', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce(self::AJAX_NONCE_ACTION),
            'scanId' => (int) (isset($_GET['scan_id']) ? (int) $_GET['scan_id'] : 0),
            'strings' => [
                'starting' => __('Starting…', 'wp-seo-next'),
                'processing' => __('Processing…', 'wp-seo-next'),
                'done' => __('Done.', 'wp-seo-next'),
                'cancelled' => __('Cancelled.', 'wp-seo-next'),
                'error' => __('Error.', 'wp-seo-next'),
                'confirmClear' => __('Clear all schema scans and issues?', 'wp-seo-next'),
                'confirmFixAll' => __('Apply safe schema fixes? This may update settings and user profile fields.', 'wp-seo-next'),
                'fixStarting' => __('Starting safe fixes…', 'wp-seo-next'),
                'fixProcessing' => __('Applying safe fixes…', 'wp-seo-next'),
                'fixAllDone' => __('Safe fixes applied. Re-scan to verify.', 'wp-seo-next'),
                'fixAllDryRun' => __('Dry run completed (no changes were saved).', 'wp-seo-next'),
                'fixAllNone' => __('Nothing to fix (already OK).', 'wp-seo-next'),
                'fixSelectOne' => __('Select at least one fix option.', 'wp-seo-next'),
                'fixing' => __('Applying…', 'wp-seo-next'),
                'fixApplied' => __('Applied. Re-scan to verify.', 'wp-seo-next'),
                'fixFailed' => __('Fix failed.', 'wp-seo-next'),
                'pickLogoTitle' => __('Select organization logo', 'wp-seo-next'),
                'pickLogoButton' => __('Use as logo', 'wp-seo-next'),
                'pickImageTitle' => __('Select featured image', 'wp-seo-next'),
                'pickImageButton' => __('Set featured image', 'wp-seo-next'),
                'pickAuthorImageTitle' => __('Select author image', 'wp-seo-next'),
                'pickAuthorImageButton' => __('Use as author image', 'wp-seo-next'),
            ],
        ]);

        // Optional: enqueue GSC inspect handler on this page too.
        /** @var ModuleRegistry $modules */
        $modules = $this->container->get('modules');
        if ($modules->isEnabled('search_console')) {
            wp_enqueue_script(
                'wpsn-gsc-admin',
                WPSN_PLUGIN_URL . 'assets/admin/gsc.js',
                [],
                WPSN_VERSION,
                true
            );

            wp_localize_script('wpsn-gsc-admin', 'WPSN_GSC', [
                'ajaxUrl' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce(SearchConsoleAdmin::AJAX_NONCE_ACTION),
                'strings' => [
                    'inspectLoading' => __('Loading…', 'wp-seo-next'),
                    'inspectFailed' => __('Failed.', 'wp-seo-next'),
                ],
            ]);
        }
    }

    private function pageUrl(array $args = []): string
    {
        $base = admin_url('tools.php');
        $args = array_merge(['page' => self::PAGE_SLUG], $args);
        return (string) add_query_arg($args, $base);
    }

    /**
     * Start a scan using the internal Queue (non-AJAX).
     */
    public function handleScanStart(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to run schema scans.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_schema_scan_start');

        // Prevent multiple concurrent scans (simple safety).
        $latest = $this->scans->latest();
        if ($latest && isset($latest['status']) && (string) $latest['status'] === 'running') {
            $id = isset($latest['id']) ? (int) $latest['id'] : 0;
            if ($id > 0) {
                wp_safe_redirect($this->pageUrl(['scan_id' => $id, 'notice' => 'scan_running']));
                exit;
            }
        }

        $publicPostTypes = get_post_types(['public' => true], 'objects');
        $publicTaxonomies = get_taxonomies(['public' => true], 'objects');

        $postTypes = [];
        if (isset($_POST['post_types']) && is_array($_POST['post_types'])) {
            foreach (wp_unslash($_POST['post_types']) as $pt) {
                $pt = sanitize_key((string) $pt);
                if ($pt === '' || $pt === 'attachment') {
                    continue;
                }
                if (is_array($publicPostTypes) && isset($publicPostTypes[$pt])) {
                    $postTypes[] = $pt;
                }
            }
        }

        $includeTerms = !empty($_POST['include_terms']);

        $taxonomies = [];
        if ($includeTerms && isset($_POST['taxonomies']) && is_array($_POST['taxonomies'])) {
            foreach (wp_unslash($_POST['taxonomies']) as $tax) {
                $tax = sanitize_key((string) $tax);
                if ($tax === '' || $tax === 'post_format') {
                    continue;
                }
                if (is_array($publicTaxonomies) && isset($publicTaxonomies[$tax])) {
                    $taxonomies[] = $tax;
                }
            }
        }

        if (empty($postTypes) && (!$includeTerms || empty($taxonomies))) {
            wp_safe_redirect($this->pageUrl(['notice' => 'scan_empty']));
            exit;
        }

        $params = [
            'post_types' => $postTypes,
            'include_terms' => $includeTerms,
            'taxonomies' => $taxonomies,
            'mode' => 'queue',
        ];

        $scanId = $this->scans->create($params);
        if ($scanId <= 0) {
            wp_safe_redirect($this->pageUrl(['notice' => 'scan_failed']));
            exit;
        }

        // Totals (for progress UI).
        $totalPosts = 0;
        foreach ($postTypes as $pt) {
            $c = wp_count_posts($pt);
            if (is_object($c) && isset($c->publish)) {
                $totalPosts += (int) $c->publish;
            }
        }

        $totalTerms = 0;
        if ($includeTerms) {
            foreach ($taxonomies as $tax) {
                $cnt = wp_count_terms($tax, ['hide_empty' => false]);
                if (!is_wp_error($cnt)) {
                    $totalTerms += (int) $cnt;
                }
            }
        }

        $state = [
            'stage' => 'posts',
            'post_types' => $postTypes,
            'pt_index' => 0,
            'last_post_id' => 0,
            'include_terms' => $includeTerms,
            'taxonomies' => $taxonomies,
            'tax_index' => 0,
            'last_term_id' => 0,
            'scanned_posts' => 0,
            'scanned_terms' => 0,
            'total_posts' => $totalPosts,
            'total_terms' => $totalTerms,
            'issues' => 0,
            'started' => time(),
        ];

        $this->scans->updateStats($scanId, [
            'stage' => 'queued',
            'scanned_posts' => 0,
            'scanned_terms' => 0,
            'issues' => 0,
            'total_posts' => (int) $totalPosts,
            'total_terms' => (int) $totalTerms,
            'total' => (int) ($totalPosts + $totalTerms),
            'updated_ts' => time(),
        ]);

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $queue->enqueue(SchemaValidatorJobs::JOB_SCAN_STEP, [
            'scan_id' => $scanId,
            'chunk' => 25,
            'state' => $state,
        ]);

        wp_safe_redirect($this->pageUrl(['scan_id' => $scanId, 'notice' => 'scan_started']));
        exit;
    }

    /**
     * Cancel a running scan (non-AJAX).
     */
    public function handleScanCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to cancel schema scans.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_schema_scan_cancel');

        $scanId = isset($_POST['scan_id']) ? (int) wp_unslash($_POST['scan_id']) : 0;
        if ($scanId <= 0) {
            wp_safe_redirect($this->pageUrl(['notice' => 'scan_cancel_failed']));
            exit;
        }

        $scan = $this->scans->get($scanId);
        if (!$scan) {
            wp_safe_redirect($this->pageUrl(['notice' => 'scan_cancel_failed']));
            exit;
        }

        $stats = [];
        if (isset($scan['stats']) && is_string($scan['stats']) && $scan['stats'] !== '') {
            $decoded = json_decode((string) $scan['stats'], true);
            if (is_array($decoded)) {
                $stats = $decoded;
            }
        }
        $stats['stage'] = 'cancelled';
        $stats['updated_ts'] = time();

        $this->scans->finish($scanId, $stats, 'cancelled');

        wp_safe_redirect($this->pageUrl(['scan_id' => $scanId, 'notice' => 'scan_cancelled']));
        exit;
    }

    /**
     * Clear all scans + issues (non-AJAX).
     */
    public function handleClearAll(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to clear schema scans.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_schema_clear_all');

        $this->issues->clearAll();
        $this->scans->clearAll();

        wp_safe_redirect($this->pageUrl(['notice' => 'cleared']));
        exit;
    }


    /**
     * Start "Safe fixes" using the internal Queue (non-AJAX primary path).
     *
     * This avoids admin-ajax 503 issues on many shared hosts.
     */
    public function handleFixSafeStart(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to apply schema safe fixes.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_schema_fix_safe_start');

        // Prevent overlapping runs per-user (same behavior as the AJAX workflow).
        $existing = get_transient($this->fixStateKey());
        if (is_array($existing) && !empty($existing['session'])) {
            wp_safe_redirect($this->pageUrl(['notice' => 'fix_running']));
            exit;
        }

        $scanId = isset($_POST['scan_id']) ? (int) wp_unslash($_POST['scan_id']) : 0;
        if ($scanId <= 0) {
            // Best-effort: fallback to latest scan.
            $latest = $this->scans->latest();
            if ($latest && isset($latest['id'])) {
                $scanId = (int) $latest['id'];
            }
        }

        $dryRun = isset($_POST['dry_run']) ? ((int) wp_unslash($_POST['dry_run']) === 1) : false;

        $fixOrgLogo = !empty($_POST['fix_org_logo']);
        $fixOrgName = !empty($_POST['fix_org_name']);
        $fixAuthorImage = !empty($_POST['fix_author_image']);
        $fixAuthorSameAs = !empty($_POST['fix_author_sameas']);
        $allowOrgWithoutScan = !empty($_POST['allow_org_without_scan']);

        // No scan => only org fixes are allowed.
        if ($scanId <= 0) {
            $fixAuthorImage = false;
            $fixAuthorSameAs = false;
        }

        $hasAny = $fixOrgLogo || $fixOrgName || $fixAuthorImage || $fixAuthorSameAs;
        if (!$hasAny) {
            wp_safe_redirect($this->pageUrl(['scan_id' => $scanId > 0 ? $scanId : null, 'notice' => 'fix_select_one']));
            exit;
        }

        if ($scanId <= 0 && !$allowOrgWithoutScan) {
            wp_safe_redirect($this->pageUrl(['notice' => 'fix_no_scan']));
            exit;
        }

        // Clear last result so the UI doesn't show stale output.
        delete_transient($this->fixLastKey());

        // Build initial result skeleton.
        $result = [
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'fixed' => [],
            'skipped' => [],
            'errors' => [],
            'counts' => [
                'total_users' => 0,
                'processed_users' => 0,
                'target_author_image' => 0,
                'target_author_sameas' => 0,
                'fixed_author_image' => 0,
                'fixed_author_sameas' => 0,
                'skipped_users' => 0,
                'skipped_user_forbidden' => 0,
                'skipped_user_not_found' => 0,
                'skipped_author_image_no_avatar' => 0,
                'skipped_author_image_already_set' => 0,
                'skipped_sameas_no_links' => 0,
                'skipped_author_sameas_no_links' => 0,
                'skipped_author_sameas_already_set' => 0,
                'target_org_logo' => 0,
                'target_org_name' => 0,
                'fixed_org_logo' => 0,
                'fixed_org_name' => 0,
                'skipped_org_logo_already_set' => 0,
                'skipped_org_name_already_set' => 0,
            ],
            'options' => [
                'org_logo' => $fixOrgLogo ? 1 : 0,
                'org_name' => $fixOrgName ? 1 : 0,
                'author_image' => $fixAuthorImage ? 1 : 0,
                'author_sameas' => $fixAuthorSameAs ? 1 : 0,
                'allow_org_without_scan' => $allowOrgWithoutScan ? 1 : 0,
            ],
        ];

        // Org-level safe fixes are constant-cost - apply them immediately.
        $this->applySafeOrgFixes($result, $dryRun, [
            'org_logo' => $fixOrgLogo,
            'org_name' => $fixOrgName,
        ]);

        // Prepare user targets from the scan issues.
        $imageIds = [];
        $sameAsIds = [];

        if ($scanId > 0) {
            try {
                if ($fixAuthorImage) {
                    $imageIds = $this->issues->distinctObjectIds($scanId, 'user', [SchemaValidationService::ISSUE_PERSON_IMAGE_MISSING]);
                }
                if ($fixAuthorSameAs) {
                    $sameAsIds = $this->issues->distinctObjectIds($scanId, 'user', [SchemaValidationService::ISSUE_PERSON_SAMEAS_MISSING]);
                }
            } catch (\Throwable $e) {
                $result['errors'][] = [
                    'type' => 'users',
                    'error' => $e->getMessage(),
                ];
            }
        }

        $allUserIds = [];
        if (is_array($imageIds) && $imageIds) {
            $allUserIds = array_merge($allUserIds, $imageIds);
        }
        if (is_array($sameAsIds) && $sameAsIds) {
            $allUserIds = array_merge($allUserIds, $sameAsIds);
        }
        $allUserIds = array_values(array_unique(array_map('intval', $allUserIds)));
        sort($allUserIds);

        $needImage = is_array($imageIds) ? array_fill_keys(array_map('intval', $imageIds), 1) : [];
        $needSameAs = is_array($sameAsIds) ? array_fill_keys(array_map('intval', $sameAsIds), 1) : [];

        $result['counts']['target_author_image'] = is_array($imageIds) ? count($imageIds) : 0;
        $result['counts']['target_author_sameas'] = is_array($sameAsIds) ? count($sameAsIds) : 0;
        $result['counts']['total_users'] = count($allUserIds);

        // No user targets -> finalize immediately.
        if (empty($allUserIds)) {
            $this->finalizeSafeFixResult($result, $scanId, $dryRun);
            $result['status'] = 'done';
            $result['finished_ts'] = time();

            set_transient($this->fixLastKey(), $result, 6 * HOUR_IN_SECONDS);

            if (!$dryRun) {
                do_action('wpsn/schema/fix_completed', $scanId, $result);
            }

            wp_safe_redirect($this->pageUrl(['scan_id' => $scanId > 0 ? $scanId : null, 'notice' => $dryRun ? 'fix_dry_done' : 'fix_done']));
            exit;
        }

        $session = wp_generate_password(16, false, false);

        $state = [
            'session' => $session,
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'cursor' => 0,
            'user_ids' => $allUserIds,
            'need_image' => $needImage,
            'need_sameas' => $needSameAs,
            'result' => $result,
            'started' => time(),
        ];

        set_transient($this->fixStateKey(), $state, HOUR_IN_SECONDS);

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $queue->enqueue(SchemaValidatorJobs::JOB_FIX_SAFE_STEP, [
            'actor_user_id' => (int) get_current_user_id(),
            'session' => $session,
            'chunk' => (int) $this->fixChunkSize,
        ]);

        wp_safe_redirect($this->pageUrl(['scan_id' => $scanId > 0 ? $scanId : null, 'notice' => $dryRun ? 'fix_dry_started' : 'fix_started']));
        exit;
    }


    /**
     * Cancel a running "Safe fixes" run (non-AJAX).
     */
    public function handleFixSafeCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to cancel schema safe fixes.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_schema_fix_safe_cancel');

        $state = get_transient($this->fixStateKey());
        delete_transient($this->fixStateKey());

        $result = null;
        if (is_array($state) && isset($state['result']) && is_array($state['result'])) {
            $result = $state['result'];
            $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
            if (!isset($result['counts']) || !is_array($result['counts'])) {
                $result['counts'] = [];
            }
            $result['counts']['processed_users'] = $cursor;
            $result['status'] = 'cancelled';
            $result['finished_ts'] = time();
        }

        if (is_array($result)) {
            set_transient($this->fixLastKey(), $result, 6 * HOUR_IN_SECONDS);
        }

        wp_safe_redirect($this->pageUrl(['notice' => 'fix_cancelled']));
        exit;
    }

    public function render(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to access this page.', 'wp-seo-next'));
        }

        $scanId = isset($_GET['scan_id']) ? (int) wp_unslash($_GET['scan_id']) : 0;
        $scan = $scanId > 0 ? $this->scans->get($scanId) : null;
        if (!$scan) {
            $scan = $this->scans->latest();
            $scanId = $scan && isset($scan['id']) ? (int) $scan['id'] : 0;
        }

        $sev = isset($_GET['sev']) ? (int) wp_unslash($_GET['sev']) : 0;
        $code = isset($_GET['code']) ? sanitize_key((string) wp_unslash($_GET['code'])) : '';
        $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';

        $paged = isset($_GET['paged']) ? max(1, (int) wp_unslash($_GET['paged'])) : 1;
        $perPage = 20;

        $filters = [
            'severity' => $sev,
            'issue_code' => $code,
            's' => $s,
        ];

        $issuesData = $scanId > 0 ? $this->issues->listByScan($scanId, $filters, $paged, $perPage) : ['rows' => [], 'total' => 0];
        $rows = $issuesData['rows'];
        $total = (int) $issuesData['total'];

        $codes = $scanId > 0 ? $this->issues->countByCode($scanId) : [];

        $scans = $this->scans->list(10);

        /** @var ModuleRegistry $modules */
        $modules = $this->container->get('modules');
        $schemaEnabled = $modules->isEnabled('schema');
        $gscEnabled = $modules->isEnabled('search_console');
        $gscProperty = (string) $this->settings->get('search_console.property', '');

        echo '<div class="wrap">';
        echo '<h1>' . esc_html__('Schema Validator', 'wp-seo-next') . '</h1>';

        $notice = isset($_GET['notice']) ? sanitize_key((string) wp_unslash($_GET['notice'])) : '';
        if ($notice === 'scan_started') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Schema scan started in background (Queue). Refresh this page later to see progress.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'scan_running') {
            echo '<div class="notice notice-info"><p>' . esc_html__('A schema scan is already running. Please wait for it to finish or cancel it.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'scan_empty') {
            echo '<div class="notice notice-warning"><p>' . esc_html__('Nothing to scan. Select at least one post type or taxonomy.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'scan_failed') {
            echo '<div class="notice notice-error"><p>' . esc_html__('Failed to start schema scan.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'scan_cancelled') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Schema scan cancelled.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_started') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Safe fixes started in background (Queue). Refresh this page later to see progress.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_dry_started') {
            echo '<div class="notice notice-info"><p>' . esc_html__('Dry run started in background (Queue). No changes will be saved.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_done') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Safe fixes completed. Re-scan to verify.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_dry_done') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Dry run completed (no changes were saved).', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_running') {
            echo '<div class="notice notice-info"><p>' . esc_html__('Safe fixes are already running. Please wait or cancel the run.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_no_scan') {
            echo '<div class="notice notice-warning"><p>' . esc_html__('To fix author fields, run a Schema scan first. Organization-only fixes can be run without a scan if explicitly allowed.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_select_one') {
            echo '<div class="notice notice-warning"><p>' . esc_html__('Select at least one safe fix option.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'fix_cancelled') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Safe fixes cancelled.', 'wp-seo-next') . '</p></div>';
        } elseif ($notice === 'cleared') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Schema scan results cleared.', 'wp-seo-next') . '</p></div>';
        }

        if (!$schemaEnabled) {
            echo '<div class="notice notice-warning"><p>' . esc_html__('Schema module is currently disabled. Validation can still run, but JSON-LD output may not appear on the front-end.', 'wp-seo-next') . '</p></div>';
        }

        // Controls.
        echo '<div class="wpsn-sv-controls">';
        echo '<h2>' . esc_html__('Run scan', 'wp-seo-next') . '</h2>';

        // Prefer non-AJAX scan (Queue). Show live-ish progress on refresh.
        $runningScan = null;
        $latestScan = $this->scans->latest();
        if ($latestScan && isset($latestScan['status']) && (string) $latestScan['status'] === 'running') {
            $runningScan = $latestScan;
        }

        $runningId = $runningScan && isset($runningScan['id']) ? (int) $runningScan['id'] : 0;
        $runningStats = [];
        if ($runningScan && isset($runningScan['stats']) && is_string($runningScan['stats']) && $runningScan['stats'] !== '') {
            $decoded = json_decode((string) $runningScan['stats'], true);
            if (is_array($decoded)) {
                $runningStats = $decoded;
            }
        }

        if ($runningId > 0) {
            $total = (int) ($runningStats['total'] ?? 0);
            $scanned = (int) ($runningStats['scanned_posts'] ?? 0) + (int) ($runningStats['scanned_terms'] ?? 0);
            $pct = ($total > 0) ? (int) round(($scanned / max(1, $total)) * 100) : 0;
            $pct = max(0, min(100, $pct));
            $stage = isset($runningStats['stage']) ? (string) $runningStats['stage'] : 'running';
            $issues = (int) ($runningStats['issues'] ?? 0);

            echo '<div class="notice notice-info" style="margin:12px 0 12px;">';
            echo '<p><strong>' . esc_html(sprintf(__('Scan #%d is running (%s).', 'wp-seo-next'), $runningId, $stage)) . '</strong> '; // translators: 1: scan id 2: stage
            echo esc_html(sprintf(__('Progress: %d%% · Issues: %d', 'wp-seo-next'), $pct, $issues)) . '</p>';
            echo '<div class="wpsn-sv-progress" style="display:block; margin:10px 0 0;">';
            echo '<div class="wpsn-sv-bar"><span style="width:' . esc_attr((string) $pct) . '%"></span></div>';
            echo '<div class="wpsn-sv-status">' . esc_html(sprintf(__('Posts: %d/%d · Terms: %d/%d', 'wp-seo-next'), (int) ($runningStats['scanned_posts'] ?? 0), (int) ($runningStats['total_posts'] ?? 0), (int) ($runningStats['scanned_terms'] ?? 0), (int) ($runningStats['total_terms'] ?? 0))) . '</div>';
            echo '</div>';
            echo '<p style="margin:10px 0 0;">';
            echo '<a class="button" href="' . esc_url($this->pageUrl(['scan_id' => $runningId])) . '">' . esc_html__('View scan', 'wp-seo-next') . '</a> ';
            echo '<a class="button" href="' . esc_url($this->pageUrl(['scan_id' => $runningId, 'refresh' => time()])) . '">' . esc_html__('Refresh', 'wp-seo-next') . '</a> ';
            echo '<a class="button" href="' . esc_url(admin_url('tools.php?page=wpsn-queue')) . '">' . esc_html__('Queue Center', 'wp-seo-next') . '</a> ';
            echo '</p>';

            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:10px;">';
            echo '<input type="hidden" name="action" value="wpsn_schema_scan_cancel" />';
            echo '<input type="hidden" name="scan_id" value="' . esc_attr((string) $runningId) . '" />';
            wp_nonce_field('wpsn_schema_scan_cancel');
            echo '<button type="submit" class="button">' . esc_html__('Cancel scan', 'wp-seo-next') . '</button>';
            echo '</form>';
            echo '</div>';
        }

        $publicPostTypes = get_post_types(['public' => true], 'objects');
        $publicTaxonomies = get_taxonomies(['public' => true], 'objects');

        echo '<p class="description">' . esc_html__('Runs in background using the internal Queue (no admin-ajax). Use “Refresh” to see progress.', 'wp-seo-next') . '</p>';

        echo '<form action="' . esc_url(admin_url('admin-post.php')) . '" method="post">';
        echo '<input type="hidden" name="action" value="wpsn_schema_scan_start" />';
        wp_nonce_field('wpsn_schema_scan_start');

        echo '<fieldset class="wpsn-sv-fieldset"><legend>' . esc_html__('Post types', 'wp-seo-next') . '</legend>';
        if (is_array($publicPostTypes)) {
            foreach ($publicPostTypes as $pt => $obj) {
                $pt = (string) $pt;
                if ($pt === 'attachment') {
                    continue;
                }
                $label = isset($obj->labels, $obj->labels->name) ? (string) $obj->labels->name : $pt;
                echo '<label class="wpsn-sv-check"><input type="checkbox" name="post_types[]" value="' . esc_attr($pt) . '" checked> ' . esc_html($label) . '</label>';
            }
        }
        echo '</fieldset>';

        echo '<fieldset class="wpsn-sv-fieldset"><legend>' . esc_html__('Terms', 'wp-seo-next') . '</legend>';
        echo '<label class="wpsn-sv-check"><input type="checkbox" name="include_terms" value="1" checked> ' . esc_html__('Include public taxonomies (categories/tags/etc.)', 'wp-seo-next') . '</label>';

        echo '<div class="wpsn-sv-taxonomies">';
        if (is_array($publicTaxonomies)) {
            foreach ($publicTaxonomies as $tax => $obj) {
                $tax = (string) $tax;
                if ($tax === 'post_format') {
                    continue;
                }
                $label = isset($obj->labels, $obj->labels->name) ? (string) $obj->labels->name : $tax;
                echo '<label class="wpsn-sv-check"><input type="checkbox" name="taxonomies[]" value="' . esc_attr($tax) . '" checked> ' . esc_html($label) . '</label>';
            }
        }
        echo '</div>';

        echo '</fieldset>';

        echo '<p class="wpsn-sv-actions">';
        echo '<button type="submit" class="button button-primary"' . disabled($runningId > 0, true, false) . '>' . esc_html__('Scan now', 'wp-seo-next') . '</button> ';
        echo '<a class="button" href="' . esc_url(admin_url('tools.php?page=wpsn-queue')) . '">' . esc_html__('Queue Center', 'wp-seo-next') . '</a>';
        echo '</p>';

        echo '</form>';

        // Clear results (non-AJAX).
        echo '<form action="' . esc_url(admin_url('admin-post.php')) . '" method="post" style="margin-top:10px;">';
        echo '<input type="hidden" name="action" value="wpsn_schema_clear_all" />';
        wp_nonce_field('wpsn_schema_clear_all');
        echo '<button type="submit" class="button" onclick="return confirm(\'' . esc_js(__('Clear all schema scan history and issues?', 'wp-seo-next')) . '\');">' . esc_html__('Clear results', 'wp-seo-next') . '</button>';
        echo '</form>';
        echo '</div>';

        // Scan history.
        echo '<hr />';
        echo '<h2>' . esc_html__('Recent scans', 'wp-seo-next') . '</h2>';

        if (empty($scans)) {
            echo '<p>' . esc_html__('No scans yet.', 'wp-seo-next') . '</p>';
        } else {
            echo '<table class="widefat striped">';
            echo '<thead><tr><th>' . esc_html__('Scan', 'wp-seo-next') . '</th><th>' . esc_html__('Status', 'wp-seo-next') . '</th><th>' . esc_html__('Started', 'wp-seo-next') . '</th><th>' . esc_html__('Finished', 'wp-seo-next') . '</th></tr></thead><tbody>';
            foreach ($scans as $r) {
                $id = isset($r['id']) ? (int) $r['id'] : 0;
                if ($id <= 0) {
                    continue;
                }
                $url = $this->pageUrl(['scan_id' => $id]);
                echo '<tr>';
                echo '<td><a href="' . esc_url($url) . '">' . esc_html('#' . $id) . '</a></td>';
                echo '<td>' . esc_html((string) ($r['status'] ?? '')) . '</td>';
                echo '<td>' . esc_html((string) ($r['started_at'] ?? '')) . '</td>';
                echo '<td>' . esc_html((string) ($r['finished_at'] ?? '')) . '</td>';
                echo '</tr>';
            }
            echo '</tbody></table>';
        }

        // Safe fix workflow (Queue-first; admin-ajax optional).
        echo '<hr />';
        echo '<h2>' . esc_html__('Fix workflow', 'wp-seo-next') . '</h2>';
        echo '<p class="description">' . esc_html__('Apply safe, low-risk fixes for common schema issues (no content rewriting). Recommended for large sites after a scan.', 'wp-seo-next') . '</p>';

        $fixDisabled = $scanId <= 0;

        // Show running state on refresh (non-AJAX friendly).
        $fixState = get_transient($this->fixStateKey());
        $fixRunning = is_array($fixState) && !empty($fixState['session']);
        if ($fixRunning) {
            $cursor = isset($fixState['cursor']) ? (int) $fixState['cursor'] : 0;
            $totalUsers = isset($fixState['user_ids']) && is_array($fixState['user_ids']) ? count($fixState['user_ids']) : 0;
            $pct = ($totalUsers > 0) ? (int) round(($cursor / max(1, $totalUsers)) * 100) : 0;
            $pct = max(0, min(100, $pct));
            $dry = !empty($fixState['dry_run']);

            $counts = [];
            if (isset($fixState['result']) && is_array($fixState['result']) && isset($fixState['result']['counts']) && is_array($fixState['result']['counts'])) {
                $counts = $fixState['result']['counts'];
            }

            echo '<div class="notice notice-info" style="margin:12px 0 12px;">';
            echo '<p><strong>' . esc_html($dry ? __('Safe fixes are running (dry run).', 'wp-seo-next') : __('Safe fixes are running.', 'wp-seo-next')) . '</strong> ';
            echo esc_html(sprintf(__('Processed %d / %d (%d%%).', 'wp-seo-next'), $cursor, $totalUsers, $pct)) . '</p>';
            echo '<div class="wpsn-sv-progress" style="display:block; margin:10px 0 0;">';
            echo '<div class="wpsn-sv-bar"><span style="width:' . esc_attr((string) $pct) . '%"></span></div>';
            echo '<div class="wpsn-sv-status">' . esc_html(sprintf(__('Target img: %d · Fixed img: %d · Target sameAs: %d · Fixed sameAs: %d', 'wp-seo-next'), (int) ($counts['target_author_image'] ?? 0), (int) ($counts['fixed_author_image'] ?? 0), (int) ($counts['target_author_sameas'] ?? 0), (int) ($counts['fixed_author_sameas'] ?? 0))) . '</div>';
            echo '</div>';
            echo '<p style="margin:10px 0 0;">';
            echo '<a class="button" href="' . esc_url($this->pageUrl(['scan_id' => $scanId > 0 ? $scanId : null, 'refresh' => time()])) . '">' . esc_html__('Refresh', 'wp-seo-next') . '</a> ';
            echo '<a class="button" href="' . esc_url(admin_url('tools.php?page=wpsn-queue')) . '">' . esc_html__('Queue Center', 'wp-seo-next') . '</a> ';
            echo '</p>';

            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:10px;">';
            echo '<input type="hidden" name="action" value="wpsn_schema_fix_safe_cancel" />';
            wp_nonce_field('wpsn_schema_fix_safe_cancel');
            echo '<button type="submit" class="button">' . esc_html__('Cancel safe fixes', 'wp-seo-next') . '</button>';
            echo '</form>';
            echo '</div>';
        }

        // Show last result snapshot (useful when the server is unstable and user refreshes later).
        $fixLast = get_transient($this->fixLastKey());
        if (!$fixRunning && is_array($fixLast) && !empty($fixLast)) {
            $status = isset($fixLast['status']) ? (string) $fixLast['status'] : 'done';
            $dry = !empty($fixLast['dry_run']);
            $counts = isset($fixLast['counts']) && is_array($fixLast['counts']) ? $fixLast['counts'] : [];

            $cls = 'notice-success';
            if ($status === 'failed') {
                $cls = 'notice-error';
            } elseif ($status === 'cancelled') {
                $cls = 'notice-warning';
            }

            echo '<div class="notice ' . esc_attr($cls) . '" style="margin:12px 0 12px;">';
            if ($status === 'failed') {
                $err = isset($fixLast['error']) ? (string) $fixLast['error'] : '';
                echo '<p><strong>' . esc_html__('Safe fixes failed.', 'wp-seo-next') . '</strong> ' . esc_html($err) . '</p>';
            } elseif ($status === 'cancelled') {
                echo '<p><strong>' . esc_html__('Safe fixes were cancelled.', 'wp-seo-next') . '</strong></p>';
            } else {
                echo '<p><strong>' . esc_html($dry ? __('Dry run completed (no changes were saved).', 'wp-seo-next') : __('Safe fixes completed.', 'wp-seo-next')) . '</strong></p>';
            }

            // Compact summary.
            echo '<details style="margin:8px 0 0;">';
            echo '<summary>' . esc_html__('Show summary', 'wp-seo-next') . '</summary>';
            echo '<pre style="white-space:pre-wrap; margin:8px 0 0;">';
            echo esc_html(
                'Organization logo: target ' . (int) ($counts['target_org_logo'] ?? 0) . ', fixed ' . (int) ($counts['fixed_org_logo'] ?? 0) . "\n" .
                'Organization name: target ' . (int) ($counts['target_org_name'] ?? 0) . ', fixed ' . (int) ($counts['fixed_org_name'] ?? 0) . "\n" .
                'Author image: target ' . (int) ($counts['target_author_image'] ?? 0) . ', fixed ' . (int) ($counts['fixed_author_image'] ?? 0) . "\n" .
                'Author sameAs: target ' . (int) ($counts['target_author_sameas'] ?? 0) . ', fixed ' . (int) ($counts['fixed_author_sameas'] ?? 0) . "\n" .
                'Skipped forbidden users: ' . (int) ($counts['skipped_user_forbidden'] ?? 0) . "\n" .
                'Skipped no avatar: ' . (int) ($counts['skipped_author_image_no_avatar'] ?? 0) . "\n" .
                'Skipped no links: ' . (int) ($counts['skipped_author_sameas_no_links'] ?? 0)
            );
            echo '</pre>';
            echo '</details>';
            echo '</div>';
        }

        if ($fixDisabled) {
            echo '<p><em>' . esc_html__('Run a scan first to enable author fixes (detected via schema issues). You can still run Organization fixes (logo/name) without a scan by enabling the option below.', 'wp-seo-next') . '</em></p>';
        }

        // Queue-first form (non-AJAX primary).
        echo '<form action="' . esc_url(admin_url('admin-post.php')) . '" method="post" style="margin-top:10px;">';
        echo '<input type="hidden" name="action" value="wpsn_schema_fix_safe_start" />';
        echo '<input type="hidden" name="scan_id" value="' . esc_attr((string) $scanId) . '" />';
        wp_nonce_field('wpsn_schema_fix_safe_start');

        echo '<fieldset class="wpsn-sv-fieldset" id="wpsn-schema-fix-safe-options">';
        echo '<legend>' . esc_html__('Safe fix options', 'wp-seo-next') . '</legend>';

        // Allow org-only fixes even if there is no scan.
        if ($fixDisabled) {
            echo '<label class="wpsn-sv-check"><input type="checkbox" id="wpsn-schema-fix-opt-org-without-scan" name="allow_org_without_scan" value="1"> ' . esc_html__('Include organization fixes even without a scan (logo/name)', 'wp-seo-next') . '</label>';
            echo '<div class="description" style="margin:6px 0 0 24px;">' . esc_html__('This will only apply Organization settings fixes. Author fixes still require a scan.', 'wp-seo-next') . '</div>';
        }

        echo '<label class="wpsn-sv-check"><input type="checkbox" id="wpsn-schema-fix-opt-org-logo" name="fix_org_logo" value="1" checked> ' . esc_html__('Organization logo (from theme logo / site icon)', 'wp-seo-next') . '</label>';
        echo '<label class="wpsn-sv-check"><input type="checkbox" id="wpsn-schema-fix-opt-org-name" name="fix_org_name" value="1" checked> ' . esc_html__('Organization name (from Site Title)', 'wp-seo-next') . '</label>';

        // If there is no scan, keep author options disabled by default (UX).
        $authorAttr = $fixDisabled ? ' disabled' : '';
        $authorChecked = $fixDisabled ? '' : ' checked';
        echo '<label class="wpsn-sv-check"><input type="checkbox" id="wpsn-schema-fix-opt-author-image" name="fix_author_image" value="1"' . $authorChecked . $authorAttr . '> ' . esc_html__('Author image (from avatar)', 'wp-seo-next') . '</label>';
        echo '<label class="wpsn-sv-check"><input type="checkbox" id="wpsn-schema-fix-opt-author-sameas" name="fix_author_sameas" value="1"' . $authorChecked . $authorAttr . '> ' . esc_html__('Author sameAs (from existing social links)', 'wp-seo-next') . '</label>';
        echo '</fieldset>';

        echo '<p class="wpsn-sv-actions">';
        echo '<button type="submit" class="button" name="dry_run" value="1"' . disabled($fixRunning, true, false) . '>' . esc_html__('Preview fixes (Queue)', 'wp-seo-next') . '</button> ';
        $confirmFix = esc_js(__('Apply safe fixes now? This will update Organization settings and/or author meta fields. You can undo changes in Revisions.', 'wp-seo-next'));
        echo '<button type="submit" class="button button-primary" name="dry_run" value="0" onclick="return confirm(\'' . $confirmFix . '\');"' . disabled($fixRunning, true, false) . '>' . esc_html__('Run safe fixes (Queue)', 'wp-seo-next') . '</button> ';
        echo '<a class="button" href="' . esc_url(admin_url('tools.php?page=wpsn-queue')) . '">' . esc_html__('Queue Center', 'wp-seo-next') . '</a>';
        echo '</p>';
        echo '</form>';

        // Advanced AJAX workflow (optional).
        echo '<details style="margin-top:12px;">';
        echo '<summary>' . esc_html__('Advanced: live progress via admin-ajax (may 503 on some hosts)', 'wp-seo-next') . '</summary>';
        echo '<p class="description" style="margin-top:8px;">' . esc_html__('If your host allows admin-ajax, you can use the live progress UI below. Otherwise, use the Queue buttons above and refresh the page later.', 'wp-seo-next') . '</p>';

        echo '<p class="wpsn-sv-actions">';
        echo '<button type="button" class="button" id="wpsn-schema-fix-safe-preview" data-scan="' . esc_attr((string) $scanId) . '"' . disabled($fixDisabled, true, false) . '>' . esc_html__('Preview fixes', 'wp-seo-next') . '</button> ';
        echo '<button type="button" class="button button-primary" id="wpsn-schema-fix-safe-apply" data-scan="' . esc_attr((string) $scanId) . '"' . disabled($fixDisabled, true, false) . '>' . esc_html__('Fix all safe', 'wp-seo-next') . '</button> ';
        echo '<button type="button" class="button" id="wpsn-schema-fix-safe-cancel" style="display:none;">' . esc_html__('Cancel', 'wp-seo-next') . '</button> ';
        echo '<span class="spinner" id="wpsn-schema-fix-safe-spinner" style="float:none;"></span>';
        echo '</p>';

        echo '<div id="wpsn-schema-fix-safe-progress" class="wpsn-sv-progress" style="display:none;">';
        echo '<div class="wpsn-sv-bar"><span id="wpsn-schema-fix-safe-bar" style="width:0%"></span></div>';
        echo '<div id="wpsn-schema-fix-safe-status" class="wpsn-sv-status"></div>';
        echo '</div>';

        // Counters (targets/fixed/left) + tooltips.
        echo '<div id="wpsn-schema-fix-safe-counters" class="wpsn-sv-counters" style="display:none;">';

        $tipTarget = esc_attr__('How many items are eligible to be fixed (from scan issues and/or missing settings).', 'wp-seo-next');
        $tipFixed = esc_attr__('How many items were actually updated by this run.', 'wp-seo-next');
        $tipAlready = esc_attr__('Targets skipped because the value was already set (scan may be stale).', 'wp-seo-next');
        $tipLeft = esc_attr__('Targets left = target − fixed − already set. These may need manual fixes.', 'wp-seo-next');

        $chip = static function (string $label, string $key, string $tip): string {
            return '<span class="wpsn-sv-chip">' .
                esc_html($label) .
                ': <span class="wpsn-sv-num" data-k="' . esc_attr($key) . '">0</span>' .
                ' <span class="wpsn-sv-tip" title="' . esc_attr($tip) . '">ⓘ</span>' .
                '</span>';
        };

        // Organization logo.
        echo '<div class="wpsn-sv-counter-row">';
        echo '<span class="wpsn-sv-counter-title">' . esc_html__('Organization logo', 'wp-seo-next') . '</span>';
        echo $chip(__('Target', 'wp-seo-next'), 'target_org_logo', $tipTarget);
        echo $chip(__('Fixed', 'wp-seo-next'), 'fixed_org_logo', $tipFixed);
        echo $chip(__('Already', 'wp-seo-next'), 'skipped_org_logo_already_set', $tipAlready);
        echo $chip(__('Left', 'wp-seo-next'), 'left_org_logo', $tipLeft);
        echo '</div>';

        // Organization name.
        echo '<div class="wpsn-sv-counter-row">';
        echo '<span class="wpsn-sv-counter-title">' . esc_html__('Organization name', 'wp-seo-next') . '</span>';
        echo $chip(__('Target', 'wp-seo-next'), 'target_org_name', $tipTarget);
        echo $chip(__('Fixed', 'wp-seo-next'), 'fixed_org_name', $tipFixed);
        echo $chip(__('Already', 'wp-seo-next'), 'skipped_org_name_already_set', $tipAlready);
        echo $chip(__('Left', 'wp-seo-next'), 'left_org_name', $tipLeft);
        echo '</div>';

        // Author image.
        echo '<div class="wpsn-sv-counter-row">';
        echo '<span class="wpsn-sv-counter-title">' . esc_html__('Author image', 'wp-seo-next') . '</span>';
        echo $chip(__('Target', 'wp-seo-next'), 'target_author_image', $tipTarget);
        echo $chip(__('Fixed', 'wp-seo-next'), 'fixed_author_image', $tipFixed);
        echo $chip(__('Already', 'wp-seo-next'), 'skipped_author_image_already_set', $tipAlready);
        echo $chip(__('Left', 'wp-seo-next'), 'left_author_image', $tipLeft);
        echo '</div>';

        // Author sameAs.
        echo '<div class="wpsn-sv-counter-row">';
        echo '<span class="wpsn-sv-counter-title">' . esc_html__('Author sameAs', 'wp-seo-next') . '</span>';
        echo $chip(__('Target', 'wp-seo-next'), 'target_author_sameas', $tipTarget);
        echo $chip(__('Fixed', 'wp-seo-next'), 'fixed_author_sameas', $tipFixed);
        echo $chip(__('Already', 'wp-seo-next'), 'skipped_author_sameas_already_set', $tipAlready);
        echo $chip(__('Left', 'wp-seo-next'), 'left_author_sameas', $tipLeft);
        echo '</div>';

        // Skip reasons row.
        $tipForbidden = esc_attr__('User could not be updated due to permissions (edit_user).', 'wp-seo-next');
        $tipNoAvatar = esc_attr__('User has no real avatar (we avoid setting a generic default avatar).', 'wp-seo-next');
        $tipNoLinks = esc_attr__('No valid social links found in the user profile.', 'wp-seo-next');
        echo '<div class="wpsn-sv-counter-row wpsn-sv-counter-row-sub">';
        echo '<span class="wpsn-sv-counter-title">' . esc_html__('Skip reasons', 'wp-seo-next') . '</span>';
        echo $chip(__('Forbidden', 'wp-seo-next'), 'skipped_user_forbidden', $tipForbidden);
        echo $chip(__('No avatar', 'wp-seo-next'), 'skipped_author_image_no_avatar', $tipNoAvatar);
        echo $chip(__('No links', 'wp-seo-next'), 'skipped_author_sameas_no_links', $tipNoLinks);
        echo '</div>';

        echo '</div>';

        echo '<div id="wpsn-schema-fix-safe-result" class="notice notice-info" style="display:none;"><p></p></div>';

        // Copy-to-clipboard for agency reports.
        echo '<div id="wpsn-schema-fix-safe-copy-wrap" style="display:none; margin: 8px 0 12px;">';
        echo '<button type="button" class="button" id="wpsn-schema-fix-safe-copy">' . esc_html__('Copy summary', 'wp-seo-next') . '</button>';
        echo '<span id="wpsn-schema-fix-safe-copy-ok" style="display:none; margin-left:8px; color:#2a7a2a; font-weight:600;">' . esc_html__('Copied!', 'wp-seo-next') . '</span>';
        echo '</div>';

        echo '</details>';

        // Issues list.
        echo '<hr />';
        echo '<h2>' . esc_html__('Issues', 'wp-seo-next') . '</h2>';

        if ($scanId <= 0) {
            echo '<p>' . esc_html__('Run a scan to see schema issues.', 'wp-seo-next') . '</p>';
            echo '</div>';
            return;
        }

        // Filters.
        echo '<form method="get" class="wpsn-sv-filters">';
        echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '">';
        echo '<input type="hidden" name="scan_id" value="' . esc_attr((string) $scanId) . '">';

        echo '<label>' . esc_html__('Severity', 'wp-seo-next') . ' ';
        echo '<select name="sev">';
        echo '<option value="0">' . esc_html__('All', 'wp-seo-next') . '</option>';
        echo '<option value="3"' . selected(3, $sev, false) . '>' . esc_html__('High', 'wp-seo-next') . '</option>';
        echo '<option value="2"' . selected(2, $sev, false) . '>' . esc_html__('Medium', 'wp-seo-next') . '</option>';
        echo '<option value="1"' . selected(1, $sev, false) . '>' . esc_html__('Low', 'wp-seo-next') . '</option>';
        echo '</select></label> ';

        echo '<label>' . esc_html__('Issue', 'wp-seo-next') . ' ';
        echo '<select name="code">';
        echo '<option value="">' . esc_html__('All', 'wp-seo-next') . '</option>';
        foreach ($codes as $c) {
            $cc = sanitize_key((string) ($c['issue_code'] ?? ''));
            if ($cc === '') {
                continue;
            }
            $label = $cc . ' (' . (int) ($c['count'] ?? 0) . ')';
            echo '<option value="' . esc_attr($cc) . '"' . selected($cc, $code, false) . '>' . esc_html($label) . '</option>';
        }
        echo '</select></label> ';

        echo '<label>' . esc_html__('Search', 'wp-seo-next') . ' <input type="search" name="s" value="' . esc_attr($s) . '"></label> ';

        echo '<button class="button">' . esc_html__('Filter', 'wp-seo-next') . '</button>';

        $exportArgs = [
            'action' => 'wpsn_schema_export_csv',
            'scan_id' => (int) $scanId,
            'sev' => (int) $sev,
            'code' => (string) $code,
            's' => (string) $s,
        ];
        $exportUrl = wp_nonce_url(add_query_arg($exportArgs, admin_url('admin-post.php')), 'wpsn_schema_export_csv');
        echo ' <a class="button" href="' . esc_url($exportUrl) . '">' . esc_html__('Export CSV', 'wp-seo-next') . '</a>';
        echo '</form>';

        if (empty($rows)) {
            echo '<p>' . esc_html__('No issues for the current filters.', 'wp-seo-next') . '</p>';
            echo '</div>';
            return;
        }

        // Pagination.
        $totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1;
        $totalPages = max(1, $totalPages);

        echo '<table class="widefat striped wpsn-sv-issues">';
        echo '<thead><tr>';
        echo '<th>' . esc_html__('Severity', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Object', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Issue', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Suggestion', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead><tbody>';

        foreach ($rows as $r) {
            $objUrl = isset($r['object_url']) ? (string) $r['object_url'] : '';
            $objTitle = isset($r['object_title']) ? (string) $r['object_title'] : '';
            $objType = isset($r['object_type']) ? (string) $r['object_type'] : '';
            $objId = isset($r['object_id']) ? (int) $r['object_id'] : 0;
            $issueCode = isset($r['issue_code']) ? (string) $r['issue_code'] : '';
            $severity = isset($r['severity']) ? (int) $r['severity'] : 1;
            $message = isset($r['message']) ? (string) $r['message'] : '';
            $suggestion = isset($r['suggestion']) ? (string) $r['suggestion'] : '';
            $details = isset($r['details']) ? (string) $r['details'] : '';

            $detailsJson = [];
            if ($details !== '') {
                $decoded = json_decode($details, true);
                if (is_array($decoded)) {
                    $detailsJson = $decoded;
                }
            }

            $missing = [];
            if (isset($detailsJson['missing']) && is_array($detailsJson['missing'])) {
                $missing = array_values(array_filter(array_map('strval', $detailsJson['missing'])));
            }

            $sevLabel = $severity === 3 ? __('High', 'wp-seo-next') : ($severity === 2 ? __('Medium', 'wp-seo-next') : __('Low', 'wp-seo-next'));
            $sevClass = $severity === 3 ? 'high' : ($severity === 2 ? 'medium' : 'low');

            $objLabel = $objTitle !== '' ? $objTitle : ($objUrl !== '' ? $objUrl : ($objType . ' #' . $objId));

            // External tools.
            $rrt = $objUrl !== '' ? ('https://search.google.com/test/rich-results?url=' . rawurlencode($objUrl)) : '';
            $sv = $objUrl !== '' ? ('https://validator.schema.org/#url=' . rawurlencode($objUrl)) : '';

            echo '<tr>';
            echo '<td><span class="wpsn-sv-sev ' . esc_attr($sevClass) . '">' . esc_html($sevLabel) . '</span></td>';
            echo '<td>';
            if ($objUrl !== '') {
                echo '<a href="' . esc_url($objUrl) . '" target="_blank" rel="noopener">' . esc_html($objLabel) . '</a>';
            } else {
                echo esc_html($objLabel);
            }
            echo '<div class="wpsn-sv-sub">' . esc_html($objType . ($objId ? (' #' . $objId) : '') . ($issueCode ? (' • ' . $issueCode) : '')) . '</div>';
            echo '</td>';
            echo '<td>' . esc_html($message) . '</td>';
            echo '<td>' . esc_html($suggestion) . '</td>';
            echo '<td>';
            if ($rrt !== '') {
                echo '<a class="button button-small" href="' . esc_url($rrt) . '" target="_blank" rel="noopener">' . esc_html__('Rich Results', 'wp-seo-next') . '</a> ';
            }
            if ($sv !== '') {
                echo '<a class="button button-small" href="' . esc_url($sv) . '" target="_blank" rel="noopener">' . esc_html__('Schema Validator', 'wp-seo-next') . '</a> ';
            }
            if ($gscEnabled && $gscProperty !== '' && $objUrl !== '') {
                // Direct-first: always provide a real href (admin-post redirect) so the button works
                // even when AJAX live mode is disabled / admin-ajax.php is rate-limited (503).
                $backUrl = admin_url('tools.php?page=' . self::PAGE_SLUG);
                $inspectUrl = add_query_arg([
                    'action' => 'wpsn_gsc_inspect_link',
                    'property' => $gscProperty,
                    'url' => $objUrl,
                    'back' => $backUrl,
                    'wpsn_nonce' => wp_create_nonce('wpsn_gsc_inspect_link'),
                ], admin_url('admin-post.php'));

                echo '<a class="button button-small wpsn-gsc-inspect" href="' . esc_url($inspectUrl) . '" data-property="' . esc_attr($gscProperty) . '" data-url="' . esc_attr($objUrl) . '">' . esc_html__('Inspect', 'wp-seo-next') . '</a> ';
            }

            // Quick fixes.
            $canFixLogo = Capabilities::canManageSettings() && in_array($issueCode, [
                SchemaValidationService::ISSUE_ORG_LOGO_MISSING,
                SchemaValidationService::ISSUE_ARTICLE_SKIPPED_LOGO,
            ], true);

            // For Article invalid, show only the relevant fixes.
            if (Capabilities::canManageSettings() && $issueCode === SchemaValidationService::ISSUE_ARTICLE_INVALID) {
                foreach ($missing as $m) {
                    if (strpos($m, 'publisher.logo') !== false) {
                        $canFixLogo = true;
                        break;
                    }
                }
            }

            if ($canFixLogo) {
                $settingsUrl = admin_url('admin.php?page=wp-seo-next#wpsn-settings-schema');
                echo '<a class="button button-small" href="' . esc_url($settingsUrl) . '">' . esc_html__('Open Schema settings', 'wp-seo-next') . '</a> ';
            }

            $canFixFeatured = ($objType === 'post' && $objId > 0 && current_user_can('edit_post', $objId))
                && in_array($issueCode, [
                    SchemaValidationService::ISSUE_ARTICLE_SKIPPED_IMAGE,
                ], true);

            if ($objType === 'post' && $objId > 0 && current_user_can('edit_post', $objId) && $issueCode === SchemaValidationService::ISSUE_ARTICLE_INVALID) {
                foreach ($missing as $m) {
                    if (strpos($m, 'image') !== false) {
                        $canFixFeatured = true;
                        break;
                    }
                }
            }

            if ($canFixFeatured) {
                $editPostUrl = get_edit_post_link($objId);
                if (is_string($editPostUrl) && $editPostUrl !== '') {
                    echo '<a class="button button-small" href="' . esc_url($editPostUrl) . '">' . esc_html__('Edit post', 'wp-seo-next') . '</a> ';
                }
            }

            $canEditUser = ($objType === 'user' && $objId > 0 && current_user_can('edit_user', $objId));
            $isPersonIssue = in_array($issueCode, [
                SchemaValidationService::ISSUE_PERSON_BIO_MISSING,
                SchemaValidationService::ISSUE_PERSON_SAMEAS_MISSING,
                SchemaValidationService::ISSUE_PERSON_IMAGE_MISSING,
            ], true);

            if ($canEditUser && $isPersonIssue) {
                $editUrl = get_edit_user_link($objId);
                if (is_string($editUrl) && $editUrl !== '') {
                    echo '<a class="button button-small" href="' . esc_url($editUrl) . '">' . esc_html__('Edit user', 'wp-seo-next') . '</a> ';
                }
            }

            // For author issues, prefer manual edit page (works even when admin-ajax is blocked).

            if ($details !== '') {
                echo '<button type="button" class="button button-small wpsn-sv-details-toggle" data-details="' . esc_attr($details) . '">' . esc_html__('Details', 'wp-seo-next') . '</button>';
            }

            echo '</td>';
            echo '</tr>';
        }

        echo '</tbody></table>';

        if ($totalPages > 1) {
            echo '<div class="tablenav"><div class="tablenav-pages">';
            echo paginate_links([
                'base' => esc_url_raw(add_query_arg(['paged' => '%#%'], $this->pageUrl(array_filter([
                    'scan_id' => $scanId,
                    'sev' => $sev ?: null,
                    'code' => $code ?: null,
                    's' => $s ?: null,
                ])))),
                'format' => '',
                'current' => $paged,
                'total' => $totalPages,
                'prev_text' => __('&laquo;', 'wp-seo-next'),
                'next_text' => __('&raquo;', 'wp-seo-next'),
            ]);
            echo '</div></div>';
        }

        echo '<div id="wpsn-schema-details-modal" class="wpsn-sv-modal" style="display:none;">';
        echo '<div class="wpsn-sv-modal-inner">';
        echo '<button type="button" class="button wpsn-sv-modal-close">' . esc_html__('Close', 'wp-seo-next') . '</button>';
        echo '<pre class="wpsn-sv-modal-pre"></pre>';
        echo '</div></div>';

        echo '</div>';
    }

    public function handleExportCsv(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_schema_export_csv');

        $scanId = isset($_GET['scan_id']) ? max(0, (int) $_GET['scan_id']) : 0;
        if ($scanId <= 0) {
            wp_die(esc_html__('Scan ID is required.', 'wp-seo-next'));
        }

        $sev = isset($_GET['sev']) ? (int) $_GET['sev'] : 0;
        $code = isset($_GET['code']) ? sanitize_key((string) wp_unslash($_GET['code'])) : '';
        $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';

        $filters = [
            'severity' => $sev,
            'issue_code' => $code,
            's' => $s,
        ];

        $filename = 'wpsn-schema-scan-' . $scanId . '-' . gmdate('Ymd-His') . '.csv';

        nocache_headers();
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);

        $out = fopen('php://output', 'w');
        if (!is_resource($out)) {
            exit;
        }

        fputcsv($out, [
            'scan_id',
            'severity',
            'severity_label',
            'issue_code',
            'message',
            'suggestion',
            'object_type',
            'object_subtype',
            'object_id',
            'object_title',
            'object_url',
            'detected_at',
            'details',
        ]);

        $page = 1;
        $perPage = 200;
        while (true) {
            $data = $this->issues->listByScan($scanId, $filters, $page, $perPage);
            $rows = isset($data['rows']) && is_array($data['rows']) ? $data['rows'] : [];
            if (empty($rows)) {
                break;
            }

            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $sevI = (int) ($row['severity'] ?? 1);
                $sevLabel = $sevI === 3 ? 'High' : ($sevI === 2 ? 'Medium' : 'Low');

                fputcsv($out, [
                    (int) $scanId,
                    $sevI,
                    $sevLabel,
                    (string) ($row['issue_code'] ?? ''),
                    (string) ($row['message'] ?? ''),
                    (string) ($row['suggestion'] ?? ''),
                    (string) ($row['object_type'] ?? ''),
                    (string) ($row['object_subtype'] ?? ''),
                    (int) ($row['object_id'] ?? 0),
                    (string) ($row['object_title'] ?? ''),
                    (string) ($row['object_url'] ?? ''),
                    (string) ($row['detected_at'] ?? ''),
                    (string) ($row['details'] ?? ''),
                ]);
            }

            $page++;
            if ($page > 20000) {
                break;
            }
        }

        fclose($out);
        exit;
    }


    public function ajaxScanStart(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $postTypes = isset($_POST['post_types']) ? (array) wp_unslash($_POST['post_types']) : [];
        $postTypes = array_values(array_filter(array_map('sanitize_key', $postTypes)));

        if (empty($postTypes)) {
            wp_send_json_error(['message' => 'no_post_types'], 400);
        }

        $includeTerms = isset($_POST['include_terms']) && (string) wp_unslash($_POST['include_terms']) === '1';
        $taxonomies = isset($_POST['taxonomies']) ? (array) wp_unslash($_POST['taxonomies']) : [];
        $taxonomies = array_values(array_filter(array_map('sanitize_key', $taxonomies)));

        // If includeTerms is off, ignore taxonomies.
        if (!$includeTerms) {
            $taxonomies = [];
        }

        $params = [
            'post_types' => $postTypes,
            'include_terms' => $includeTerms,
            'taxonomies' => $taxonomies,
        ];

        $scanId = $this->scans->create($params);
        if ($scanId <= 0) {
            wp_send_json_error(['message' => 'db_error'], 500);
        }

        $totalPosts = 0;
        foreach ($postTypes as $pt) {
            $counts = wp_count_posts($pt);
            if (is_object($counts) && isset($counts->publish)) {
                $totalPosts += (int) $counts->publish;
            }
        }

        $totalTerms = 0;
        if ($includeTerms && !empty($taxonomies)) {
            foreach ($taxonomies as $tax) {
                if (function_exists('wp_count_terms')) {
                    $c = wp_count_terms($tax, ['hide_empty' => false]);
                    if (!is_wp_error($c)) {
                        $totalTerms += (int) $c;
                    }
                } else {
                    $c = get_terms(['taxonomy' => $tax, 'hide_empty' => false, 'fields' => 'count']);
                    if (!is_wp_error($c) && is_numeric($c)) {
                        $totalTerms += (int) $c;
                    }
                }
            }
        }

        $session = wp_generate_password(16, false, false);

        $state = [
            'session' => $session,
            'scan_id' => $scanId,
            'stage' => 'posts',
            'post_types' => $postTypes,
            'pt_index' => 0,
            'page' => 1,
            'per_page' => 20,
            'include_terms' => $includeTerms,
            'taxonomies' => $taxonomies,
            'tax_index' => 0,
            'term_offset' => 0,
            'scanned_posts' => 0,
            'scanned_terms' => 0,
            'issues' => 0,
            'seen_user_issues' => [],
            'total_posts' => $totalPosts,
            'total_terms' => $totalTerms,
            'started' => time(),
        ];

        set_transient($this->stateKey(), $state, HOUR_IN_SECONDS);

        wp_send_json_success([
            'session' => $session,
            'scan_id' => $scanId,
            'total' => $totalPosts + $totalTerms,
            'total_posts' => $totalPosts,
            'total_terms' => $totalTerms,
        ]);
    }

    public function ajaxScanStep(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $session = isset($_POST['session']) ? sanitize_text_field((string) wp_unslash($_POST['session'])) : '';
        $chunk = isset($_POST['chunk']) ? (int) wp_unslash($_POST['chunk']) : 20;
        $chunk = max(1, min(100, $chunk));

        $state = get_transient($this->stateKey());
        if (!is_array($state) || empty($state['session']) || (string) $state['session'] !== $session) {
            wp_send_json_error(['message' => 'missing_state'], 400);
        }

        $scanId = isset($state['scan_id']) ? (int) $state['scan_id'] : 0;
        if ($scanId <= 0) {
            wp_send_json_error(['message' => 'bad_scan_id'], 400);
        }

        $processed = 0;
        $issuesAdded = 0;

        $seenUserIssues = [];
        if (isset($state['seen_user_issues']) && is_array($state['seen_user_issues'])) {
            $seenUserIssues = $state['seen_user_issues'];
        }

        while ($processed < $chunk) {
            $stage = isset($state['stage']) ? (string) $state['stage'] : 'done';

            if ($stage === 'posts') {
                $postTypes = isset($state['post_types']) && is_array($state['post_types']) ? $state['post_types'] : [];
                $ptIndex = isset($state['pt_index']) ? (int) $state['pt_index'] : 0;

                if ($ptIndex >= count($postTypes)) {
                    // move to terms
                    $state['stage'] = (isset($state['include_terms']) && $state['include_terms']) ? 'terms' : 'done';
                    continue;
                }

                $pt = (string) $postTypes[$ptIndex];
                $page = isset($state['page']) ? max(1, (int) $state['page']) : 1;
                $perPage = isset($state['per_page']) ? max(1, (int) $state['per_page']) : 20;

                $q = new \WP_Query([
                    'post_type' => $pt,
                    'post_status' => 'publish',
                    'fields' => 'ids',
                    'posts_per_page' => $perPage,
                    'paged' => $page,
                    'orderby' => 'ID',
                    'order' => 'ASC',
                    'no_found_rows' => true,
                    'update_post_meta_cache' => false,
                    'update_post_term_cache' => false,
                    'suppress_filters' => true,
                ]);

                $ids = is_array($q->posts) ? $q->posts : [];
                if (empty($ids)) {
                    // next post type
                    $state['pt_index'] = $ptIndex + 1;
                    $state['page'] = 1;
                    continue;
                }

                foreach ($ids as $id) {
                    if ($processed >= $chunk) {
                        break;
                    }
                    $id = (int) $id;
                    if ($id <= 0) {
                        continue;
                    }

                    $issues = $this->validator->validatePost($id, $pt);
                    foreach ($issues as $issue) {
                        if (is_array($issue)) {
                            $objType = isset($issue['object_type']) ? (string) $issue['object_type'] : '';
                            if ($objType === 'user') {
                                $uid = isset($issue['object_id']) ? (int) $issue['object_id'] : 0;
                                $code = isset($issue['issue_code']) ? (string) $issue['issue_code'] : '';
                                if ($uid > 0 && $code !== '') {
                                    $k = (string) $uid . '|' . $code;
                                    if (isset($seenUserIssues[$k])) {
                                        continue;
                                    }
                                    $seenUserIssues[$k] = 1;
                                }
                            }
                        }

                        $this->issues->insert($scanId, $issue);
                        $issuesAdded++;
                        $state['issues'] = (int) ($state['issues'] ?? 0) + 1;
                    }

                    $state['scanned_posts'] = (int) ($state['scanned_posts'] ?? 0) + 1;
                    $processed++;
                }

                // advance page
                $state['page'] = $page + 1;
                continue;
            }

            if ($stage === 'terms') {
                $includeTerms = isset($state['include_terms']) ? (bool) $state['include_terms'] : false;
                if (!$includeTerms) {
                    $state['stage'] = 'done';
                    continue;
                }

                $taxonomies = isset($state['taxonomies']) && is_array($state['taxonomies']) ? $state['taxonomies'] : [];
                $taxIndex = isset($state['tax_index']) ? (int) $state['tax_index'] : 0;

                if ($taxIndex >= count($taxonomies)) {
                    $state['stage'] = 'done';
                    continue;
                }

                $tax = (string) $taxonomies[$taxIndex];
                $offset = isset($state['term_offset']) ? max(0, (int) $state['term_offset']) : 0;
                $perPage = isset($state['per_page']) ? max(1, (int) $state['per_page']) : 20;

                $termIds = get_terms([
                    'taxonomy' => $tax,
                    'hide_empty' => false,
                    'fields' => 'ids',
                    'number' => $perPage,
                    'offset' => $offset,
                    'orderby' => 'term_id',
                    'order' => 'ASC',
                ]);

                if (is_wp_error($termIds) || empty($termIds) || !is_array($termIds)) {
                    // next taxonomy
                    $state['tax_index'] = $taxIndex + 1;
                    $state['term_offset'] = 0;
                    continue;
                }

                foreach ($termIds as $termId) {
                    if ($processed >= $chunk) {
                        break;
                    }
                    $termId = (int) $termId;
                    if ($termId <= 0) {
                        continue;
                    }

                    $issues = $this->validator->validateTerm($termId, $tax);
                    foreach ($issues as $issue) {
                        $this->issues->insert($scanId, $issue);
                        $issuesAdded++;
                        $state['issues'] = (int) ($state['issues'] ?? 0) + 1;
                    }

                    $state['scanned_terms'] = (int) ($state['scanned_terms'] ?? 0) + 1;
                    $processed++;
                    $offset++;
                }

                $state['term_offset'] = $offset;
                continue;
            }

            // done
            break;
        }

        $total = ((int) ($state['total_posts'] ?? 0)) + ((int) ($state['total_terms'] ?? 0));
        $scanned = ((int) ($state['scanned_posts'] ?? 0)) + ((int) ($state['scanned_terms'] ?? 0));

        // Update DB stats for visibility.
        $this->scans->updateStats($scanId, [
            'scanned_posts' => (int) ($state['scanned_posts'] ?? 0),
            'scanned_terms' => (int) ($state['scanned_terms'] ?? 0),
            'issues' => (int) ($state['issues'] ?? 0),
            'total' => $total,
        ]);

        $done = isset($state['stage']) && (string) $state['stage'] === 'done';
        if ($done || ($total > 0 && $scanned >= $total)) {
            $done = true;
            $this->scans->finish($scanId, [
                'scanned_posts' => (int) ($state['scanned_posts'] ?? 0),
                'scanned_terms' => (int) ($state['scanned_terms'] ?? 0),
                'issues' => (int) ($state['issues'] ?? 0),
                'total' => $total,
            ], 'done');

            delete_transient($this->stateKey());


            // Fire hook for Automation SDK webhooks (and other integrations).
            $sevCounts = $this->issues->countsBySeverity($scanId);
            $codes = $this->issues->countByCode($scanId);
            $topCodes = [];
            if (!empty($codes)) {
                $topCodes = array_slice($codes, 0, 10);
            }

            $scanRow = $this->scans->get($scanId);
            $startedAt = $scanRow && !empty($scanRow['started_at']) ? strtotime((string) $scanRow['started_at']) : (int) ($state['started'] ?? time());
            $finishedAt = $scanRow && !empty($scanRow['finished_at']) ? strtotime((string) $scanRow['finished_at']) : time();
            $duration = 0;
            if ($startedAt && $finishedAt && $finishedAt >= $startedAt) {
                $duration = (int) ($finishedAt - $startedAt);
            }

            $summary = [
                'scan_id' => (int) $scanId,
                'status' => 'done',
                'scanned_posts' => (int) ($state['scanned_posts'] ?? 0),
                'scanned_terms' => (int) ($state['scanned_terms'] ?? 0),
                'total_posts' => (int) ($state['total_posts'] ?? 0),
                'total_terms' => (int) ($state['total_terms'] ?? 0),
                'issues_total' => (int) ($sevCounts['total'] ?? ($state['issues'] ?? 0)),
                'issues_by_severity' => $sevCounts,
                'top_issue_codes' => $topCodes,
                'params' => [
                    'post_types' => isset($state['post_types']) && is_array($state['post_types']) ? array_values($state['post_types']) : [],
                    'include_terms' => !empty($state['include_terms']),
                    'taxonomies' => isset($state['taxonomies']) && is_array($state['taxonomies']) ? array_values($state['taxonomies']) : [],
                ],
                'started_at' => $startedAt ? gmdate('c', (int) $startedAt) : '',
                'finished_at' => $finishedAt ? gmdate('c', (int) $finishedAt) : gmdate('c'),
                'duration_sec' => $duration,
            ];

            do_action('wpsn/schema/scan_completed', $scanId, $summary);
        } else {
            set_transient($this->stateKey(), $state, HOUR_IN_SECONDS);
        }

        wp_send_json_success([
            'scan_id' => $scanId,
            'processed' => $processed,
            'issues_added' => $issuesAdded,
            'scanned' => $scanned,
            'total' => $total,
            'stage' => (string) ($state['stage'] ?? ''),
            'done' => $done,
        ]);
    }

    public function ajaxScanCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $state = get_transient($this->stateKey());
        if (is_array($state) && isset($state['scan_id'])) {
            $scanId = (int) $state['scan_id'];
            if ($scanId > 0) {
                $this->scans->finish($scanId, [
                    'scanned_posts' => (int) ($state['scanned_posts'] ?? 0),
                    'scanned_terms' => (int) ($state['scanned_terms'] ?? 0),
                    'issues' => (int) ($state['issues'] ?? 0),
                ], 'cancelled');
            }
        }

        delete_transient($this->stateKey());

        // Fire hook for integrations.
        if (isset($scanId) && $scanId > 0) {
            $sevCounts = $this->issues->countsBySeverity($scanId);
            do_action('wpsn/schema/scan_cancelled', $scanId, [
                'scan_id' => (int) $scanId,
                'status' => 'cancelled',
                'issues_by_severity' => $sevCounts,
            ]);
        }

        wp_send_json_success(['cancelled' => true]);
    }

    public function ajaxClearAll(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $this->issues->clearAll();
        $this->scans->clearAll();

        wp_send_json_success(['cleared' => true]);
    }

    public function ajaxQuickFix(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $fix = isset($_POST['fix']) ? sanitize_key((string) wp_unslash($_POST['fix'])) : '';
        if ($fix === '') {
            wp_send_json_error(['message' => 'missing_fix'], 400);
        }

        if ($fix === 'set_org_logo') {
            if (!Capabilities::canManageSettings()) {
                wp_send_json_error(['message' => 'forbidden_settings'], 403);
            }

            $attachmentId = isset($_POST['attachment_id']) ? (int) wp_unslash($_POST['attachment_id']) : 0;
            $url = isset($_POST['url']) ? trim((string) wp_unslash($_POST['url'])) : '';

            if ($attachmentId > 0) {
                $img = wp_get_attachment_image_url($attachmentId, 'full');
                if (is_string($img) && $img !== '') {
                    $url = $img;
                }
            }

            $url = $url !== '' ? esc_url_raw($url) : '';
            if ($url === '') {
                wp_send_json_error(['message' => 'missing_url'], 400);
            }

            /** @var \WPSN\Admin\SettingsRepository $repo */
            $repo = $this->container->get('settings_repo');
            $beforeOpt = get_option(SettingsRepository::OPTION_NAME, []);
            $settings = $repo->getAll();

            if (!isset($settings['schema']) || !is_array($settings['schema'])) {
                $settings['schema'] = [];
            }
            $settings['schema']['org_logo_url'] = $url;

            $repo->update($settings);
            $this->settings->flush();

            // Unified revisions log.
            $revRepo = $this->revisionsRepo();
            if ($revRepo instanceof RevisionsRepository) {
                $afterOpt = get_option(SettingsRepository::OPTION_NAME, []);
                if ($beforeOpt !== $afterOpt) {
                    $revRepo->insert([
                        'object_type' => 'option',
                        'object_id' => 0,
                        'object_subtype' => SettingsRepository::OPTION_NAME,
                        'action' => 'quick_fix_org_logo',
                        'source' => 'schema',
                        'actor_user_id' => (int) get_current_user_id(),
                        'before' => [
                            'option' => [
                                'name' => SettingsRepository::OPTION_NAME,
                                'value' => $beforeOpt,
                            ],
                        ],
                        'after' => [
                            'option' => [
                                'name' => SettingsRepository::OPTION_NAME,
                                'value' => $afterOpt,
                            ],
                        ],
                        'meta' => [
                            'fix' => 'set_org_logo',
                        ],
                    ]);
                }
            }

            wp_send_json_success([
                'fix' => $fix,
                'org_logo_url' => $url,
            ]);
        }

        if ($fix === 'set_featured_image') {
            $postId = isset($_POST['post_id']) ? (int) wp_unslash($_POST['post_id']) : 0;
            $attachmentId = isset($_POST['attachment_id']) ? (int) wp_unslash($_POST['attachment_id']) : 0;

            if ($postId <= 0 || !get_post($postId)) {
                wp_send_json_error(['message' => 'invalid_post'], 400);
            }
            if ($attachmentId <= 0 || get_post_type($attachmentId) !== 'attachment') {
                wp_send_json_error(['message' => 'invalid_attachment'], 400);
            }

            $mime = (string) get_post_mime_type($attachmentId);
            if ($mime === '' || strpos($mime, 'image/') !== 0) {
                wp_send_json_error(['message' => 'attachment_not_image'], 400);
            }
            if (!current_user_can('edit_post', $postId)) {
                wp_send_json_error(['message' => 'forbidden_post'], 403);
            }

            if (!function_exists('set_post_thumbnail')) {
                wp_send_json_error(['message' => 'no_thumbnail_support'], 500);
            }

            $beforeThumb = (int) get_post_thumbnail_id($postId);

            $ok = set_post_thumbnail($postId, $attachmentId);
            if (!$ok) {
                wp_send_json_error(['message' => 'failed'], 500);
            }

            // Unified revisions log.
            $revRepo = $this->revisionsRepo();
            if ($revRepo instanceof RevisionsRepository) {
                $afterThumb = (int) get_post_thumbnail_id($postId);
                if ($beforeThumb !== $afterThumb) {
                    $p = get_post($postId);
                    $revRepo->insert([
                        'object_type' => 'post',
                        'object_id' => $postId,
                        'object_subtype' => $p ? (string) $p->post_type : '',
                        'action' => 'quick_fix_featured_image',
                        'source' => 'schema',
                        'actor_user_id' => (int) get_current_user_id(),
                        'before' => [
                            'meta' => [
                                '_thumbnail_id' => $beforeThumb > 0 ? (string) $beforeThumb : '',
                            ],
                        ],
                        'after' => [
                            'meta' => [
                                '_thumbnail_id' => $afterThumb > 0 ? (string) $afterThumb : '',
                            ],
                        ],
                        'meta' => [
                            'fix' => 'set_featured_image',
                            'attachment_id' => $attachmentId,
                        ],
                    ]);
                }
            }

            wp_send_json_success([
                'fix' => $fix,
                'post_id' => $postId,
                'attachment_id' => $attachmentId,
            ]);
        }


        if ($fix === 'set_author_image') {
            $userId = isset($_POST['user_id']) ? (int) wp_unslash($_POST['user_id']) : 0;
            if ($userId <= 0 || !get_user_by('id', $userId)) {
                wp_send_json_error(['message' => 'invalid_user'], 400);
            }
            if (!current_user_can('edit_user', $userId)) {
                wp_send_json_error(['message' => 'forbidden_user'], 403);
            }

            $attachmentId = isset($_POST['attachment_id']) ? (int) wp_unslash($_POST['attachment_id']) : 0;
            $url = isset($_POST['url']) ? trim((string) wp_unslash($_POST['url'])) : '';

            if ($attachmentId > 0) {
                $img = wp_get_attachment_image_url($attachmentId, 'full');
                if (is_string($img) && $img !== '') {
                    $url = $img;
                }
            }

            $url = $url !== '' ? esc_url_raw($url) : '';
            if ($url === '') {
                wp_send_json_error(['message' => 'missing_url'], 400);
            }

            $beforeMeta = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_IMAGE_URL, true);
            update_user_meta($userId, UserMetaKeys::AUTHOR_IMAGE_URL, $url);

            // Unified revisions log.
            $revRepo = $this->revisionsRepo();
            if ($revRepo instanceof RevisionsRepository && $beforeMeta !== $url) {
                $revRepo->insert([
                    'object_type' => 'user',
                    'object_id' => $userId,
                    'object_subtype' => 'meta',
                    'action' => 'quick_fix_author_image',
                    'source' => 'schema',
                    'actor_user_id' => (int) get_current_user_id(),
                    'before' => [
                        'user_meta' => [
                            UserMetaKeys::AUTHOR_IMAGE_URL => $beforeMeta,
                        ],
                    ],
                    'after' => [
                        'user_meta' => [
                            UserMetaKeys::AUTHOR_IMAGE_URL => $url,
                        ],
                    ],
                    'meta' => [
                        'fix' => 'set_author_image',
                    ],
                ]);
            }

            wp_send_json_success([
                'fix' => $fix,
                'user_id' => $userId,
                'author_image_url' => $url,
            ]);
        }

        wp_send_json_error(['message' => 'unknown_fix'], 400);
    }


    /**
     * Start batched safe fixes session.
     */
    public function ajaxFixSafeStart(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $requestedScanId = isset($_POST['scan_id']) ? (int) wp_unslash($_POST['scan_id']) : 0;
        $allowOrgWithoutScan = isset($_POST['allow_org_without_scan']) ? ((int) wp_unslash($_POST['allow_org_without_scan']) === 1) : false;

        $scanId = $requestedScanId;
        if ($scanId <= 0 && !$allowOrgWithoutScan) {
            $latest = $this->scans->latest();
            $scanId = ($latest && isset($latest['id'])) ? (int) $latest['id'] : 0;
        }

        // If there is still no scan, require explicit opt-in for org-only fixes.
        if ($scanId <= 0 && !$allowOrgWithoutScan) {
            wp_send_json_error(['message' => 'no_scan'], 400);
        }

        $dryRun = !empty($_POST['dry_run']);

        // Which fixes are enabled.
        // Default to enabled for back-compat (older UI had no options).
        $fixOrgLogo = isset($_POST['fix_org_logo']) ? ((int) wp_unslash($_POST['fix_org_logo']) === 1) : true;
        $fixOrgName = isset($_POST['fix_org_name']) ? ((int) wp_unslash($_POST['fix_org_name']) === 1) : true;
        $fixAuthorImage = isset($_POST['fix_author_image']) ? ((int) wp_unslash($_POST['fix_author_image']) === 1) : true;
        $fixAuthorSameAs = isset($_POST['fix_author_sameas']) ? ((int) wp_unslash($_POST['fix_author_sameas']) === 1) : true;

        // If we explicitly run without a scan: only org fixes are applicable.
        if ($scanId <= 0 && $allowOrgWithoutScan) {
            $fixAuthorImage = false;
            $fixAuthorSameAs = false;
        }

        $options = [
            'org_logo' => $fixOrgLogo ? 1 : 0,
            'org_name' => $fixOrgName ? 1 : 0,
            'author_image' => $fixAuthorImage ? 1 : 0,
            'author_sameas' => $fixAuthorSameAs ? 1 : 0,
            'allow_org_without_scan' => $allowOrgWithoutScan ? 1 : 0,
        ];

        $chunk = isset($_POST['chunk']) ? (int) wp_unslash($_POST['chunk']) : $this->fixChunkSize;
        $chunk = max(10, min(200, $chunk));

        $session = wp_generate_password(16, false, false);

        $result = [
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'options' => $options,
            'fixed' => [],
            'skipped' => [],
            'errors' => [],
            'counts' => [
                'total_users' => 0,
                'processed_users' => 0,
                'target_author_image' => 0,
                'target_author_sameas' => 0,
                'target_org_logo' => 0,
                'target_org_name' => 0,
                'fixed_org_logo' => 0,
                'fixed_org_name' => 0,
                'fixed_author_image' => 0,
                'fixed_author_sameas' => 0,
                'skipped_users' => 0,
                'skipped_sameas_no_links' => 0,

                // Detailed skip counters (why targets were not fixed).
                'skipped_user_forbidden' => 0,
                'skipped_user_not_found' => 0,
                'skipped_author_image_no_avatar' => 0,
                'skipped_author_image_already_set' => 0,
                'skipped_author_sameas_no_links' => 0,
                'skipped_author_sameas_already_set' => 0,

                // Org already-set counters (mainly for reporting/tooltips).
                'skipped_org_logo_already_set' => 0,
                'skipped_org_name_already_set' => 0,
            ],
        ];

        // Apply small, constant-cost org-level fixes immediately.
        $this->applySafeOrgFixes($result, $dryRun, $options);

        // Gather user IDs from scan issues.
        $imageIds = [];
        $sameAsIds = [];
        if ($scanId > 0) {
            try {
                if ($fixAuthorImage) {
                    $imageIds = $this->issues->distinctObjectIds($scanId, 'user', [SchemaValidationService::ISSUE_PERSON_IMAGE_MISSING]);
                }
                if ($fixAuthorSameAs) {
                    $sameAsIds = $this->issues->distinctObjectIds($scanId, 'user', [SchemaValidationService::ISSUE_PERSON_SAMEAS_MISSING]);
                }
            } catch (\Throwable $e) {
                $result['errors'][] = [
                    'type' => 'users',
                    'error' => $e->getMessage(),
                ];
            }
        }

        $allUserIds = [];
        if (is_array($imageIds) && $imageIds) {
            $allUserIds = array_merge($allUserIds, $imageIds);
        }
        if (is_array($sameAsIds) && $sameAsIds) {
            $allUserIds = array_merge($allUserIds, $sameAsIds);
        }
        $allUserIds = array_values(array_unique(array_map('intval', $allUserIds)));
        sort($allUserIds);

        $needImage = $imageIds ? array_fill_keys(array_map('intval', $imageIds), 1) : [];
        $needSameAs = $sameAsIds ? array_fill_keys(array_map('intval', $sameAsIds), 1) : [];

        $result['counts']['target_author_image'] = is_array($imageIds) ? count($imageIds) : 0;
        $result['counts']['target_author_sameas'] = is_array($sameAsIds) ? count($sameAsIds) : 0;

        $result['counts']['total_users'] = count($allUserIds);

        // If there is nothing to do, return immediately.
        if (empty($allUserIds)) {
            $this->finalizeSafeFixResult($result, $scanId, $dryRun);
            $result['done'] = true;
            $result['session'] = $session;

            if (!$dryRun) {
                do_action('wpsn/schema/fix_completed', $scanId, $result);
            }

            wp_send_json_success($result);
        }

        $state = [
            'session' => $session,
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'chunk' => $chunk,
            'cursor' => 0,
            'user_ids' => $allUserIds,
            'need_image' => $needImage,
            'need_sameas' => $needSameAs,
            'result' => $result,
            'started' => time(),
        ];

        set_transient($this->fixStateKey(), $state, HOUR_IN_SECONDS);

        wp_send_json_success([
            'session' => $session,
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'processed_users' => 0,
            'total_users' => count($allUserIds),
            'fixed' => $result['fixed'],
            'skipped' => $result['skipped'],
            'errors' => $result['errors'],
            'counts' => $result['counts'],
        ]);
    }


    /**
     * Process next batch of safe fixes.
     */
    public function ajaxFixSafeStep(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $session = isset($_POST['session']) ? sanitize_text_field((string) wp_unslash($_POST['session'])) : '';
        $chunk = isset($_POST['chunk']) ? (int) wp_unslash($_POST['chunk']) : 0;

        $state = get_transient($this->fixStateKey());
        if (!is_array($state) || empty($state['session']) || (string) $state['session'] !== $session) {
            wp_send_json_error(['message' => 'missing_state'], 400);
        }

        $chunkState = isset($state['chunk']) ? (int) $state['chunk'] : $this->fixChunkSize;
        $chunk = $chunk > 0 ? max(10, min(200, $chunk)) : $chunkState;

        $scanId = isset($state['scan_id']) ? (int) $state['scan_id'] : 0;
        $dryRun = !empty($state['dry_run']);

        $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
        $userIds = isset($state['user_ids']) && is_array($state['user_ids']) ? $state['user_ids'] : [];
        $needImage = isset($state['need_image']) && is_array($state['need_image']) ? $state['need_image'] : [];
        $needSameAs = isset($state['need_sameas']) && is_array($state['need_sameas']) ? $state['need_sameas'] : [];

        $result = isset($state['result']) && is_array($state['result']) ? $state['result'] : [];
        if (!isset($result['counts']) || !is_array($result['counts'])) {
            $result['counts'] = [];
        }

        $processedNow = 0;
        $totalUsers = count($userIds);

        while ($processedNow < $chunk && $cursor < $totalUsers) {
            $userId = (int) $userIds[$cursor];
            $cursor++;
            $processedNow++;

            $did = $this->applySafeUserFixes($result, $dryRun, $userId, isset($needImage[$userId]), isset($needSameAs[$userId]));
            if (!$did) {
                // no-op
            }
        }

        $result['counts']['processed_users'] = $cursor;
        $done = $cursor >= $totalUsers;

        $state['cursor'] = $cursor;
        $state['result'] = $result;

        if ($done) {
            delete_transient($this->fixStateKey());

            $this->finalizeSafeFixResult($result, $scanId, $dryRun);

            if (!$dryRun) {
                do_action('wpsn/schema/fix_completed', $scanId, $result);
            }

            wp_send_json_success([
                'done' => true,
                'session' => $session,
                'scan_id' => $scanId,
                'dry_run' => $dryRun ? 1 : 0,
                'processed_users' => $cursor,
                'total_users' => $totalUsers,
                'result' => $result,
                'counts' => $result['counts'],
            ]);
        }

        set_transient($this->fixStateKey(), $state, HOUR_IN_SECONDS);

        wp_send_json_success([
            'done' => false,
            'session' => $session,
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'processed_users' => $cursor,
            'total_users' => $totalUsers,
            'counts' => $result['counts'],
        ]);
    }


    public function ajaxFixSafeCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        delete_transient($this->fixStateKey());
        wp_send_json_success(['cancelled' => true]);
    }


    public function ajaxFixSafe(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => 'bad_nonce'], 400);
        }

        $requestedScanId = isset($_POST['scan_id']) ? (int) wp_unslash($_POST['scan_id']) : 0;
        $allowOrgWithoutScan = isset($_POST['allow_org_without_scan']) ? ((int) wp_unslash($_POST['allow_org_without_scan']) === 1) : false;

        $scanId = $requestedScanId;
        if ($scanId <= 0 && !$allowOrgWithoutScan) {
            $latest = $this->scans->latest();
            $scanId = ($latest && isset($latest['id'])) ? (int) $latest['id'] : 0;
        }

        if ($scanId <= 0 && !$allowOrgWithoutScan) {
            wp_send_json_error(['message' => 'no_scan'], 400);
        }

        $dryRun = !empty($_POST['dry_run']);

        // Which fixes are enabled.
        $fixOrgLogo = isset($_POST['fix_org_logo']) ? ((int) wp_unslash($_POST['fix_org_logo']) === 1) : true;
        $fixOrgName = isset($_POST['fix_org_name']) ? ((int) wp_unslash($_POST['fix_org_name']) === 1) : true;
        $fixAuthorImage = isset($_POST['fix_author_image']) ? ((int) wp_unslash($_POST['fix_author_image']) === 1) : true;
        $fixAuthorSameAs = isset($_POST['fix_author_sameas']) ? ((int) wp_unslash($_POST['fix_author_sameas']) === 1) : true;

        if ($scanId <= 0 && $allowOrgWithoutScan) {
            $fixAuthorImage = false;
            $fixAuthorSameAs = false;
        }

        $options = [
            'org_logo' => $fixOrgLogo ? 1 : 0,
            'org_name' => $fixOrgName ? 1 : 0,
            'author_image' => $fixAuthorImage ? 1 : 0,
            'author_sameas' => $fixAuthorSameAs ? 1 : 0,
            'allow_org_without_scan' => $allowOrgWithoutScan ? 1 : 0,
        ];

        $result = [
            'scan_id' => $scanId,
            'dry_run' => $dryRun ? 1 : 0,
            'options' => $options,
            'fixed' => [],
            'skipped' => [],
            'errors' => [],
            'counts' => [
                'total_users' => 0,
                'processed_users' => 0,
                'target_author_image' => 0,
                'target_author_sameas' => 0,
                'target_org_logo' => 0,
                'target_org_name' => 0,
                'fixed_org_logo' => 0,
                'fixed_org_name' => 0,
                'fixed_author_image' => 0,
                'fixed_author_sameas' => 0,
                'skipped_users' => 0,
                'skipped_sameas_no_links' => 0,

                // Detailed skip counters.
                'skipped_user_forbidden' => 0,
                'skipped_user_not_found' => 0,
                'skipped_author_image_no_avatar' => 0,
                'skipped_author_image_already_set' => 0,
                'skipped_author_sameas_no_links' => 0,
                'skipped_author_sameas_already_set' => 0,

                // Org already-set counters (mainly for reporting/tooltips).
                'skipped_org_logo_already_set' => 0,
                'skipped_org_name_already_set' => 0,
            ],
        ];

        $this->applySafeOrgFixes($result, $dryRun, $options);

        $imageIds = [];
        $sameAsIds = [];
        if ($scanId > 0) {
            try {
                if ($fixAuthorImage) {
                    $imageIds = $this->issues->distinctObjectIds($scanId, 'user', [SchemaValidationService::ISSUE_PERSON_IMAGE_MISSING]);
                }
                if ($fixAuthorSameAs) {
                    $sameAsIds = $this->issues->distinctObjectIds($scanId, 'user', [SchemaValidationService::ISSUE_PERSON_SAMEAS_MISSING]);
                }
            } catch (\Throwable $e) {
                $result['errors'][] = [
                    'type' => 'users',
                    'error' => $e->getMessage(),
                ];
            }
        }

        $allUserIds = [];
        if ($imageIds) {
            $allUserIds = array_merge($allUserIds, $imageIds);
        }
        if ($sameAsIds) {
            $allUserIds = array_merge($allUserIds, $sameAsIds);
        }

        $allUserIds = array_values(array_unique(array_map('intval', $allUserIds)));
        sort($allUserIds);

        $needImage = $imageIds ? array_fill_keys(array_map('intval', $imageIds), 1) : [];
        $needSameAs = $sameAsIds ? array_fill_keys(array_map('intval', $sameAsIds), 1) : [];

        $result['counts']['target_author_image'] = is_array($imageIds) ? count($imageIds) : 0;
        $result['counts']['target_author_sameas'] = is_array($sameAsIds) ? count($sameAsIds) : 0;

        $result['counts']['total_users'] = count($allUserIds);

        foreach ($allUserIds as $uid) {
            $uid = (int) $uid;
            $this->applySafeUserFixes($result, $dryRun, $uid, isset($needImage[$uid]), isset($needSameAs[$uid]));
        }

        $result['counts']['processed_users'] = $result['counts']['total_users'];

        $this->finalizeSafeFixResult($result, $scanId, $dryRun);

        // Notify / log (only on real apply).
        if (!$dryRun) {
            /**
             * Fired when schema "safe fixes" completed.
             *
             * @since 0.50.0
             *
             * @param int $scanId
             * @param array<string,mixed> $result
             */
            do_action('wpsn/schema/fix_completed', $scanId, $result);
        }

        wp_send_json_success($result);
    }


    /**
     * @param array<string,mixed> $result
     */
    private function applySafeOrgFixes(array &$result, bool $dryRun, array $options = []): void
    {
        if (!isset($result['fixed']) || !is_array($result['fixed'])) {
            $result['fixed'] = [];
        }
        if (!isset($result['skipped']) || !is_array($result['skipped'])) {
            $result['skipped'] = [];
        }
        if (!isset($result['errors']) || !is_array($result['errors'])) {
            $result['errors'] = [];
        }

        if (!isset($result['counts']) || !is_array($result['counts'])) {
            $result['counts'] = [];
        }

        $doLogo = !empty($options['org_logo']);
        $doName = !empty($options['org_name']);

        // Pre-fill stable counters.
        $result['counts']['target_org_logo'] = (int) ($result['counts']['target_org_logo'] ?? 0);
        $result['counts']['target_org_name'] = (int) ($result['counts']['target_org_name'] ?? 0);
        $result['counts']['fixed_org_logo'] = (int) ($result['counts']['fixed_org_logo'] ?? 0);
        $result['counts']['fixed_org_name'] = (int) ($result['counts']['fixed_org_name'] ?? 0);

        if (!$doLogo) {
            $result['skipped'][] = ['type' => 'org_logo', 'reason' => 'disabled'];
        }
        if (!$doName) {
            $result['skipped'][] = ['type' => 'org_name', 'reason' => 'disabled'];
        }

        // If both org fixes are disabled - nothing to do.
        if (!$doLogo && !$doName) {
            return;
        }

        // Both org_logo and org_name live in settings.
        if (!Capabilities::canManageSettings()) {
            if ($doLogo) {
                $result['skipped'][] = ['type' => 'org_logo', 'reason' => 'forbidden_settings'];
            }
            if ($doName) {
                $result['skipped'][] = ['type' => 'org_name', 'reason' => 'forbidden_settings'];
            }
            return;
        }

        try {
            /** @var \WPSN\Admin\SettingsRepository $repo */
            $repo = $this->container->get('settings_repo');
            $beforeOpt = get_option(SettingsRepository::OPTION_NAME, []);
            $settings = $repo->getAll();
            if (!isset($settings['schema']) || !is_array($settings['schema'])) {
                $settings['schema'] = [];
            }

            $dirty = false;

            // org_logo_url
            if ($doLogo) {
                $currentLogo = isset($settings['schema']['org_logo_url']) ? trim((string) $settings['schema']['org_logo_url']) : '';
                if ($currentLogo !== '') {
                    $result['skipped'][] = ['type' => 'org_logo', 'reason' => 'already_set'];
                    $result['counts']['skipped_org_logo_already_set'] = (int) ($result['counts']['skipped_org_logo_already_set'] ?? 0) + 1;
                } else {
                    $result['counts']['target_org_logo'] = 1;
                    $guessed = $this->guessOrgLogoUrl();
                    if ($guessed === '') {
                        $result['skipped'][] = ['type' => 'org_logo', 'reason' => 'no_theme_logo_or_site_icon'];
                    } else {
                        if (!$dryRun) {
                            $settings['schema']['org_logo_url'] = $guessed;
                            $dirty = true;
                        }
                        $result['counts']['fixed_org_logo'] = 1;
                        $result['fixed'][] = ['type' => 'org_logo', 'value' => $guessed, 'source' => 'theme_or_site_icon'];
                    }
                }
            }

            // org_name
            if ($doName) {
                $currentName = isset($settings['schema']['org_name']) ? trim((string) $settings['schema']['org_name']) : '';
                if ($currentName !== '') {
                    $result['skipped'][] = ['type' => 'org_name', 'reason' => 'already_set'];
                    $result['counts']['skipped_org_name_already_set'] = (int) ($result['counts']['skipped_org_name_already_set'] ?? 0) + 1;
                } else {
                    $result['counts']['target_org_name'] = 1;
                    $blogName = get_bloginfo('name');
                    $blogName = is_string($blogName) ? sanitize_text_field($blogName) : '';
                    $blogName = trim($blogName);

                    if ($blogName === '') {
                        $result['skipped'][] = ['type' => 'org_name', 'reason' => 'empty_blogname'];
                    } else {
                        if (!$dryRun) {
                            $settings['schema']['org_name'] = $blogName;
                            $dirty = true;
                        }
                        $result['counts']['fixed_org_name'] = 1;
                        $result['fixed'][] = ['type' => 'org_name', 'value' => $blogName, 'source' => 'blogname'];
                    }
                }
            }

            if ($dirty && !$dryRun) {
                $repo->update($settings);
                $this->settings->flush();

                // Unified revisions log.
                $revRepo = $this->revisionsRepo();
                if ($revRepo instanceof RevisionsRepository) {
                    $afterOpt = get_option(SettingsRepository::OPTION_NAME, []);
                    if ($beforeOpt !== $afterOpt) {
                        $revRepo->insert([
                            'object_type' => 'option',
                            'object_id' => 0,
                            'object_subtype' => SettingsRepository::OPTION_NAME,
                            'action' => 'safe_fix_org',
                            'source' => 'schema',
                            'actor_user_id' => (int) get_current_user_id(),
                            'before' => [
                                'option' => [
                                    'name' => SettingsRepository::OPTION_NAME,
                                    'value' => $beforeOpt,
                                ],
                            ],
                            'after' => [
                                'option' => [
                                    'name' => SettingsRepository::OPTION_NAME,
                                    'value' => $afterOpt,
                                ],
                            ],
                            'meta' => [
                                'fix' => 'safe_org',
                                'org_logo' => $doLogo ? 1 : 0,
                                'org_name' => $doName ? 1 : 0,
                            ],
                        ]);
                    }
                }
            }
        } catch (\Throwable $e) {
            $result['errors'][] = ['type' => 'org_settings', 'error' => $e->getMessage()];
        }
    }


    /**
     * Apply safe per-user fixes (author image and/or sameAs).
     *
     * @param array<string,mixed> $result
     */
    private function applySafeUserFixes(array &$result, bool $dryRun, int $userId, bool $needImage, bool $needSameAs): bool
    {
        if ($userId <= 0) {
            return false;
        }

        if (!isset($result['counts']) || !is_array($result['counts'])) {
            $result['counts'] = [];
        }

        if (!current_user_can('edit_user', $userId)) {
            $result['counts']['skipped_users'] = (int) ($result['counts']['skipped_users'] ?? 0) + 1;
            $result['counts']['skipped_user_forbidden'] = (int) ($result['counts']['skipped_user_forbidden'] ?? 0) + 1;
            return false;
        }

        $user = get_user_by('id', $userId);
        if (!$user instanceof \WP_User) {
            $result['counts']['skipped_users'] = (int) ($result['counts']['skipped_users'] ?? 0) + 1;
            $result['counts']['skipped_user_not_found'] = (int) ($result['counts']['skipped_user_not_found'] ?? 0) + 1;
            return false;
        }

        $didSomething = false;

        $beforeMeta = [];
        $afterMeta = [];

        if ($needImage) {
            $existing = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_IMAGE_URL, true);
            if (trim($existing) === '') {
                // Avoid setting a generic/default avatar as author image.
                $avatarUrl = '';
                $found = true;
                if (function_exists('get_avatar_data')) {
                    $data = get_avatar_data($userId, ['size' => 512]);
                    if (is_array($data)) {
                        $avatarUrl = isset($data['url']) ? (string) $data['url'] : '';
                        $found = !empty($data['found_avatar']);
                    }
                }
                if ($avatarUrl === '') {
                    $avatarUrl = get_avatar_url($userId, ['size' => 512]);
                }
                $avatarUrl = is_string($avatarUrl) ? trim($avatarUrl) : '';

                if (!$found || $avatarUrl === '') {
                    $result['counts']['skipped_users'] = (int) ($result['counts']['skipped_users'] ?? 0) + 1;
                    $result['counts']['skipped_author_image_no_avatar'] = (int) ($result['counts']['skipped_author_image_no_avatar'] ?? 0) + 1;
                } else {
                    if (!$dryRun) {
                        $newUrl = esc_url_raw($avatarUrl);
                        $beforeMeta[UserMetaKeys::AUTHOR_IMAGE_URL] = $existing;
                        $afterMeta[UserMetaKeys::AUTHOR_IMAGE_URL] = $newUrl;
                        update_user_meta($userId, UserMetaKeys::AUTHOR_IMAGE_URL, $newUrl);
                    }
                    $result['counts']['fixed_author_image'] = (int) ($result['counts']['fixed_author_image'] ?? 0) + 1;
                    $didSomething = true;
                }
            } else {
                // Scan was likely stale (user already has an author image now).
                $result['counts']['skipped_author_image_already_set'] = (int) ($result['counts']['skipped_author_image_already_set'] ?? 0) + 1;
            }
        }

        if ($needSameAs) {
            $existing = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_SAME_AS, true);
            if (trim($existing) === '') {
                $urls = $this->guessAuthorSameAsUrls($userId);
                if ($urls) {
                    $value = implode("\n", $urls);
                    if (!$dryRun) {
                        $beforeMeta[UserMetaKeys::AUTHOR_SAME_AS] = $existing;
                        $afterMeta[UserMetaKeys::AUTHOR_SAME_AS] = $value;
                        update_user_meta($userId, UserMetaKeys::AUTHOR_SAME_AS, $value);
                    }
                    $result['counts']['fixed_author_sameas'] = (int) ($result['counts']['fixed_author_sameas'] ?? 0) + 1;
                    $didSomething = true;
                } else {
                    $result['counts']['skipped_sameas_no_links'] = (int) ($result['counts']['skipped_sameas_no_links'] ?? 0) + 1;
                    $result['counts']['skipped_author_sameas_no_links'] = (int) ($result['counts']['skipped_author_sameas_no_links'] ?? 0) + 1;
                }
            } else {
                $result['counts']['skipped_author_sameas_already_set'] = (int) ($result['counts']['skipped_author_sameas_already_set'] ?? 0) + 1;
            }
        }

        if ($didSomething && !$dryRun && !empty($afterMeta)) {
            $revRepo = $this->revisionsRepo();
            if ($revRepo instanceof RevisionsRepository) {
                $revRepo->insert([
                    'object_type' => 'user',
                    'object_id' => $userId,
                    'object_subtype' => 'meta',
                    'action' => 'safe_fix_user',
                    'source' => 'schema',
                    'actor_user_id' => (int) get_current_user_id(),
                    'before' => [
                        'user_meta' => $beforeMeta,
                    ],
                    'after' => [
                        'user_meta' => $afterMeta,
                    ],
                    'meta' => [
                        'fix' => 'safe_user',
                        'need_image' => $needImage ? 1 : 0,
                        'need_sameas' => $needSameAs ? 1 : 0,
                    ],
                ]);
            }
        }

        return $didSomething;
    }


    /**
     * Add summarized fixed/skipped entries + stable counters.
     *
     * @param array<string,mixed> $result
     */
    private function finalizeSafeFixResult(array &$result, int $scanId, bool $dryRun): void
    {
        if (!isset($result['fixed']) || !is_array($result['fixed'])) {
            $result['fixed'] = [];
        }
        if (!isset($result['skipped']) || !is_array($result['skipped'])) {
            $result['skipped'] = [];
        }
        if (!isset($result['counts']) || !is_array($result['counts'])) {
            $result['counts'] = [];
        }

        $fixedImg = (int) ($result['counts']['fixed_author_image'] ?? 0);
        $fixedSame = (int) ($result['counts']['fixed_author_sameas'] ?? 0);
        $targetImg = (int) ($result['counts']['target_author_image'] ?? 0);
        $targetSame = (int) ($result['counts']['target_author_sameas'] ?? 0);

        $opts = isset($result['options']) && is_array($result['options']) ? $result['options'] : [];
        $doAuthorImage = !array_key_exists('author_image', $opts) ? true : !empty($opts['author_image']);
        $doAuthorSameAs = !array_key_exists('author_sameas', $opts) ? true : !empty($opts['author_sameas']);

        // Summary entry: author_image
        if (!$doAuthorImage) {
            $result['skipped'][] = ['type' => 'author_image', 'reason' => 'disabled'];
        } elseif ($fixedImg > 0) {
            $result['fixed'][] = ['type' => 'author_image', 'count' => $fixedImg, 'target' => $targetImg, 'source' => 'avatar'];
        } else {
            $reason = $scanId > 0 ? ($targetImg > 0 ? 'could_not_fix' : 'none_missing_or_already_set') : 'no_scan';
            $result['skipped'][] = ['type' => 'author_image', 'reason' => $reason];
        }

        // Summary entry: author_sameas
        if (!$doAuthorSameAs) {
            $result['skipped'][] = ['type' => 'author_sameas', 'reason' => 'disabled'];
        } elseif ($fixedSame > 0) {
            $result['fixed'][] = ['type' => 'author_sameas', 'count' => $fixedSame, 'target' => $targetSame, 'source' => 'profile_social_links'];
        } else {
            $reason = $scanId > 0 ? ($targetSame > 0 ? 'could_not_fix' : 'none_missing_or_no_social_links') : 'no_scan';
            $result['skipped'][] = ['type' => 'author_sameas', 'reason' => $reason];
        }

        $result['counts']['fixed_total'] = count($result['fixed']);
        // Back-compat: older UI expected fixed_users.
        $result['counts']['fixed_users'] = $fixedImg;
        $result['counts']['fixed_author_image'] = $fixedImg;
        $result['counts']['fixed_author_sameas'] = $fixedSame;

        // Ensure stable keys exist.
        $result['counts']['skipped_users'] = (int) ($result['counts']['skipped_users'] ?? 0);
        $result['counts']['skipped_sameas_no_links'] = (int) ($result['counts']['skipped_sameas_no_links'] ?? 0);
        $result['counts']['skipped_user_forbidden'] = (int) ($result['counts']['skipped_user_forbidden'] ?? 0);
        $result['counts']['skipped_user_not_found'] = (int) ($result['counts']['skipped_user_not_found'] ?? 0);
        $result['counts']['skipped_author_image_no_avatar'] = (int) ($result['counts']['skipped_author_image_no_avatar'] ?? 0);
        $result['counts']['skipped_author_image_already_set'] = (int) ($result['counts']['skipped_author_image_already_set'] ?? 0);
        $result['counts']['skipped_author_sameas_no_links'] = (int) ($result['counts']['skipped_author_sameas_no_links'] ?? 0);
        $result['counts']['skipped_author_sameas_already_set'] = (int) ($result['counts']['skipped_author_sameas_already_set'] ?? 0);
        $result['counts']['skipped_org_logo_already_set'] = (int) ($result['counts']['skipped_org_logo_already_set'] ?? 0);
        $result['counts']['skipped_org_name_already_set'] = (int) ($result['counts']['skipped_org_name_already_set'] ?? 0);
        $result['counts']['total_users'] = (int) ($result['counts']['total_users'] ?? 0);
        $result['counts']['processed_users'] = (int) ($result['counts']['processed_users'] ?? 0);
        $result['counts']['target_author_image'] = (int) ($result['counts']['target_author_image'] ?? 0);
        $result['counts']['target_author_sameas'] = (int) ($result['counts']['target_author_sameas'] ?? 0);
        $result['counts']['target_org_logo'] = (int) ($result['counts']['target_org_logo'] ?? 0);
        $result['counts']['target_org_name'] = (int) ($result['counts']['target_org_name'] ?? 0);
        $result['counts']['fixed_org_logo'] = (int) ($result['counts']['fixed_org_logo'] ?? 0);
        $result['counts']['fixed_org_name'] = (int) ($result['counts']['fixed_org_name'] ?? 0);
        $result['scan_id'] = $scanId;
        $result['dry_run'] = $dryRun ? 1 : 0;
    }


    /**
     * Guess sameAs URLs for an author from existing profile fields.
     * We only use fields that already contain URLs (no guessing / no rewriting).
     *
     * @return array<int,string>
     */
    private function guessAuthorSameAsUrls(int $userId): array
    {
        $urls = [];

        $user = get_user_by('id', $userId);
        if ($user instanceof \WP_User) {
            $userUrl = isset($user->user_url) ? (string) $user->user_url : '';
            if ($this->isValidHttpUrl($userUrl)) {
                $urls[] = esc_url_raw($userUrl);
            }

            // Contact methods (from WP core + plugins/theme).
            if (function_exists('wp_get_user_contact_methods')) {
                $methods = wp_get_user_contact_methods($user);
                if (is_array($methods)) {
                    foreach (array_keys($methods) as $key) {
                        $key = sanitize_key((string) $key);
                        if ($key === '') {
                            continue;
                        }
                        $val = (string) get_user_meta($userId, $key, true);
                        if ($this->isValidHttpUrl($val)) {
                            $urls[] = esc_url_raw($val);
                        }
                    }
                }
            }
        }

        // Common meta keys used by themes/plugins.
        $commonKeys = [
            'facebook',
            'twitter',
            'x',
            'instagram',
            'linkedin',
            'youtube',
            'pinterest',
            'tiktok',
            'github',
            'vk',
            'telegram',
            'whatsapp',
        ];
        foreach ($commonKeys as $key) {
            $val = (string) get_user_meta($userId, $key, true);
            if ($this->isValidHttpUrl($val)) {
                $urls[] = esc_url_raw($val);
            }
        }

        // Yoast user profile social fields.
        $yoastKeys = [
            '_yoast_wpseo_profile_facebook',
            '_yoast_wpseo_profile_twitter',
            '_yoast_wpseo_profile_instagram',
            '_yoast_wpseo_profile_linkedin',
            '_yoast_wpseo_profile_pinterest',
            '_yoast_wpseo_profile_youtube',
        ];
        foreach ($yoastKeys as $key) {
            $val = (string) get_user_meta($userId, $key, true);
            if ($this->isValidHttpUrl($val)) {
                $urls[] = esc_url_raw($val);
            }
        }

        // Rank Math user social fields.
        $rankKeys = [
            'rank_math_facebook',
            'rank_math_twitter',
            'rank_math_instagram',
            'rank_math_linkedin',
            'rank_math_youtube',
            'rank_math_pinterest',
        ];
        foreach ($rankKeys as $key) {
            $val = (string) get_user_meta($userId, $key, true);
            if ($this->isValidHttpUrl($val)) {
                $urls[] = esc_url_raw($val);
            }
        }

        // AIOSEO user social fields (best-effort).
        $aioKeys = [
            'aioseo_facebook',
            'aioseo_twitter',
            'aioseo_instagram',
            'aioseo_linkedin',
            'aioseo_youtube',
        ];
        foreach ($aioKeys as $key) {
            $val = (string) get_user_meta($userId, $key, true);
            if ($this->isValidHttpUrl($val)) {
                $urls[] = esc_url_raw($val);
            }
        }

        // Cleanup + unique.
        $clean = [];
        foreach ($urls as $u) {
            $u = is_string($u) ? trim($u) : '';
            if ($u === '') {
                continue;
            }
            $u = esc_url_raw($u);
            if ($u === '' || !$this->isValidHttpUrl($u)) {
                continue;
            }
            $clean[] = $u;
        }

        $clean = array_values(array_unique($clean));
        return $clean;
    }


    private function isValidHttpUrl(string $url): bool
    {
        $url = trim((string) $url);
        if ($url === '') {
            return false;
        }

        $url = esc_url_raw($url);
        if ($url === '') {
            return false;
        }

        if (function_exists('wp_http_validate_url')) {
            $v = wp_http_validate_url($url);
            if (!is_string($v) || $v === '') {
                return false;
            }
            $url = $v;
        } else {
            if (filter_var($url, FILTER_VALIDATE_URL) === false) {
                return false;
            }
        }

        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
            return false;
        }
        $scheme = strtolower((string) $parts['scheme']);
        if ($scheme !== 'http' && $scheme !== 'https') {
            return false;
        }

        return true;
    }

    private function guessOrgLogoUrl(): string
    {
        // Theme Custom Logo.
        $customLogoId = (int) get_theme_mod('custom_logo');
        if ($customLogoId > 0) {
            $img = wp_get_attachment_image_url($customLogoId, 'full');
            if (is_string($img) && trim($img) !== '') {
                return esc_url_raw($img);
            }
        }

        // Site Icon.
        $icon = get_site_icon_url(512);
        if (is_string($icon) && trim($icon) !== '') {
            return esc_url_raw($icon);
        }

        return '';
    }

    private function revisionsRepo(): ?RevisionsRepository
    {
        if ($this->revRepo instanceof RevisionsRepository) {
            return $this->revRepo;
        }

        // Respect module toggle.
        if (!$this->settings->moduleEnabled('revisions', true)) {
            return null;
        }

        try {
            RevisionsInstaller::maybeInstall();
            $this->revRepo = new RevisionsRepository($this->logger);
            return $this->revRepo;
        } catch (\Throwable $e) {
            return null;
        }
    }


    private function fixStateKey(): string
    {
        return 'wpsn_schema_fix_state_' . (int) get_current_user_id();
    }


    private function fixLastKey(): string
    {
        return 'wpsn_schema_fix_last_' . (int) get_current_user_id();
    }


    private function stateKey(): string
    {
        return 'wpsn_schema_scan_state_' . (int) get_current_user_id();
    }
}
