<?php

namespace WPSN\Modules\SchemaValidator;

use WPSN\Core\Container;
use WPSN\Core\Logger;
use WPSN\Core\Queue\QueueInterface;
use WPSN\Core\Settings;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;
use WPSN\SEO\UserMetaKeys;

defined('ABSPATH') || exit;

/**
 * Queue jobs for Schema Validator.
 *
 * Goal: allow schema scans to run without relying on admin-ajax (some hosts block it / 503).
 */
final class SchemaValidatorJobs
{
    /** Job: process one scan chunk (posts/terms) and enqueue next chunk if needed. */
    public const JOB_SCAN_STEP = 'schema_scan_step';

    /** Job: process one chunk of Schema "Safe fixes" (user meta fixes) and enqueue next chunk if needed. */
    public const JOB_FIX_SAFE_STEP = 'schema_fix_safe_step';

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

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

    private ?RevisionsRepository $revRepo = null;

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

        $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_filter('wpsn_queue_handle_' . self::JOB_SCAN_STEP, [$this, 'handleScanStep'], 10, 3);
        add_filter('wpsn_queue_handle_' . self::JOB_FIX_SAFE_STEP, [$this, 'handleFixSafeStep'], 10, 3);
    }

    /**
     * Queue handler: Schema Safe Fixes (non-AJAX primary path).
     *
     * This processes *only* per-user fixes (author image / sameAs), because org-level fixes are constant-cost and
     * are applied in the admin-post start handler.
     *
     * Payload must include:
     * - actor_user_id: int
     * - session: string
     * - chunk: int (optional)
     *
     * @param mixed $null
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */
    public function handleFixSafeStep($null, array $payload, array $job)
    {
        $actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : 0;
        $session = isset($payload['session']) ? (string) $payload['session'] : '';
        $chunk = isset($payload['chunk']) ? (int) $payload['chunk'] : 50;
        $chunk = max(10, min(200, $chunk));

        if ($actorUserId <= 0 || $session === '') {
            return null;
        }

        // Run under the initiating user context, so capability checks + revision logs work.
        if (function_exists('wp_set_current_user')) {
            wp_set_current_user($actorUserId);
        }

        $stateKey = $this->fixStateKeyFor($actorUserId);
        $lastKey = $this->fixLastKeyFor($actorUserId);

        try {
            // Best-effort: avoid memory death on shared hosting.
            if (function_exists('wp_raise_memory_limit')) {
                // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_raise_memory_limit
                wp_raise_memory_limit('admin');
            }

            $state = get_transient($stateKey);
            if (!is_array($state) || empty($state['session']) || (string) $state['session'] !== $session) {
                // Cancelled/expired or replaced by another run.
                return null;
            }

            $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++;

                $this->applySafeUserFixes($result, $dryRun, $userId, isset($needImage[$userId]), isset($needSameAs[$userId]));
            }

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

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

            if ($done) {
                delete_transient($stateKey);

                $this->finalizeSafeFixResult($result, $scanId, $dryRun);
                $result['status'] = 'done';
                $result['finished_ts'] = time();
                $started = isset($state['started']) ? (int) $state['started'] : 0;
                if ($started > 0) {
                    $result['duration_sec'] = max(0, (int) (time() - $started));
                }

                set_transient($lastKey, $result, 6 * HOUR_IN_SECONDS);

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

                return null;
            }

            set_transient($stateKey, $state, HOUR_IN_SECONDS);

            // Enqueue the next chunk.
            $this->queue->enqueue(self::JOB_FIX_SAFE_STEP, [
                'actor_user_id' => $actorUserId,
                'session' => $session,
                'chunk' => $chunk,
            ]);

            return null;
        } catch (\Throwable $e) {
            $msg = $e->getMessage();
            $this->logger->error('Schema safe fix job failed: ' . $msg);

            // Best-effort: keep a last-result for the UI.
            $result = [
                'status' => 'failed',
                'error' => $msg,
                'finished_ts' => time(),
            ];
            set_transient($lastKey, $result, 6 * HOUR_IN_SECONDS);

            return [
                'status' => 'failed',
                'error' => $msg,
            ];
        } finally {
            // Clean up current user context.
            if (function_exists('wp_set_current_user')) {
                wp_set_current_user(0);
            }
        }
    }

    /**
     * Queue handler.
     *
     * @param mixed $null
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */
    public function handleScanStep($null, array $payload, array $job)
    {
        $scanId = isset($payload['scan_id']) ? (int) $payload['scan_id'] : 0;
        if ($scanId <= 0) {
            return null;
        }

        $scan = $this->scans->get($scanId);
        if (!$scan) {
            return null;
        }

        $status = isset($scan['status']) ? (string) $scan['status'] : '';
        // If the scan is no longer running (cancelled/failed/done), ignore queued jobs.
        if ($status !== 'running') {
            return null;
        }

        $state = isset($payload['state']) && is_array($payload['state']) ? $payload['state'] : [];
        $chunk = isset($payload['chunk']) ? (int) $payload['chunk'] : 25;
        $chunk = max(5, min(200, $chunk));

        // Best-effort: avoid memory death on shared hosting.
        if (function_exists('wp_raise_memory_limit')) {
            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_raise_memory_limit
            wp_raise_memory_limit('admin');
        }

        try {
            $processed = 0;
            $issuesAdded = 0;

            // Normalize state defaults.
            $state['stage'] = isset($state['stage']) ? (string) $state['stage'] : 'posts';
            $state['post_types'] = isset($state['post_types']) && is_array($state['post_types']) ? array_values($state['post_types']) : [];
            $state['taxonomies'] = isset($state['taxonomies']) && is_array($state['taxonomies']) ? array_values($state['taxonomies']) : [];
            $state['include_terms'] = !empty($state['include_terms']);

            $state['pt_index'] = isset($state['pt_index']) ? (int) $state['pt_index'] : 0;
            $state['tax_index'] = isset($state['tax_index']) ? (int) $state['tax_index'] : 0;
            $state['last_post_id'] = isset($state['last_post_id']) ? (int) $state['last_post_id'] : 0;
            $state['last_term_id'] = isset($state['last_term_id']) ? (int) $state['last_term_id'] : 0;

            $state['scanned_posts'] = isset($state['scanned_posts']) ? (int) $state['scanned_posts'] : 0;
            $state['scanned_terms'] = isset($state['scanned_terms']) ? (int) $state['scanned_terms'] : 0;
            $state['issues'] = isset($state['issues']) ? (int) $state['issues'] : 0;
            $state['total_posts'] = isset($state['total_posts']) ? (int) $state['total_posts'] : 0;
            $state['total_terms'] = isset($state['total_terms']) ? (int) $state['total_terms'] : 0;
            $state['started'] = isset($state['started']) ? (int) $state['started'] : time();

            while ($processed < $chunk) {
                if ($state['stage'] === 'posts') {
                    $pt = $state['post_types'][$state['pt_index']] ?? '';
                    $pt = is_string($pt) ? sanitize_key($pt) : '';

                    // No more post types → move to terms or done.
                    if ($pt === '') {
                        if ($state['include_terms'] && !empty($state['taxonomies'])) {
                            $state['stage'] = 'terms';
                            $state['tax_index'] = 0;
                            $state['last_term_id'] = 0;
                            continue;
                        }

                        $state['stage'] = 'done';
                        break;
                    }

                    $ids = $this->fetchPostIds($pt, (int) $state['last_post_id'], min($chunk - $processed, 50));
                    if (empty($ids)) {
                        // Next post type.
                        $state['pt_index']++;
                        $state['last_post_id'] = 0;
                        continue;
                    }

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

                        $issues = $this->validator->validatePost($id, $pt);
                        foreach ($issues as $issue) {
                            if (!is_array($issue)) {
                                continue;
                            }
                            $issue['scan_id'] = $scanId;
                            $this->issues->insert($issue);
                            $issuesAdded++;
                        }

                        $state['scanned_posts']++;
                        $processed++;
                        $state['last_post_id'] = $id;

                        if ($processed >= $chunk) {
                            break;
                        }
                    }

                    continue;
                }

                if ($state['stage'] === 'terms') {
                    if (!$state['include_terms']) {
                        $state['stage'] = 'done';
                        break;
                    }

                    $tax = $state['taxonomies'][$state['tax_index']] ?? '';
                    $tax = is_string($tax) ? sanitize_key($tax) : '';

                    if ($tax === '') {
                        $state['stage'] = 'done';
                        break;
                    }

                    $ids = $this->fetchTermIds($tax, (int) $state['last_term_id'], min($chunk - $processed, 50));
                    if (empty($ids)) {
                        // Next taxonomy.
                        $state['tax_index']++;
                        $state['last_term_id'] = 0;
                        continue;
                    }

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

                        $issues = $this->validator->validateTerm($id, $tax);
                        foreach ($issues as $issue) {
                            if (!is_array($issue)) {
                                continue;
                            }
                            $issue['scan_id'] = $scanId;
                            $this->issues->insert($issue);
                            $issuesAdded++;
                        }

                        $state['scanned_terms']++;
                        $processed++;
                        $state['last_term_id'] = $id;

                        if ($processed >= $chunk) {
                            break;
                        }
                    }

                    continue;
                }

                // done
                $state['stage'] = 'done';
                break;
            }

            $state['issues'] += $issuesAdded;

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

            // Persist progress for UI.
            $this->scans->updateStats($scanId, [
                'stage' => (string) ($state['stage'] ?? ''),
                'scanned_posts' => (int) ($state['scanned_posts'] ?? 0),
                'scanned_terms' => (int) ($state['scanned_terms'] ?? 0),
                'issues' => (int) ($state['issues'] ?? 0),
                'total_posts' => (int) ($state['total_posts'] ?? 0),
                'total_terms' => (int) ($state['total_terms'] ?? 0),
                'total' => (int) $total,
                'updated_ts' => time(),
            ]);

            $done = isset($state['stage']) && (string) $state['stage'] === 'done';
            if ($done || ($total > 0 && $scanned >= $total)) {
                $this->finishScan($scanId, $state);
                return null;
            }

            // Enqueue the next chunk.
            $this->queue->enqueue(self::JOB_SCAN_STEP, [
                'scan_id' => $scanId,
                'chunk' => $chunk,
                'state' => $state,
            ]);

            return null;
        } catch (\Throwable $e) {
            // Mark scan as failed (best-effort) and rethrow to mark job failed.
            $this->logger->error('Schema scan job failed: scan #' . $scanId . ' - ' . $e->getMessage());

            $stats = $this->decodeStats(isset($scan['stats']) ? (string) $scan['stats'] : '');
            $stats['stage'] = 'failed';
            $stats['error'] = $e->getMessage();
            $stats['updated_ts'] = time();
            $this->scans->finish($scanId, $stats, 'failed');

            throw $e;
        }
    }

    /**
     * @return int[]
     */
    private function fetchPostIds(string $postType, int $afterId, int $limit): array
    {
        global $wpdb;

        $postType = sanitize_key($postType);
        $afterId = max(0, (int) $afterId);
        $limit = max(1, min(200, (int) $limit));

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $sql = $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s AND ID > %d ORDER BY ID ASC LIMIT %d",
            $postType,
            'publish',
            $afterId,
            $limit
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $ids = $wpdb->get_col($sql);
        if (!is_array($ids)) {
            return [];
        }

        return array_values(array_filter(array_map('intval', $ids), static fn($v) => $v > 0));
    }

    /**
     * @return int[]
     */
    private function fetchTermIds(string $taxonomy, int $afterId, int $limit): array
    {
        global $wpdb;

        $taxonomy = sanitize_key($taxonomy);
        $afterId = max(0, (int) $afterId);
        $limit = max(1, min(200, (int) $limit));

        // term_taxonomy has (term_id, taxonomy)
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $sql = $wpdb->prepare(
            "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s AND term_id > %d ORDER BY term_id ASC LIMIT %d",
            $taxonomy,
            $afterId,
            $limit
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $ids = $wpdb->get_col($sql);
        if (!is_array($ids)) {
            return [];
        }

        return array_values(array_filter(array_map('intval', $ids), static fn($v) => $v > 0));
    }

    /**
     * @return array<string,mixed>
     */
    private function decodeStats(string $raw): array
    {
        $raw = is_string($raw) ? $raw : '';
        if ($raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : [];
    }

    /**
     * Finish scan and fire webhooks.
     *
     * @param array<string,mixed> $state
     */
    private function finishScan(int $scanId, array $state): void
    {
        $total = ((int) ($state['total_posts'] ?? 0)) + ((int) ($state['total_terms'] ?? 0));

        $this->scans->finish($scanId, [
            'stage' => 'done',
            'scanned_posts' => (int) ($state['scanned_posts'] ?? 0),
            'scanned_terms' => (int) ($state['scanned_terms'] ?? 0),
            'issues' => (int) ($state['issues'] ?? 0),
            'total_posts' => (int) ($state['total_posts'] ?? 0),
            'total_terms' => (int) ($state['total_terms'] ?? 0),
            'total' => (int) $total,
            'updated_ts' => time(),
        ], 'done');

        // 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);
    }


    // -------------------------------------------------------------------------
    // Safe fixes helpers (shared behavior with Admin, but executed in Queue)
    // -------------------------------------------------------------------------

    private function fixStateKeyFor(int $userId): string
    {
        return 'wpsn_schema_fix_state_' . (int) $userId;
    }

    private function fixLastKeyFor(int $userId): string
    {
        return 'wpsn_schema_fix_last_' . (int) $userId;
    }

    /**
     * Apply safe per-user fixes (author image and/or sameAs).
     *
     * This is intentionally duplicated from SchemaValidatorAdmin so Queue-based execution can run without admin-ajax.
     *
     * @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 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;
        }
    }
}
