<?php

declare(strict_types=1);

namespace ShieldX\Scanner;

defined('ABSPATH') || exit;

use ShieldX\Maintenance\FileShadowCopyManager;
use ShieldX\Core\FileTypeSettings;
use ShieldX\Storage\DurableJobStore;
use ShieldX\Storage\FileStorage;
use ShieldX\Support\WordPressPaths;

final class LocalScanner
{
    private const MAX_CONTENT_BYTES = 262144;
    private const MAX_CONTENT_BYTES_PER_RUN = 16777216;
    private const PROGRESSIVE_BATCH_FILES = 400;
    private const PROGRESSIVE_BATCH_SECONDS = 8;
    private const PROGRESSIVE_SHADOW_BATCH_FILES = 5000;
    private const PROGRESSIVE_CHUNK_FILES = 50;
    private const PROGRESSIVE_SHADOW_CHUNK_FILES = 1000;
    private const PROGRESSIVE_STORED_ISSUES = 200;
    private const PROGRESSIVE_STORED_SHADOW_ITEMS = 200;
    private const PROGRESSIVE_EXTERNAL_FILE_LIST_THRESHOLD = 2000;
    private const PROGRESSIVE_FILE_LIST_LINE_BYTES = 8192;
    private const PROGRESSIVE_JOB_TIMEOUT_SECONDS = 3600;
    private const SCOPE_COUNT_CACHE_TTL = 600;
    private const QUICK_SCAN_MAX_SECONDS = 8;
    private const MAX_QUICK_SCAN_FILES = 20000;
    public const DEFAULT_MAX_FILES = 5000;
    public const DEFAULT_SHADOW_MAX_FILES = 100000;

    /** @var ScanRepository */
    private $repository;

    /** @var ScanRuleEngine */
    private $engine;

    /** @var string */
    private $rootDir;

    /** @var string */
    private $contentDir;

    /** @var DatabaseScanner|null */
    private $databaseScanner;

    /** @var FileShadowCopyManager|null */
    private $shadowCopies;

    /** @var CoreFileVerifier|null */
    private $coreVerifier;

    /** @var FileStorage|null */
    private $storage;

    /** @var DurableJobStore */
    private $durableJobs;

    /** @var PluginFileVerifier|null */
    private $pluginVerifier;

    /** @var FileTypeSettings */
    private $fileTypes;

    /** @var int */
    private $contentBytesRead = 0;

    /** @var array<string,array{file_count:int,count_truncated:bool}> */
    private static $scopeCountCache = [];

    public function __construct(ScanRepository $repository, ScanRuleEngine $engine, string $rootDir, string $contentDir, ?DatabaseScanner $databaseScanner = null, ?FileShadowCopyManager $shadowCopies = null, ?CoreFileVerifier $coreVerifier = null, ?FileStorage $storage = null, ?PluginFileVerifier $pluginVerifier = null, ?FileTypeSettings $fileTypes = null, ?DurableJobStore $durableJobs = null)
    {
        $this->repository = $repository;
        $this->engine = $engine;
        $this->rootDir = rtrim($rootDir, '/\\');
        $this->contentDir = rtrim($contentDir, '/\\');
        $this->databaseScanner = $databaseScanner;
        $this->shadowCopies = $shadowCopies;
        $this->coreVerifier = $coreVerifier;
        $this->storage = $storage;
        $this->pluginVerifier = $pluginVerifier;
        $this->fileTypes = $fileTypes ?: FileTypeSettings::fromWordPress();
        $this->durableJobs = $durableJobs ?: new DurableJobStore();
    }

    public static function fromWordPress(): self
    {
        $root = WordPressPaths::root();
        $content = WordPressPaths::contentDir();

        return new self(ScanRepository::fromWordPress(), new ScanRuleEngine(), $root, $content, DatabaseScanner::fromWordPress(), FileShadowCopyManager::fromWordPress(), CoreFileVerifier::fromWordPress(), FileStorage::fromWordPress(), PluginFileVerifier::fromWordPress(), FileTypeSettings::fromWordPress(), new DurableJobStore());
    }

    /**
     * @param string|array<int,string>|null $scopes
     * @return array<string,mixed>
     */
    public function runQuickScan(int $maxFiles = self::DEFAULT_MAX_FILES, $scopes = null, string $type = 'quick'): array
    {
        $this->assertWordPressRootReady();
        $this->resetContentBudget();
        $maxFiles = max(1, min(self::MAX_QUICK_SCAN_FILES, $maxFiles));
        $scanId = self::newScanId();
        $started = microtime(true);
        $scanStartedAt = gmdate('c');
        $scopeDetails = $this->scanScopes($scopes);
        $fileTypeContext = $type === 'admin_change' ? FileTypeSettings::CONTEXT_BACKUP : FileTypeSettings::CONTEXT_MALWARE;
        $discovery = $this->discoverFiles($maxFiles, $scopeDetails, $fileTypeContext, true);
        $files = isset($discovery['files']) && is_array($discovery['files']) ? $discovery['files'] : [];
        $fileInfos = isset($discovery['file_infos']) && is_array($discovery['file_infos']) ? $discovery['file_infos'] : $files;
        $scopeIds = $this->scopeIds($scopeDetails);
        $scopeLabels = $this->scopeLabels($scopeDetails);
        $scopePaths = $this->scopePaths($scopeDetails);
        $databaseSelected = $this->databaseScopeSelected($scopeDetails);
        $activeThreatIds = [];
        $inspectedPaths = [];
        $issues = [];
        $filesScanned = 0;
        $filesSkipped = 0;
        $filesNew = 0;
        $filesChanged = 0;
        $filesHashChecked = 0;
        $filesHashUnchanged = 0;
        $shadowCopiesCreated = 0;
        $shadowChangesPending = 0;
        $shadowChangedItems = [];
        $baselineRecordsLoaded = 0;
        $activeIssuePathsLoaded = 0;

        $this->repository->startScan($scanId, $type === 'scheduled' ? 'scheduled' : 'quick');

        try {
            $batch = $this->processFileBatch($fileInfos, $scanId, $scanStartedAt, $started + self::QUICK_SCAN_MAX_SECONDS);
            $activeThreatIds = $batch['active_threat_ids'];
            $inspectedPaths = $batch['inspected_paths'];
            $issues = $batch['issues'];
            $filesScanned = (int) $batch['files_scanned'];
            $filesSkipped = (int) $batch['files_skipped'];
            $filesNew = (int) $batch['files_new'];
            $filesChanged = (int) $batch['files_changed'];
            $filesHashChecked = (int) $batch['files_hash_checked'];
            $filesHashUnchanged = (int) $batch['files_hash_unchanged'];
            $shadowCopiesCreated = (int) $batch['shadow_copies_created'];
            $shadowChangesPending = (int) $batch['shadow_changes_pending'];
            $shadowChangedItems = $batch['shadow_changed_items'];
            $baselineRecordsLoaded = (int) $batch['baseline_records_loaded'];
            $activeIssuePathsLoaded = (int) $batch['active_issue_paths_loaded'];

            $timeLimited = !empty($batch['time_limited']);
            $databaseResult = $timeLimited ? [
                'available' => false,
                'engine_version' => DatabaseScanner::ENGINE_VERSION,
                'items_scanned' => 0,
                'issues_found' => 0,
                'inspected_paths' => [],
                'issues' => [],
            ] : $this->scanDatabaseIfSelected($databaseSelected);
            $databaseIssues = isset($databaseResult['issues']) && is_array($databaseResult['issues']) ? $databaseResult['issues'] : [];
            foreach ($databaseIssues as $issue) {
                if (!is_array($issue)) {
                    continue;
                }

                $issues[] = $issue;
                $activeThreatIds[] = (string) ($issue['threat_id'] ?? '');
                $this->repository->upsertIssue($issue);
            }
            $inspectedPaths = array_merge(
                $inspectedPaths,
                isset($databaseResult['inspected_paths']) && is_array($databaseResult['inspected_paths']) ? array_map('strval', $databaseResult['inspected_paths']) : []
            );

            $activeThreatIds = array_values(array_unique($activeThreatIds));
            $this->repository->resolveScannerIssuesForInspectedPaths($activeThreatIds, $inspectedPaths);
            $this->repository->resolveScannerIssuesForInspectedPaths([], $this->engine->retiredFalsePositivePaths());
            $result = [
                'scan_id' => $scanId,
                'status' => $timeLimited ? 'partial' : 'completed',
                'engine_version' => ScanRuleEngine::ENGINE_VERSION,
                'smart_mode' => 'mtime_hash_incremental',
                'scope' => implode(',', $scopeIds),
                'scope_label' => implode(', ', $scopeLabels),
                'scope_path' => implode(', ', $scopePaths),
                'scope_count' => count($scopeIds),
                'truncated' => !empty($discovery['truncated']),
                'time_limited' => $timeLimited,
                'files_total' => count($files),
                'files_scanned' => $filesScanned,
                'files_skipped' => $filesSkipped,
                'files_new' => $filesNew,
                'files_changed' => $filesChanged,
                'files_hash_checked' => $filesHashChecked,
                'files_hash_unchanged' => $filesHashUnchanged,
                'baseline_records_loaded' => $baselineRecordsLoaded,
                'active_issue_paths_loaded' => $activeIssuePathsLoaded,
                'shadow_copies_created' => $shadowCopiesCreated,
                'shadow_changes_pending' => $shadowChangesPending,
                'shadow_changed_items' => $this->shadowChangedItemSummaries($shadowChangedItems),
                'database_selected' => $databaseSelected,
                'database_available' => !empty($databaseResult['available']),
                'database_engine_version' => (string) ($databaseResult['engine_version'] ?? DatabaseScanner::ENGINE_VERSION),
                'database_items_scanned' => (int) ($databaseResult['items_scanned'] ?? 0),
                'database_issues_found' => (int) ($databaseResult['issues_found'] ?? 0),
                'issues_found' => count($issues),
                'duration_ms' => (int) round((microtime(true) - $started) * 1000),
                'issues' => $issues,
            ];
            $this->repository->finishScan($scanId, $timeLimited ? 'partial' : 'completed', count($files), $filesScanned, count($issues), $result);

            return $result;
        } catch (\Throwable $error) {
            $result = [
                'scan_id' => $scanId,
                'status' => 'failed',
                'engine_version' => ScanRuleEngine::ENGINE_VERSION,
                'smart_mode' => 'mtime_hash_incremental',
                'scope' => implode(',', $scopeIds),
                'scope_label' => implode(', ', $scopeLabels),
                'scope_path' => implode(', ', $scopePaths),
                'scope_count' => count($scopeIds),
                'truncated' => !empty($discovery['truncated']),
                'files_total' => count($files),
                'files_scanned' => $filesScanned,
                'files_skipped' => $filesSkipped,
                'files_new' => $filesNew,
                'files_changed' => $filesChanged,
                'files_hash_checked' => $filesHashChecked,
                'files_hash_unchanged' => $filesHashUnchanged,
                'baseline_records_loaded' => $baselineRecordsLoaded,
                'active_issue_paths_loaded' => $activeIssuePathsLoaded,
                'shadow_copies_created' => $shadowCopiesCreated,
                'shadow_changes_pending' => $shadowChangesPending,
                'shadow_changed_items' => $this->shadowChangedItemSummaries($shadowChangedItems),
                'database_selected' => $databaseSelected,
                'database_available' => false,
                'database_engine_version' => DatabaseScanner::ENGINE_VERSION,
                'database_items_scanned' => 0,
                'database_issues_found' => 0,
                'issues_found' => count($issues),
                'duration_ms' => (int) round((microtime(true) - $started) * 1000),
                'error' => $error->getMessage(),
            ];
            $this->repository->finishScan($scanId, 'failed', count($files), $filesScanned, count($issues), $result);

            return $result;
        }
    }

    /**
     * Start a resumable admin scan. Each continuation request processes a
     * bounded file batch so large sites do not hit web-server timeouts.
     *
     * @param string|array<int,string>|null $scopes
     * @return array<string,mixed>
     */
    public function startProgressiveScan(int $maxFiles = self::DEFAULT_MAX_FILES, $scopes = null, string $redirectPage = 'shield-x-scan', bool $precheckQueue = true, string $scanMode = 'scan', string $databaseDepth = 'quick'): array
    {
        $this->assertWordPressRootReady();
        $this->reapAbandonedProgressiveJobs();
        $scanId = self::newScanId();
        $scanStartedAt = gmdate('c');
        $scopeDetails = $this->scanScopes($scopes);
        $redirectPage = in_array($redirectPage, ['shield-x-scan', 'shield-x-backup'], true) ? $redirectPage : 'shield-x-scan';
        $scanMode = $scanMode === 'shadow' ? 'shadow' : 'scan';
        $databaseSelected = $this->databaseScopeSelected($scopeDetails);
        $databaseDepth = $databaseDepth === 'deep' ? 'deep' : 'quick';
        if ($scanMode === 'shadow' && $maxFiles === self::DEFAULT_MAX_FILES) {
            $maxFiles = self::DEFAULT_SHADOW_MAX_FILES;
        }
        if ($scanMode !== 'shadow') {
            $maxFiles = max(1, min(self::MAX_QUICK_SCAN_FILES, $maxFiles));
        }

        $fileTypeContext = $scanMode === 'shadow' ? FileTypeSettings::CONTEXT_BACKUP : FileTypeSettings::CONTEXT_MALWARE;
        $scopeIds = $this->scopeIds($scopeDetails);
        $aggregate = $this->emptyProgressiveAggregate();

        $job = [
            'scan_id' => $scanId,
            'status' => 'running',
            'started_at' => microtime(true),
            'scan_started_at' => $scanStartedAt,
            'created_at' => gmdate('c'),
            'updated_at' => gmdate('c'),
            'engine_version' => ScanRuleEngine::ENGINE_VERSION,
            'smart_mode' => 'mtime_hash_incremental',
            'scope' => implode(',', $scopeIds),
            'scope_label' => implode(', ', $this->scopeLabels($scopeDetails)),
            'scope_path' => implode(', ', $this->scopePaths($scopeDetails)),
            'scope_count' => count($scopeIds),
            'scan_mode' => $scanMode,
            'truncated' => false,
            'files' => [],
            'files_external' => false,
            'files_durable' => false,
            'files_ref' => '',
            'files_byte_offset' => 0,
            'files_total' => 0,
            'files_discovered' => 0,
            'files_prechecked_skipped' => 0,
            'cursor' => 0,
            'database_selected' => $databaseSelected,
            'database_done' => !$databaseSelected,
            'database_depth' => $databaseDepth,
            'database_state' => $databaseSelected && $databaseDepth === 'deep' && $this->databaseScanner
                ? (new ProgressiveDatabaseScanner($this->databaseScanner))->start()
                : [],
            // Recursive discovery runs in the first continuation request so opening the admin scan UI does not walk the whole site.
            'discovery_pending' => true,
            'discovery_scopes' => $scopeDetails,
            'discovery_file_type_context' => $fileTypeContext,
            'discovery_max_files' => $maxFiles,
            'discovery_precheck_queue' => $precheckQueue,
            'redirect_page' => $redirectPage,
            'batches' => 0,
            'aggregate' => $aggregate,
        ];

        $this->repository->startScan($scanId, $databaseSelected && $databaseDepth === 'deep' ? 'deep' : 'quick');
        $this->writeScanJob($job);

        return $this->publicProgressiveState($job);
    }

    /**
     * @return array<string,mixed>
     */
    public function runProgressiveScanBatch(string $scanId, int $batchFiles = self::PROGRESSIVE_BATCH_FILES, int $maxSeconds = self::PROGRESSIVE_BATCH_SECONDS): array
    {
        $this->reapAbandonedProgressiveJobs($scanId);
        $job = $this->readScanJob($scanId);
        if (!$job) {
            return [
                'scan_id' => $scanId,
                'status' => 'failed',
                'error' => 'Scan job was not found.',
            ];
        }

        if ((string) ($job['status'] ?? '') !== 'running') {
            return $this->publicProgressiveState($job);
        }

        $this->resetContentBudget();
        $started = microtime(true);
        $shadowMode = $this->progressiveShadowMode($job);
        if ($shadowMode && $batchFiles === self::PROGRESSIVE_BATCH_FILES) {
            $batchFiles = self::PROGRESSIVE_SHADOW_BATCH_FILES;
        }
        $batchFiles = max(1, min($shadowMode ? self::PROGRESSIVE_SHADOW_BATCH_FILES : 500, $batchFiles));
        $maxSeconds = max(2, min(20, $maxSeconds));
        $chunkLimit = $shadowMode ? self::PROGRESSIVE_SHADOW_CHUNK_FILES : self::PROGRESSIVE_CHUNK_FILES;
        $files = [];
        $total = 0;
        $cursor = 0;
        $processedThisRequest = 0;
        $aggregate = $this->emptyProgressiveAggregate();

        try {
            if (!empty($job['discovery_pending'])) {
                $job = $this->initializeProgressiveJobFiles($job);
            }

            $files = !empty($job['files_durable'])
                ? $this->readDurableProgressiveJobFiles((string) ($job['scan_id'] ?? ''))
                : (isset($job['files']) && is_array($job['files']) ? array_values(array_map('strval', $job['files'])) : []);
            $total = max(count($files), (int) ($job['files_total'] ?? 0));
            $cursor = max(0, (int) ($job['cursor'] ?? 0));
            $aggregate = isset($job['aggregate']) && is_array($job['aggregate']) ? $job['aggregate'] : $this->emptyProgressiveAggregate();
            $fileList = $this->openProgressiveJobFileList($job);
            while ($cursor < $total && $processedThisRequest < $batchFiles && (microtime(true) - $started) < $maxSeconds) {
                $remaining = $batchFiles - $processedThisRequest;
                $chunkSize = min($chunkLimit, $remaining, $total - $cursor);
                $chunk = $fileList ? $this->readProgressiveJobFileChunk($fileList, $chunkSize) : array_slice($files, $cursor, $chunkSize);
                if (!$chunk) {
                    $cursor = $total;
                    break;
                }

                $batch = $shadowMode
                    ? $this->processShadowFileBatch($chunk, (string) $job['scan_id'], (string) ($job['scan_started_at'] ?? gmdate('c')))
                    : $this->processFileBatch($chunk, (string) $job['scan_id'], (string) ($job['scan_started_at'] ?? gmdate('c')));
                $aggregate = $this->mergeProgressiveAggregate($aggregate, $batch);
                $cursor += count($chunk);
                $processedThisRequest += count($chunk);

                if ((microtime(true) - $started) >= $maxSeconds) {
                    break;
                }
            }

            $job['cursor'] = $cursor;
            $job['aggregate'] = $aggregate;
            $job['batches'] = (int) ($job['batches'] ?? 0) + 1;
            $job['updated_at'] = gmdate('c');
            if ($fileList) {
                $byteOffset = $fileList->ftell();
                $job['files_byte_offset'] = is_int($byteOffset) ? $byteOffset : 0;
            }

            if ($cursor >= $total && empty($job['database_done']) && (microtime(true) - $started) < $maxSeconds) {
                if ((string) ($job['database_depth'] ?? 'quick') === 'deep' && $this->databaseScanner) {
                    $databaseEngine = new ProgressiveDatabaseScanner($this->databaseScanner);
                    $databaseState = isset($job['database_state']) && is_array($job['database_state'])
                        ? $job['database_state']
                        : $databaseEngine->start();
                    $databaseStep = $databaseEngine->step($databaseState);
                    $job['database_state'] = $databaseStep['state'];
                    $aggregate = $this->mergeProgressiveDeepDatabaseBatch($aggregate, $databaseStep['batch']);
                    $job['aggregate'] = $aggregate;

                    $completedPhase = (string) ($databaseStep['completed_phase'] ?? '');
                    if ($completedPhase !== '') {
                        $this->repository->resolveScannerIssuesForPathPrefixes(
                            isset($databaseStep['completed_phase_active_threat_ids']) && is_array($databaseStep['completed_phase_active_threat_ids'])
                                ? $databaseStep['completed_phase_active_threat_ids']
                                : [],
                            $this->databasePhasePrefixes($completedPhase)
                        );
                    }

                    $databaseStatus = (string) ($job['database_state']['status'] ?? 'failed');
                    if ($databaseStatus === 'failed') {
                        throw new \RuntimeException((string) ($job['database_state']['error'] ?? 'Deep database scan failed.'));
                    }
                    $job['database_done'] = $databaseStatus === 'completed';
                } else {
                    $databaseResult = $this->scanDatabaseIfSelected(!empty($job['database_selected']));
                    $aggregate = $this->mergeProgressiveDatabaseResult($aggregate, $databaseResult);
                    $job['aggregate'] = $aggregate;
                    $job['database_done'] = true;
                }
            }

            if ($cursor >= $total && !empty($job['database_done'])) {
                $result = $this->completeProgressiveScan($job);
                $job['status'] = 'completed';
                $job['result'] = $result;
                $job['updated_at'] = gmdate('c');
                $this->deleteScanJob((string) ($job['scan_id'] ?? $scanId));

                return $this->publicProgressiveState($job);
            }

            $this->writeScanJob($job);

            return $this->publicProgressiveState($job);
        } catch (\Throwable $error) {
            $job['status'] = 'failed';
            $job['error'] = $error->getMessage();
            $job['updated_at'] = gmdate('c');
            $aggregate = isset($job['aggregate']) && is_array($job['aggregate']) ? $job['aggregate'] : $this->emptyProgressiveAggregate();
            $issuesFound = (int) ($aggregate['issues_found'] ?? count($aggregate['issues'] ?? []));
            $filesTotalForResult = (int) ($job['files_discovered'] ?? $total);
            $this->repository->finishScan($scanId, 'failed', $filesTotalForResult, (int) ($aggregate['files_scanned'] ?? 0), $issuesFound, [
                'scan_id' => $scanId,
                'status' => 'failed',
                'error' => $error->getMessage(),
                'files_total' => $filesTotalForResult,
                'files_scanned' => (int) ($aggregate['files_scanned'] ?? 0),
                'issues_found' => $issuesFound,
            ]);
            $this->deleteScanJob($scanId);

            return $this->publicProgressiveState($job);
        }
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(bool $withScopeCounts = true): array
    {
        $summary = $this->repository->summary();
        $summary['available'] = $this->repository->available();
        $summary['scopes'] = $this->availableScopes($withScopeCounts);

        return $summary;
    }

    public function sanitizeScope(string $scope): string
    {
        if ($scope === 'all' || $scope === 'content') {
            return 'all';
        }

        foreach ($this->availableScopes(false) as $candidate) {
            if ((string) $candidate['id'] === $scope) {
                return $scope;
            }
        }

        return 'all';
    }

    /**
     * @param array<int,string> $scopes
     * @return array<int,string>
     */
    public function sanitizeScopes(array $scopes): array
    {
        $available = [];

        foreach ($this->availableScopes(false) as $scope) {
            $available[(string) $scope['id']] = true;
        }

        $selected = [];

        foreach ($scopes as $scope) {
            $scope = $this->sanitizeScopeId((string) $scope);

            if (($scope === 'all' || $scope === 'content') && empty($selected)) {
                return array_keys($available);
            }

            if (isset($available[$scope])) {
                $selected[$scope] = true;
            }
        }

        return $selected ? array_keys($selected) : array_keys($available);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function availableScopes(bool $withCounts = true): array
    {
        $scopes = [];

        foreach ($this->scopeDefinitions() as $scope) {
            if (!empty($scope['database'])) {
                if ($withCounts) {
                    $scope['file_count'] = 0;
                    $scope['count_label'] = 'Database checks';
                }

                $scope['selected_default'] = true;
                $scopes[] = $scope;
                continue;
            }

            $absolutePath = (string) ($scope['absolute_path'] ?? '');

            if (empty($scope['root_probes']) && ($absolutePath === '' || !is_dir($absolutePath))) {
                continue;
            }

            if ($withCounts) {
                $count = $this->cachedScopeCount($scope);
                $scope['file_count'] = $count['file_count'];
                $scope['count_truncated'] = $count['count_truncated'];
            }

            $scope['selected_default'] = true;
            $scopes[] = $scope;
        }

        return $scopes;
    }

    /**
     * Scope counts are only an admin-card hint; cache briefly so large sites do
     * not walk the same directories on every SHIELD-X page render.
     *
     * @param array<string,mixed> $scope
     * @return array{file_count:int,count_truncated:bool}
     */
    private function cachedScopeCount(array $scope): array
    {
        $cacheKey = $this->scopeCountCacheKey($scope);
        if (isset(self::$scopeCountCache[$cacheKey])) {
            return self::$scopeCountCache[$cacheKey];
        }

        if (function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached) && isset($cached['file_count'], $cached['count_truncated'])) {
                self::$scopeCountCache[$cacheKey] = [
                    'file_count' => max(0, (int) $cached['file_count']),
                    'count_truncated' => (bool) $cached['count_truncated'],
                ];

                return self::$scopeCountCache[$cacheKey];
            }
        }

        $counted = $this->discoverFiles(10000, [$scope]);
        $files = isset($counted['files']) && is_array($counted['files']) ? $counted['files'] : [];
        $result = [
            'file_count' => count($files),
            'count_truncated' => !empty($counted['truncated']),
        ];

        self::$scopeCountCache[$cacheKey] = $result;
        if (function_exists('set_transient')) {
            set_transient($cacheKey, $result, self::SCOPE_COUNT_CACHE_TTL);
        }

        return $result;
    }

    /**
     * @param array<string,mixed> $scope
     */
    private function scopeCountCacheKey(array $scope): string
    {
        $id = (string) ($scope['id'] ?? '');
        $path = (string) ($scope['absolute_path'] ?? '');
        $probes = isset($scope['root_probes']) && is_array($scope['root_probes']) ? implode('|', array_map('strval', $scope['root_probes'])) : '';

        return 'shieldx_scope_count_' . md5($this->rootDir . '|' . $id . '|' . $path . '|' . $probes);
    }

    /**
     * @param array<int,array<string,mixed>> $scopes
     * @return array<string,mixed>
     */
    private function discoverFiles(int $maxFiles, array $scopes, string $fileTypeContext = FileTypeSettings::CONTEXT_MALWARE, bool $includeFileInfo = false): array
    {
        $files = [];
        $fileInfos = [];
        $truncated = false;

        foreach ($scopes as $scope) {
            if (!empty($scope['database'])) {
                continue;
            }

            if (!empty($scope['root_probes'])) {
                foreach ($this->rootProbeFiles() as $file) {
                    if (is_file($file) && $this->fileTypeAllowed($file, $fileTypeContext)) {
                        $files[$file] = $file;
                        if ($includeFileInfo) {
                            $fileInfos[$file] = new \SplFileInfo($file);
                        }
                    }

                    if (count($files) >= $maxFiles) {
                        $truncated = true;
                        break 2;
                    }
                }
            }

            $scanDir = (string) ($scope['absolute_path'] ?? '');

            if ($scanDir !== '' && is_dir($scanDir) && is_readable($scanDir)) {
                if (!empty($scope['root_only'])) {
                    foreach ($this->topLevelFiles($scanDir) as $path) {
                        if ($this->shouldSkipPath($path, $scope) || !$this->fileTypeAllowed($path, $fileTypeContext)) {
                            continue;
                        }

                        $files[$path] = $path;
                        if ($includeFileInfo) {
                            $fileInfos[$path] = new \SplFileInfo($path);
                        }
                        if (count($files) >= $maxFiles) {
                            $truncated = true;
                            break 2;
                        }
                    }

                    continue;
                }

                $directoryFlags = \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_FILEINFO;
                // Intentionally do not include FilesystemIterator::FOLLOW_SYMLINKS; scans must stay within the resolved WordPress root.
                $iterator = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($scanDir, $directoryFlags),
                    \RecursiveIteratorIterator::LEAVES_ONLY,
                    \RecursiveIteratorIterator::CATCH_GET_CHILD
                );

                foreach ($iterator as $item) {
                    if (!$item instanceof \SplFileInfo || !$item->isFile()) {
                        continue;
                    }

                    $path = $item->getPathname();
                    if ($this->shouldSkipPath($path, $scope) || !$this->fileTypeAllowed($path, $fileTypeContext)) {
                        continue;
                    }

                    $files[$path] = $path;
                    if ($includeFileInfo) {
                        $fileInfos[$path] = $item;
                    }
                    if (count($files) >= $maxFiles) {
                        $truncated = true;
                        break 2;
                    }

                }
            }
        }

        ksort($files);

        $discoveredFiles = array_values(array_slice($files, 0, $maxFiles));
        $result = [
            'files' => $discoveredFiles,
            'truncated' => $truncated || count($files) > $maxFiles,
        ];

        if ($includeFileInfo) {
            $result['file_infos'] = [];
            foreach ($discoveredFiles as $path) {
                if (isset($fileInfos[$path]) && $fileInfos[$path] instanceof \SplFileInfo) {
                    $result['file_infos'][] = $fileInfos[$path];
                }
            }
        }

        return $result;
    }

    private function fileTypeAllowed(string $path, string $context): bool
    {
        return $this->fileTypes->allowsPath($this->relativePath($path), $context);
    }

    /**
     * @return array<int,string>
     */
    private function rootProbeFiles(): array
    {
        $names = [
            '.htaccess',
            '.env',
            '.env.local',
            'debug.log',
            'index.php',
            'info.php',
            'license.txt',
            'phpinfo.php',
            'readme.html',
            'wp-config.php.bak',
            'wp-config.php.old',
            'wp-config.php.save',
            'wp-config.php.swp',
            'wp-config.php.txt',
            'wp-config-sample.php',
            'wp-config-docker.php',
            '.git/config',
        ];
        $files = [];

        foreach ($names as $name) {
            $files[] = $this->rootDir . '/' . $name;
        }

        return $files;
    }

    private function assertWordPressRootReady(): void
    {
        if ($this->rootDir === '' || !is_dir($this->rootDir) || !is_readable($this->rootDir)) {
            throw new \RuntimeException('WordPress root directory is unavailable for scanning.');
        }
    }

    /**
     * @return array<int,string>
     */
    private function topLevelFiles(string $directory): array
    {
        $files = [];
        $items = scandir($directory);

        if (!is_array($items)) {
            return $files;
        }

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }

            $path = rtrim($directory, '/\\') . '/' . $item;

            if (is_file($path)) {
                $files[] = $path;
            }
        }

        sort($files);

        return $files;
    }

    /**
     * @param array<string,mixed> $scope
     */
    private function shouldSkipPath(string $path, array $scope = []): bool
    {
        $relative = ScanRuleEngine::normalizePath($this->relativePath($path));
        $skipPrefixes = [
            '.shield-x/',
            '.' . 'shieldx/',
            'node_modules/',
            'vendor/',
            'wp-content/cache/',
            'wp-content/upgrade/',
            'wp-content/plugins/xerotact-shield-x-security/',
            'wp-content/plugins/xerotact-shield-x-extensions/',
        ];

        $storagePrefix = $this->managedStoragePrefix();
        if ($storagePrefix !== '') {
            $skipPrefixes[] = $storagePrefix;
        }

        foreach ($skipPrefixes as $prefix) {
            if ($this->relativePathHasPrefix($relative, $prefix)) {
                return true;
            }
        }

        $scopeExcludeSegments = isset($scope['exclude_segments']) && is_array($scope['exclude_segments']) ? $scope['exclude_segments'] : [];

        foreach ($scopeExcludeSegments as $segment) {
            if ($this->relativePathHasPrefix($relative, (string) $segment)) {
                return true;
            }
        }

        return false;
    }

    private function managedStoragePrefix(): string
    {
        if ($this->storage === null) {
            return '';
        }

        $root = rtrim(str_replace('\\', '/', $this->rootDir), '/');
        $storage = rtrim(str_replace('\\', '/', $this->storage->baseDir()), '/');
        if ($root === '' || $storage === '' || $storage === $root || strpos($storage, $root . '/') !== 0) {
            return '';
        }

        return ltrim(substr($storage, strlen($root) + 1), '/');
    }

    private function relativePathHasPrefix(string $path, string $prefix): bool
    {
        $path = trim(ScanRuleEngine::normalizePath($path), '/');
        $prefix = trim(ScanRuleEngine::normalizePath($prefix), '/');

        return $prefix !== '' && ($path === $prefix || strpos($path . '/', $prefix . '/') === 0);
    }

    /**
     * @return array<string,mixed>|null
     */
    private function fileMetadata(string $filePath): ?array
    {
        if (!is_file($filePath)) {
            return null;
        }

        $size = filesize($filePath);
        $modified = filemtime($filePath);
        if (!is_int($size) || !is_int($modified)) {
            return null;
        }
        $changed = filectime($filePath);
        if (!is_int($changed)) {
            $changed = $modified;
        }

        return $this->fileMetadataValues($filePath, $size, $modified, $changed);
    }

    /**
     * @return array<string,mixed>|null
     */
    private function fileMetadataFromInfo(\SplFileInfo $file): ?array
    {
        try {
            $filePath = $file->getPathname();
            $size = (int) $file->getSize();
            $modified = (int) $file->getMTime();
        } catch (\RuntimeException $exception) {
            unset($exception);

            return null;
        }
        try {
            $changed = (int) $file->getCTime();
        } catch (\RuntimeException $exception) {
            unset($exception);
            $changed = $modified;
        }

        return $this->fileMetadataValues($filePath, $size, $modified, $changed);
    }

    /**
     * @return array<string,mixed>
     */
    private function fileMetadataValues(string $filePath, int $size, int $modified, int $changed): array
    {
        $relative = $this->relativePath($filePath);
        $pluginSlug = '';
        $isLink = is_link($filePath);
        $resolved = realpath($filePath);
        $resolvedPath = is_string($resolved) ? $resolved : '';
        if (preg_match('~^wp-content/plugins/([^/]+)/~', $relative, $matches)) {
            $pluginSlug = (string) $matches[1];
        }

        return [
            'path' => $relative,
            'absolute_path' => $filePath,
            'size' => $size,
            'modified_at' => $modified > 0 ? gmdate('Y-m-d H:i:s', $modified) : current_time('mysql', true),
            'metadata_changed_at' => $changed > 0 ? gmdate('Y-m-d H:i:s', $changed) : ($modified > 0 ? gmdate('Y-m-d H:i:s', $modified) : current_time('mysql', true)),
            'plugin_slug' => $pluginSlug,
            'is_core' => strpos($relative, 'wp-admin/') === 0 || strpos($relative, 'wp-includes/') === 0,
            'is_symlink' => $isLink,
            'real_path' => $resolvedPath,
            'symlink_outside_root' => $isLink && !$this->resolvedPathInsideRoot($resolvedPath),
        ];
    }

    private function resolvedPathInsideRoot(string $resolvedPath): bool
    {
        if ($resolvedPath === '') {
            return false;
        }

        $root = realpath($this->rootDir);
        if (!is_string($root) || $root === '') {
            return false;
        }

        $root = rtrim(str_replace('\\', '/', $root), '/') . '/';
        $candidate = rtrim(str_replace('\\', '/', $resolvedPath), '/');

        return $candidate === rtrim($root, '/') || strpos($candidate . '/', $root) === 0;
    }

    /**
     * @param array<int,string|\SplFileInfo> $filePaths
     * @return array<int,array<string,mixed>>
     */
    private function fileMetadataList(array $filePaths): array
    {
        $metadata = [];

        foreach ($filePaths as $filePath) {
            $file = $filePath instanceof \SplFileInfo
                ? $this->fileMetadataFromInfo($filePath)
                : $this->fileMetadata((string) $filePath);
            if (is_array($file)) {
                $metadata[] = $file;
            }
        }

        return $metadata;
    }

    /**
     * @param array<int,array<string,mixed>> $metadataList
     * @return array<int,string>
     */
    private function metadataPaths(array $metadataList): array
    {
        $paths = [];

        foreach ($metadataList as $metadata) {
            $path = (string) ($metadata['path'] ?? '');
            if ($path !== '') {
                $paths[] = $path;
            }
        }

        return array_values(array_unique($paths));
    }

    private function metadataMatchesBaseline(string $knownModified, string $currentModified, int $knownSize, int $currentSize, string $knownChanged = '', string $currentChanged = ''): bool
    {
        if ($knownModified === '' || $knownModified !== $currentModified) {
            return false;
        }

        if ($knownSize >= 0 && $knownSize !== $currentSize) {
            return false;
        }

        return $knownChanged === '' || $currentChanged === '' || $knownChanged === $currentChanged;
    }

    /**
     * @param array<string,mixed> $metadata
     * @return array<string,mixed>|null
     */
    private function filePayload(array $metadata, string $hash): ?array
    {
        $filePath = (string) ($metadata['absolute_path'] ?? '');

        if ($filePath === '' || $hash === '') {
            return null;
        }

        $contents = '';
        $size = (int) ($metadata['size'] ?? 0);
        $contentRead = true;

        if ($size <= self::MAX_CONTENT_BYTES && $this->isTextCandidate($filePath)) {
            $bytesToRead = min($size, self::MAX_CONTENT_BYTES);
            $contentRead = $bytesToRead === 0;
            if ($bytesToRead > 0 && ($this->contentBytesRead + $bytesToRead) <= self::MAX_CONTENT_BYTES_PER_RUN) {
                $read = file_get_contents($filePath, false, null, 0, self::MAX_CONTENT_BYTES);
                $contents = is_string($read) ? $read : '';
                $this->contentBytesRead += strlen($contents);
                $contentRead = true;
            }
        }

        $metadata['contents'] = $contents;
        $metadata['hash'] = $hash;
        $metadata['content_read'] = $contentRead;

        return $metadata;
    }

    private function resetContentBudget(): void
    {
        $this->contentBytesRead = 0;
    }

    private function fileHash(string $filePath): string
    {
        $hash = is_file($filePath) ? hash_file('sha256', $filePath) : false;

        return is_string($hash) ? $hash : '';
    }

    private function isTextCandidate(string $filePath): bool
    {
        $basename = strtolower(basename($filePath));
        $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));

        if ($basename === '.env' || strpos($basename, '.env.') === 0 || in_array($basename, ['.htaccess', '.user.ini', 'web.config'], true)) {
            return true;
        }

        return (bool) preg_match('~^php[0-9]?$~', $extension)
            || in_array($extension, ['phtml', 'phar', 'inc', 'js', 'css', 'html', 'htm', 'txt', 'log', 'sql', 'xml', 'json', 'md', 'bak', 'old', 'orig', 'save', 'swp'], true);
    }

    /**
     * @param array<string,mixed> $file
     */
    private function isOfficialCoreMatch(array $file): bool
    {
        if (!$this->coreVerifier) {
            return false;
        }

        $path = (string) ($file['path'] ?? '');
        if ($path === '') {
            return false;
        }

        try {
            return $this->coreVerifier->isOfficialCoreMatch(
                $path,
                (string) ($file['hash'] ?? '')
            );
        } catch (\Throwable $error) {
            return false;
        }
    }

    /**
     * @param array<string,mixed> $file
     */
    private function isOfficialPluginMatch(array $file): bool
    {
        if (!$this->pluginVerifier) {
            return false;
        }

        $path = (string) ($file['path'] ?? '');
        if ($path === '') {
            return false;
        }

        try {
            return $this->pluginVerifier->isOfficialPluginMatch(
                $path,
                (string) ($file['hash'] ?? '')
            );
        } catch (\Throwable $error) {
            return false;
        }
    }

    /**
     * @param array<string,mixed> $file
     */
    private function resolveVerifiedCoreIssues(array $file, string $hash): bool
    {
        if ($hash === '') {
            return false;
        }

        $file['hash'] = $hash;
        if (!$this->isOfficialCoreMatch($file)) {
            return false;
        }

        $this->repository->resolveVerifiedCoreFileIssues((string) ($file['path'] ?? ''));

        return true;
    }

    /**
     * @param array<string,mixed> $file
     */
    private function resolveVerifiedPluginIssues(array $file, string $hash): bool
    {
        if ($hash === '') {
            return false;
        }

        $file['hash'] = $hash;
        if (!$this->isOfficialPluginMatch($file)) {
            return false;
        }

        $this->repository->resolveVerifiedCoreFileIssues((string) ($file['path'] ?? ''));

        return true;
    }

    private function relativePath(string $filePath): string
    {
        $normalized = ScanRuleEngine::normalizePath($filePath);
        $root = ScanRuleEngine::normalizePath($this->rootDir);

        if ($root !== '' && strpos($normalized, $root . '/') === 0) {
            return substr($normalized, strlen($root) + 1);
        }

        return $normalized;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function scopeDefinitions(): array
    {
        return [
            [
                'id' => 'root',
                'label' => 'Site root',
                'relative_path' => 'WordPress root files',
                'absolute_path' => $this->rootDir,
                'root_probes' => true,
                'root_only' => true,
                'description' => 'Top-level WordPress files such as index.php, wp-login.php, config files, readme, and license.',
            ],
            [
                'id' => 'core-admin',
                'label' => 'Core admin',
                'relative_path' => 'wp-admin',
                'absolute_path' => $this->rootDir . '/wp-admin',
                'root_probes' => false,
                'description' => 'WordPress admin core files.',
            ],
            [
                'id' => 'core-includes',
                'label' => 'Core includes',
                'relative_path' => 'wp-includes',
                'absolute_path' => $this->rootDir . '/wp-includes',
                'root_probes' => false,
                'description' => 'WordPress core libraries and assets.',
            ],
            [
                'id' => 'plugins',
                'label' => 'Plugins',
                'relative_path' => 'wp-content/plugins',
                'absolute_path' => $this->contentDir . '/plugins',
                'root_probes' => false,
                'description' => 'Installed plugin directories.',
            ],
            [
                'id' => 'themes',
                'label' => 'Themes',
                'relative_path' => 'wp-content/themes',
                'absolute_path' => $this->contentDir . '/themes',
                'root_probes' => false,
                'description' => 'Installed theme directories.',
            ],
            [
                'id' => 'uploads',
                'label' => 'Uploads',
                'relative_path' => 'wp-content/uploads',
                'absolute_path' => $this->contentDir . '/uploads',
                'root_probes' => false,
                'description' => 'Media uploads and generated files.',
            ],
            [
                'id' => 'mu-plugins',
                'label' => 'MU Plugins',
                'relative_path' => 'wp-content/mu-plugins',
                'absolute_path' => $this->contentDir . '/mu-plugins',
                'root_probes' => false,
                'description' => 'Must-use plugin files.',
            ],
            [
                'id' => 'content-other',
                'label' => 'Other wp-content',
                'relative_path' => 'wp-content other',
                'absolute_path' => $this->contentDir,
                'root_probes' => false,
                'exclude_segments' => [
                    '/wp-content/plugins/',
                    '/wp-content/themes/',
                    '/wp-content/uploads/',
                    '/wp-content/mu-plugins/',
                ],
                'description' => 'Languages, drop-ins, and other content not covered by plugin/theme/upload folders.',
            ],
            [
                'id' => 'database',
                'label' => 'Database',
                'relative_path' => 'wp_options, users, posts, widgets, cron',
                'absolute_path' => '',
                'database' => true,
                'root_probes' => false,
                'description' => 'Options, admin users, posts, widgets, cron hooks, redirects, scripts, and autoloaded values.',
            ],
        ];
    }

    /**
     * @param string|array<int,string>|null $scopes
     * @return array<int,array<string,mixed>>
     */
    private function scanScopes($scopes): array
    {
        if (is_string($scopes)) {
            if ($scopes === 'all' || $scopes === 'content') {
                $scopes = [];
            } else {
                $scopes = explode(',', $scopes);
            }
        } elseif (!is_array($scopes)) {
            $scopes = [];
        }

        $selected = $this->sanitizeScopes(array_map('strval', $scopes));
        $available = $this->availableScopes(false);
        $scanScopes = [];

        foreach ($available as $scope) {
            if (in_array((string) $scope['id'], $selected, true)) {
                $scanScopes[] = $scope;
            }
        }

        return $scanScopes ?: $available;
    }

    /**
     * @param array<int,array<string,mixed>> $scopes
     * @return array<int,string>
     */
    private function scopeIds(array $scopes): array
    {
        $ids = [];

        foreach ($scopes as $scope) {
            $ids[] = (string) ($scope['id'] ?? '');
        }

        return array_values(array_filter($ids));
    }

    /**
     * @param array<int,array<string,mixed>> $scopes
     * @return array<int,string>
     */
    private function scopeLabels(array $scopes): array
    {
        $labels = [];

        foreach ($scopes as $scope) {
            $labels[] = (string) ($scope['label'] ?? $scope['id'] ?? '');
        }

        return array_values(array_filter($labels));
    }

    /**
     * @param array<int,array<string,mixed>> $scopes
     * @return array<int,string>
     */
    private function scopePaths(array $scopes): array
    {
        $paths = [];

        foreach ($scopes as $scope) {
            $paths[] = (string) ($scope['relative_path'] ?? '');
        }

        return array_values(array_filter($paths));
    }

    private function sanitizeScopeId(string $scope): string
    {
        if (function_exists('sanitize_key')) {
            return sanitize_key($scope);
        }

        $scope = strtolower($scope);

        return preg_replace('~[^a-z0-9_-]+~', '', $scope) ?: '';
    }

    /**
     * @param array<int,array<string,mixed>> $scopes
     */
    private function databaseScopeSelected(array $scopes): bool
    {
        foreach ($scopes as $scope) {
            if (!empty($scope['database']) || (string) ($scope['id'] ?? '') === 'database') {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function scanDatabaseIfSelected(bool $selected): array
    {
        if (!$selected || !$this->databaseScanner) {
            return [
                'engine_version' => DatabaseScanner::ENGINE_VERSION,
                'available' => false,
                'items_scanned' => 0,
                'issues_found' => 0,
                'inspected_paths' => [],
                'active_threat_ids' => [],
                'issues' => [],
            ];
        }

        return $this->databaseScanner->scan();
    }

    /**
     * Build a smaller progressive queue before the modal opens. This keeps the
     * second scan from walking every unchanged file through the visible batch
     * progress, while still reporting skipped files in the final result.
     *
     * @param array<int,string|\SplFileInfo> $files
     * @return array<string,mixed>
     */
    private function prepareProgressiveFileQueue(array $files): array
    {
        $metadataList = $this->fileMetadataList($files);
        $paths = $this->metadataPaths($metadataList);
        $fileRecords = $this->repository->fileRecords($paths);
        if (empty($fileRecords) && !$this->repository->available()) {
            foreach ($paths as $path) {
                $record = $this->repository->fileRecord($path);
                if (is_array($record)) {
                    $fileRecords[$path] = $record;
                }
            }
        }

        $activeIssuePaths = $this->repository->activeScannerIssuePaths($paths);
        if (empty($activeIssuePaths) && !$this->repository->available()) {
            foreach ($paths as $path) {
                if ($this->repository->activeIssueCountForPath($path) > 0) {
                    $activeIssuePaths[] = $path;
                }
            }
        }

        $activeIssueLookup = array_fill_keys($activeIssuePaths, true);
        $queued = [];
        $skipped = 0;

        foreach ($metadataList as $file) {
            $path = (string) ($file['path'] ?? '');
            $absolutePath = (string) ($file['absolute_path'] ?? '');
            if ($path === '' || $absolutePath === '') {
                continue;
            }

            if (!empty($file['symlink_outside_root'])) {
                $queued[] = $absolutePath;
                continue;
            }

            $known = $fileRecords[$path] ?? null;
            $knownHash = is_array($known) ? (string) ($known['file_hash'] ?? '') : '';
            $knownModified = is_array($known) ? (string) ($known['last_modified'] ?? '') : '';
            $knownChanged = is_array($known) ? (string) ($known['metadata_changed'] ?? '') : '';
            $knownSize = is_array($known) ? (int) ($known['file_size'] ?? -1) : -1;
            $currentModified = (string) ($file['modified_at'] ?? '');
            $currentChanged = (string) ($file['metadata_changed_at'] ?? '');
            $currentSize = (int) ($file['size'] ?? 0);
            $preserveExistingBaseline = false;

            if (is_array($known) && $knownHash !== '' && !isset($activeIssueLookup[$path]) && $this->metadataMatchesBaseline($knownModified, $currentModified, $knownSize, $currentSize, $knownChanged, $currentChanged)) {
                $skipped++;
                continue;
            }

            $queued[] = $absolutePath;
        }

        return [
            'files' => $queued,
            'files_prechecked_skipped' => $skipped,
            'baseline_records_loaded' => count($fileRecords),
            'active_issue_paths_loaded' => count($activeIssuePaths),
        ];
    }

    /**
     * Populate a newly-created progressive scan job outside the initial admin
     * request. This keeps recursive traversal out of the modal-open path while
     * preserving the existing queue precheck and external-file-list behavior.
     *
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function initializeProgressiveJobFiles(array $job): array
    {
        $scopeDetails = isset($job['discovery_scopes']) && is_array($job['discovery_scopes']) ? $job['discovery_scopes'] : [];
        $maxFiles = max(1, (int) ($job['discovery_max_files'] ?? self::DEFAULT_MAX_FILES));
        $fileTypeContext = (string) ($job['discovery_file_type_context'] ?? FileTypeSettings::CONTEXT_MALWARE);
        if (!in_array($fileTypeContext, [FileTypeSettings::CONTEXT_MALWARE, FileTypeSettings::CONTEXT_BACKUP], true)) {
            $fileTypeContext = FileTypeSettings::CONTEXT_MALWARE;
        }

        $precheckQueue = !empty($job['discovery_precheck_queue']);
        $discovery = $this->discoverFiles($maxFiles, $scopeDetails, $fileTypeContext, $precheckQueue);
        $discoveredFiles = isset($discovery['files']) && is_array($discovery['files']) ? array_values(array_map('strval', $discovery['files'])) : [];
        $discoveredFileInfos = isset($discovery['file_infos']) && is_array($discovery['file_infos']) ? $discovery['file_infos'] : $discoveredFiles;
        $queue = $precheckQueue
            ? $this->prepareProgressiveFileQueue($discoveredFileInfos)
            : [
                'files' => $discoveredFiles,
                'files_prechecked_skipped' => 0,
                'baseline_records_loaded' => 0,
                'active_issue_paths_loaded' => 0,
            ];
        $files = isset($queue['files']) && is_array($queue['files']) ? array_values(array_map('strval', $queue['files'])) : [];
        $fileQueue = $this->prepareProgressiveJobFiles((string) ($job['scan_id'] ?? ''), $files);
        $aggregate = isset($job['aggregate']) && is_array($job['aggregate']) ? $job['aggregate'] : $this->emptyProgressiveAggregate();
        $aggregate['files_skipped'] = (int) ($queue['files_prechecked_skipped'] ?? 0);
        $aggregate['baseline_records_loaded'] = (int) ($queue['baseline_records_loaded'] ?? 0);
        $aggregate['active_issue_paths_loaded'] = (int) ($queue['active_issue_paths_loaded'] ?? 0);

        $job['truncated'] = !empty($discovery['truncated']);
        $job['files'] = $fileQueue['files'];
        $job['files_external'] = (bool) ($fileQueue['files_external'] ?? false);
        $job['files_durable'] = (bool) ($fileQueue['files_durable'] ?? false);
        $job['files_ref'] = (string) ($fileQueue['files_ref'] ?? '');
        $job['files_byte_offset'] = 0;
        $job['files_total'] = count($files);
        $job['files_discovered'] = count($discoveredFiles);
        $job['files_prechecked_skipped'] = (int) ($queue['files_prechecked_skipped'] ?? 0);
        $job['cursor'] = 0;
        $job['aggregate'] = $aggregate;
        $job['discovery_pending'] = false;
        unset(
            $job['discovery_scopes'],
            $job['discovery_file_type_context'],
            $job['discovery_max_files'],
            $job['discovery_precheck_queue']
        );

        return $job;
    }

    /**
     * @param array<int,string|\SplFileInfo> $files
     * @return array<string,mixed>
     */
    private function processFileBatch(array $files, string $scanId, string $scanStartedAt, ?float $deadline = null): array
    {
        $metadataList = $this->fileMetadataList($files);
        $paths = $this->metadataPaths($metadataList);
        $fileRecords = $this->repository->fileRecords($paths);
        if (empty($fileRecords) && !$this->repository->available()) {
            foreach ($paths as $path) {
                $record = $this->repository->fileRecord($path);
                if (is_array($record)) {
                    $fileRecords[$path] = $record;
                }
            }
        }

        $activeIssuePaths = $this->repository->activeScannerIssuePaths($paths);
        if (empty($activeIssuePaths) && !$this->repository->available()) {
            foreach ($paths as $path) {
                if ($this->repository->activeIssueCountForPath($path) > 0) {
                    $activeIssuePaths[] = $path;
                }
            }
        }

        $activeIssueLookup = array_fill_keys($activeIssuePaths, true);
        $issues = [];
        $activeThreatIds = [];
        $inspectedPaths = [];
        $filesScanned = 0;
        $filesSkipped = 0;
        $filesNew = 0;
        $filesChanged = 0;
        $filesHashChecked = 0;
        $filesHashUnchanged = 0;
        $shadowCopiesCreated = 0;
        $shadowChangesPending = 0;
        $shadowChangedItems = [];
        $timeLimited = false;

        $shadowIndexBatch = $this->beginShadowIndexBatch();
        try {
        foreach ($metadataList as $file) {
            if ($deadline !== null && microtime(true) >= $deadline) {
                $timeLimited = true;
                break;
            }

            $path = (string) ($file['path'] ?? '');
            $absolutePath = (string) ($file['absolute_path'] ?? '');
            if ($path === '' || $absolutePath === '') {
                continue;
            }

            if (!empty($file['symlink_outside_root'])) {
                $issue = $this->symlinkOutsideRootIssue($file);
                $issues[] = $issue;
                $activeThreatIds[] = (string) ($issue['threat_id'] ?? '');
                $this->repository->upsertIssue($issue);
                $inspectedPaths[] = $path;
                $filesScanned++;
                continue;
            }

            $known = $fileRecords[$path] ?? null;
            $knownHash = is_array($known) ? (string) ($known['file_hash'] ?? '') : '';
            $knownModified = is_array($known) ? (string) ($known['last_modified'] ?? '') : '';
            $knownChanged = is_array($known) ? (string) ($known['metadata_changed'] ?? '') : '';
            $knownSize = is_array($known) ? (int) ($known['file_size'] ?? -1) : -1;
            $hasActiveIssue = isset($activeIssueLookup[$path]);
            $currentModified = (string) ($file['modified_at'] ?? '');
            $currentChanged = (string) ($file['metadata_changed_at'] ?? '');
            $currentSize = (int) ($file['size'] ?? 0);
            $preserveExistingBaseline = false;

            if (is_array($known) && $knownHash !== '' && !$hasActiveIssue && $this->metadataMatchesBaseline($knownModified, $currentModified, $knownSize, $currentSize, $knownChanged, $currentChanged)) {
                $filesSkipped++;
                continue;
            }

            $hash = $this->fileHash($absolutePath);
            if ($hash === '') {
                $filesSkipped++;
                continue;
            }

            if (is_array($known) && $knownHash !== '') {
                $filesHashChecked++;

                if ($hash === $knownHash) {
                    $filesHashUnchanged++;
                    $file['hash'] = $hash;

                    if ($this->resolveVerifiedCoreIssues($file, $hash) || $this->resolveVerifiedPluginIssues($file, $hash)) {
                        $inspectedPaths[] = $path;
                        $filesSkipped++;
                        continue;
                    }

                    if (!$hasActiveIssue) {
                        $filesSkipped++;
                        continue;
                    }
                } else {
                    $file['hash'] = $hash;
                    if ($this->isOfficialCoreMatch($file)) {
                        $file['official_core_verified'] = true;
                        $this->repository->recordFileBaseline($file);
                        $this->repository->resolveVerifiedCoreFileIssues($path);
                        $inspectedPaths[] = $path;
                        $filesScanned++;
                        continue;
                    }

                    if ($this->isOfficialPluginMatch($file)) {
                        $file['official_plugin_verified'] = true;
                        $this->repository->recordFileBaseline($file);
                        $this->repository->resolveVerifiedCoreFileIssues($path);
                        $inspectedPaths[] = $path;
                        $filesScanned++;
                        continue;
                    }

                    $filesChanged++;
                    $preserveExistingBaseline = true;
                    $integrityIssue = $this->fileModifiedIssue($file, $knownHash);
                    $issues[] = $integrityIssue;
                    $activeThreatIds[] = (string) ($integrityIssue['threat_id'] ?? '');
                    $this->repository->upsertIssue($integrityIssue);

                    $shadowChange = $this->markShadowChange($file, $knownHash, $scanId, $scanStartedAt);
                    if (is_array($shadowChange)) {
                        $shadowChangedItems[] = $shadowChange;
                        $shadowChangesPending++;
                    }
                }
            } else {
                $filesNew++;
            }

            $payload = $this->filePayload($file, $hash);
            if (!is_array($payload)) {
                $filesSkipped++;
                continue;
            }

            if (array_key_exists('content_read', $payload) && empty($payload['content_read'])) {
                $filesSkipped++;
                continue;
            }

            if ($this->isOfficialCoreMatch($payload)) {
                $payload['official_core_verified'] = true;
                $this->repository->resolveVerifiedCoreFileIssues($path);
            } elseif ($this->isOfficialPluginMatch($payload)) {
                $payload['official_plugin_verified'] = true;
                $this->repository->resolveVerifiedCoreFileIssues($path);
            }

            foreach ($this->engine->evaluateFile($payload) as $issue) {
                $issue['content_hash'] = $hash;
                $issues[] = $issue;
                $activeThreatIds[] = (string) ($issue['threat_id'] ?? '');
                $this->repository->upsertIssue($issue);
            }

            if (!$preserveExistingBaseline) {
                $this->repository->recordFileBaseline($payload);
            }
            if (!$preserveExistingBaseline && $this->ensureTrustedShadow($path, $absolutePath, $hash, $payload)) {
                $shadowCopiesCreated++;
            }

            $inspectedPaths[] = $path;
            $filesScanned++;
        }
        } finally {
            $this->commitShadowIndexBatch($shadowIndexBatch);
        }

        return [
            'active_threat_ids' => array_values(array_unique(array_filter($activeThreatIds))),
            'inspected_paths' => array_values(array_unique($inspectedPaths)),
            'issues' => $issues,
            'files_scanned' => $filesScanned,
            'files_skipped' => $filesSkipped,
            'files_new' => $filesNew,
            'files_changed' => $filesChanged,
            'files_hash_checked' => $filesHashChecked,
            'files_hash_unchanged' => $filesHashUnchanged,
            'shadow_copies_created' => $shadowCopiesCreated,
            'shadow_changes_pending' => $shadowChangesPending,
            'shadow_changed_items' => $shadowChangedItems,
            'baseline_records_loaded' => count($fileRecords),
            'active_issue_paths_loaded' => count($activeIssuePaths),
            'time_limited' => $timeLimited,
        ];
    }

    /**
     * Shadow-copy scans only need metadata, hashes for changed candidates, and
     * shadow index updates. Keeping rule evaluation out of this pass makes
     * large backup-page shadow scans much cheaper on shared hosting.
     *
     * @param array<int,string|\SplFileInfo> $files
     * @return array<string,mixed>
     */
    private function processShadowFileBatch(array $files, string $scanId, string $scanStartedAt): array
    {
        $metadataList = $this->fileMetadataList($files);
        $paths = $this->metadataPaths($metadataList);
        $fileRecords = $this->repository->fileRecords($paths);
        if (empty($fileRecords) && !$this->repository->available()) {
            foreach ($paths as $path) {
                $record = $this->repository->fileRecord($path);
                if (is_array($record)) {
                    $fileRecords[$path] = $record;
                }
            }
        }

        $filesScanned = 0;
        $filesSkipped = 0;
        $filesNew = 0;
        $filesChanged = 0;
        $filesHashChecked = 0;
        $filesHashUnchanged = 0;
        $shadowCopiesCreated = 0;
        $shadowChangesPending = 0;
        $shadowChangedItems = [];

        $shadowIndexBatch = $this->beginShadowIndexBatch();
        try {
        foreach ($metadataList as $file) {
            $path = (string) ($file['path'] ?? '');
            $absolutePath = (string) ($file['absolute_path'] ?? '');
            if ($path === '' || $absolutePath === '') {
                continue;
            }

            if (!empty($file['symlink_outside_root'])) {
                $filesSkipped++;
                continue;
            }

            $known = $fileRecords[$path] ?? null;
            $knownHash = is_array($known) ? (string) ($known['file_hash'] ?? '') : '';
            $knownModified = is_array($known) ? (string) ($known['last_modified'] ?? '') : '';
            $knownChanged = is_array($known) ? (string) ($known['metadata_changed'] ?? '') : '';
            $knownSize = is_array($known) ? (int) ($known['file_size'] ?? -1) : -1;
            $currentModified = (string) ($file['modified_at'] ?? '');
            $currentChanged = (string) ($file['metadata_changed_at'] ?? '');
            $currentSize = (int) ($file['size'] ?? 0);

            if (is_array($known) && $knownHash !== '' && $this->metadataMatchesBaseline($knownModified, $currentModified, $knownSize, $currentSize, $knownChanged, $currentChanged)) {
                $filesSkipped++;
                continue;
            }

            $hash = $this->fileHash($absolutePath);
            if ($hash === '') {
                $filesSkipped++;
                continue;
            }

            $file['hash'] = $hash;

            if (is_array($known) && $knownHash !== '') {
                $filesHashChecked++;

                if ($hash === $knownHash) {
                    $filesHashUnchanged++;
                    $this->repository->recordFileBaseline($file);
                    $filesSkipped++;
                    continue;
                }

                $filesChanged++;
                $shadowChange = $this->markShadowChange($file, $knownHash, $scanId, $scanStartedAt);
                if (is_array($shadowChange)) {
                    $shadowChangedItems[] = $shadowChange;
                    $shadowChangesPending++;
                }
            } else {
                $filesNew++;
                if ($this->ensureTrustedShadow($path, $absolutePath, $hash, $file)) {
                    $shadowCopiesCreated++;
                }
            }

            $this->repository->recordFileBaseline($file);
            $filesScanned++;
        }
        } finally {
            $this->commitShadowIndexBatch($shadowIndexBatch);
        }

        return [
            'active_threat_ids' => [],
            'inspected_paths' => [],
            'issues' => [],
            'files_scanned' => $filesScanned,
            'files_skipped' => $filesSkipped,
            'files_new' => $filesNew,
            'files_changed' => $filesChanged,
            'files_hash_checked' => $filesHashChecked,
            'files_hash_unchanged' => $filesHashUnchanged,
            'shadow_copies_created' => $shadowCopiesCreated,
            'shadow_changes_pending' => $shadowChangesPending,
            'shadow_changed_items' => $shadowChangedItems,
            'baseline_records_loaded' => count($fileRecords),
            'active_issue_paths_loaded' => 0,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function emptyProgressiveAggregate(): array
    {
        return [
            'active_threat_ids' => [],
            'inspected_paths' => [],
            'issues' => [],
            'issues_found' => 0,
            'files_scanned' => 0,
            'files_skipped' => 0,
            'files_new' => 0,
            'files_changed' => 0,
            'files_hash_checked' => 0,
            'files_hash_unchanged' => 0,
            'shadow_copies_created' => 0,
            'shadow_changes_pending' => 0,
            'shadow_changed_items' => [],
            'baseline_records_loaded' => 0,
            'active_issue_paths_loaded' => 0,
            'database_available' => false,
            'database_engine_version' => DatabaseScanner::ENGINE_VERSION,
            'database_items_scanned' => 0,
            'database_issues_found' => 0,
        ];
    }

    /**
     * @param array<string,mixed> $aggregate
     * @param array<string,mixed> $batch
     * @return array<string,mixed>
     */
    private function mergeProgressiveAggregate(array $aggregate, array $batch): array
    {
        foreach (['active_threat_ids', 'inspected_paths'] as $key) {
            $left = isset($aggregate[$key]) && is_array($aggregate[$key]) ? $aggregate[$key] : [];
            $right = isset($batch[$key]) && is_array($batch[$key]) ? $batch[$key] : [];
            $aggregate[$key] = array_values(array_unique(array_merge(array_map('strval', $left), array_map('strval', $right))));
        }

        $batchIssues = isset($batch['issues']) && is_array($batch['issues']) ? $batch['issues'] : [];
        $aggregate['issues_found'] = (int) ($aggregate['issues_found'] ?? 0) + count($batchIssues);
        $aggregate['issues'] = array_slice(array_merge(
            isset($aggregate['issues']) && is_array($aggregate['issues']) ? $aggregate['issues'] : [],
            $batchIssues
        ), 0, self::PROGRESSIVE_STORED_ISSUES);

        $batchShadowItems = isset($batch['shadow_changed_items']) && is_array($batch['shadow_changed_items']) ? $batch['shadow_changed_items'] : [];
        $aggregate['shadow_changed_items'] = array_slice(array_merge(
            isset($aggregate['shadow_changed_items']) && is_array($aggregate['shadow_changed_items']) ? $aggregate['shadow_changed_items'] : [],
            $batchShadowItems
        ), 0, self::PROGRESSIVE_STORED_SHADOW_ITEMS);

        foreach (['files_scanned', 'files_skipped', 'files_new', 'files_changed', 'files_hash_checked', 'files_hash_unchanged', 'shadow_copies_created', 'shadow_changes_pending', 'baseline_records_loaded', 'active_issue_paths_loaded'] as $key) {
            $aggregate[$key] = (int) ($aggregate[$key] ?? 0) + (int) ($batch[$key] ?? 0);
        }

        return $aggregate;
    }

    /**
     * @param array<string,mixed> $aggregate
     * @param array<string,mixed> $databaseResult
     * @return array<string,mixed>
     */
    private function mergeProgressiveDatabaseResult(array $aggregate, array $databaseResult): array
    {
        $databaseIssues = isset($databaseResult['issues']) && is_array($databaseResult['issues']) ? $databaseResult['issues'] : [];
        foreach ($databaseIssues as $issue) {
            if (!is_array($issue)) {
                continue;
            }

            $aggregate['issues'][] = $issue;
            $aggregate['issues'] = array_slice($aggregate['issues'], 0, self::PROGRESSIVE_STORED_ISSUES);
            $aggregate['issues_found'] = (int) ($aggregate['issues_found'] ?? 0) + 1;
            $aggregate['active_threat_ids'][] = (string) ($issue['threat_id'] ?? '');
            $this->repository->upsertIssue($issue);
        }

        $inspectedPaths = isset($databaseResult['inspected_paths']) && is_array($databaseResult['inspected_paths']) ? array_map('strval', $databaseResult['inspected_paths']) : [];
        $aggregate['inspected_paths'] = array_values(array_unique(array_merge(
            isset($aggregate['inspected_paths']) && is_array($aggregate['inspected_paths']) ? array_map('strval', $aggregate['inspected_paths']) : [],
            $inspectedPaths
        )));
        $activeThreatIds = isset($aggregate['active_threat_ids']) && is_array($aggregate['active_threat_ids']) ? $aggregate['active_threat_ids'] : [];
        $aggregate['active_threat_ids'] = array_values(array_unique(array_filter(array_map('strval', $activeThreatIds))));
        $aggregate['database_available'] = !empty($databaseResult['available']);
        $aggregate['database_engine_version'] = (string) ($databaseResult['engine_version'] ?? DatabaseScanner::ENGINE_VERSION);
        $aggregate['database_items_scanned'] = (int) ($databaseResult['items_scanned'] ?? 0);
        $aggregate['database_issues_found'] = (int) ($databaseResult['issues_found'] ?? 0);

        return $aggregate;
    }

    /**
     * @param array<string,mixed> $aggregate
     * @param array<string,mixed> $databaseBatch
     * @return array<string,mixed>
     */
    private function mergeProgressiveDeepDatabaseBatch(array $aggregate, array $databaseBatch): array
    {
        $databaseIssues = isset($databaseBatch['issues']) && is_array($databaseBatch['issues']) ? $databaseBatch['issues'] : [];
        foreach ($databaseIssues as $issue) {
            if (!is_array($issue)) {
                continue;
            }

            $aggregate['issues'][] = $issue;
            $aggregate['issues'] = array_slice($aggregate['issues'], 0, self::PROGRESSIVE_STORED_ISSUES);
            $aggregate['issues_found'] = (int) ($aggregate['issues_found'] ?? 0) + 1;
            $aggregate['active_threat_ids'][] = (string) ($issue['threat_id'] ?? '');
            $this->repository->upsertIssue($issue);
        }

        $activeThreatIds = isset($aggregate['active_threat_ids']) && is_array($aggregate['active_threat_ids']) ? $aggregate['active_threat_ids'] : [];
        $aggregate['active_threat_ids'] = array_values(array_unique(array_filter(array_map('strval', $activeThreatIds))));
        $aggregate['database_available'] = !empty($databaseBatch['available']) || !empty($aggregate['database_available']);
        $aggregate['database_engine_version'] = (string) ($databaseBatch['engine_version'] ?? DatabaseScanner::DEEP_ENGINE_VERSION);
        $aggregate['database_items_scanned'] = (int) ($aggregate['database_items_scanned'] ?? 0) + (int) ($databaseBatch['items_scanned'] ?? 0);
        $aggregate['database_issues_found'] = (int) ($aggregate['database_issues_found'] ?? 0) + (int) ($databaseBatch['issues_found'] ?? 0);

        return $aggregate;
    }

    /**
     * @return array<int,string>
     */
    private function databasePhasePrefixes(string $phase): array
    {
        if ($phase === DatabaseScanner::DEEP_PHASE_OPTIONS) {
            return ['db:options:', 'db:cron:'];
        }
        if ($phase === DatabaseScanner::DEEP_PHASE_POSTS) {
            return ['db:posts:'];
        }
        if ($phase === DatabaseScanner::DEEP_PHASE_POSTMETA) {
            return ['db:postmeta:'];
        }
        if ($phase === DatabaseScanner::DEEP_PHASE_COMMENTS) {
            return ['db:comments:'];
        }
        if ($phase === DatabaseScanner::DEEP_PHASE_ADMINISTRATORS) {
            return ['db:users:'];
        }

        return [];
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function completeProgressiveScan(array $job): array
    {
        $aggregate = isset($job['aggregate']) && is_array($job['aggregate']) ? $job['aggregate'] : $this->emptyProgressiveAggregate();
        $issues = isset($aggregate['issues']) && is_array($aggregate['issues']) ? $aggregate['issues'] : [];
        $issuesFound = (int) ($aggregate['issues_found'] ?? count($issues));
        $activeThreatIds = isset($aggregate['active_threat_ids']) && is_array($aggregate['active_threat_ids']) ? array_values(array_unique(array_filter(array_map('strval', $aggregate['active_threat_ids'])))) : [];
        $inspectedPaths = isset($aggregate['inspected_paths']) && is_array($aggregate['inspected_paths']) ? array_values(array_unique(array_filter(array_map('strval', $aggregate['inspected_paths'])))) : [];

        $this->repository->resolveScannerIssuesForInspectedPaths($activeThreatIds, $inspectedPaths);
        $this->repository->resolveScannerIssuesForInspectedPaths([], $this->engine->retiredFalsePositivePaths());

        $filesTotal = (int) ($job['files_discovered'] ?? $job['files_total'] ?? 0);
        $filesScanned = (int) ($aggregate['files_scanned'] ?? 0);
        $durationMs = (int) round((microtime(true) - (float) ($job['started_at'] ?? microtime(true))) * 1000);

        $result = [
            'scan_id' => (string) ($job['scan_id'] ?? ''),
            'status' => 'completed',
            'engine_version' => ScanRuleEngine::ENGINE_VERSION,
            'smart_mode' => 'mtime_hash_incremental',
            'scan_mode' => (string) ($job['scan_mode'] ?? 'scan'),
            'progressive' => true,
            'batches' => (int) ($job['batches'] ?? 0),
            'scope' => (string) ($job['scope'] ?? ''),
            'scope_label' => (string) ($job['scope_label'] ?? ''),
            'scope_path' => (string) ($job['scope_path'] ?? ''),
            'scope_count' => (int) ($job['scope_count'] ?? 0),
            'truncated' => !empty($job['truncated']),
            'files_total' => $filesTotal,
            'files_scanned' => $filesScanned,
            'files_skipped' => (int) ($aggregate['files_skipped'] ?? 0),
            'files_new' => (int) ($aggregate['files_new'] ?? 0),
            'files_changed' => (int) ($aggregate['files_changed'] ?? 0),
            'files_hash_checked' => (int) ($aggregate['files_hash_checked'] ?? 0),
            'files_hash_unchanged' => (int) ($aggregate['files_hash_unchanged'] ?? 0),
            'baseline_records_loaded' => (int) ($aggregate['baseline_records_loaded'] ?? 0),
            'active_issue_paths_loaded' => (int) ($aggregate['active_issue_paths_loaded'] ?? 0),
            'shadow_copies_created' => (int) ($aggregate['shadow_copies_created'] ?? 0),
            'shadow_changes_pending' => (int) ($aggregate['shadow_changes_pending'] ?? 0),
            'shadow_changed_items' => $this->shadowChangedItemSummaries(isset($aggregate['shadow_changed_items']) && is_array($aggregate['shadow_changed_items']) ? $aggregate['shadow_changed_items'] : []),
            'database_selected' => !empty($job['database_selected']),
            'database_depth' => (string) ($job['database_depth'] ?? 'quick'),
            'database_available' => !empty($aggregate['database_available']),
            'database_engine_version' => (string) ($aggregate['database_engine_version'] ?? DatabaseScanner::ENGINE_VERSION),
            'database_items_scanned' => (int) ($aggregate['database_items_scanned'] ?? 0),
            'database_issues_found' => (int) ($aggregate['database_issues_found'] ?? 0),
            'database_phase_totals' => isset($job['database_state']['phase_totals']) && is_array($job['database_state']['phase_totals'])
                ? $job['database_state']['phase_totals']
                : [],
            'issues_found' => $issuesFound,
            'duration_ms' => $durationMs,
            'issues' => $issues,
        ];

        $this->repository->finishScan((string) ($job['scan_id'] ?? ''), 'completed', $filesTotal, $filesScanned, $issuesFound, $result);

        return $result;
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function publicProgressiveState(array $job): array
    {
        $filesTotal = (int) ($job['files_total'] ?? 0);
        $cursor = min($filesTotal, max(0, (int) ($job['cursor'] ?? 0)));
        $status = (string) ($job['status'] ?? 'running');
        $aggregate = isset($job['aggregate']) && is_array($job['aggregate']) ? $job['aggregate'] : [];
        $percent = $filesTotal > 0 ? (int) floor(($cursor / max(1, $filesTotal)) * 100) : 100;
        if (!empty($job['discovery_pending'])) {
            $percent = 0;
        } elseif ($status === 'completed') {
            $percent = 100;
        } elseif ($percent >= 100) {
            $percent = 99;
        }
        $deepDatabaseRunning = (string) ($job['database_depth'] ?? 'quick') === 'deep'
            && empty($job['database_done'])
            && isset($job['database_state'])
            && is_array($job['database_state']);
        if ($deepDatabaseRunning && $cursor >= $filesTotal) {
            $databasePhaseIndex = max(0, min(5, (int) ($job['database_state']['phase_index'] ?? 0)));
            $percent = min(99, (int) floor(($databasePhaseIndex / 5) * 100));
        }

        $state = [
            'scan_id' => (string) ($job['scan_id'] ?? ''),
            'status' => $status,
            'percent' => $percent,
            'files_total' => $filesTotal,
            'files_processed' => $cursor,
            'files_discovered' => (int) ($job['files_discovered'] ?? $filesTotal),
            'files_prechecked_skipped' => (int) ($job['files_prechecked_skipped'] ?? 0),
            'files_scanned' => (int) ($aggregate['files_scanned'] ?? 0),
            'files_skipped' => (int) ($aggregate['files_skipped'] ?? 0),
            'files_hash_checked' => (int) ($aggregate['files_hash_checked'] ?? 0),
            'issues_found' => (int) ($aggregate['issues_found'] ?? 0),
            'discovery_pending' => !empty($job['discovery_pending']),
            'batches' => (int) ($job['batches'] ?? 0),
            'scope' => (string) ($job['scope'] ?? ''),
            'scope_label' => (string) ($job['scope_label'] ?? ''),
            'scan_mode' => (string) ($job['scan_mode'] ?? 'scan'),
            'redirect_page' => (string) ($job['redirect_page'] ?? 'shield-x-scan'),
            'database_depth' => (string) ($job['database_depth'] ?? 'quick'),
            'database_status' => isset($job['database_state']['status']) ? (string) $job['database_state']['status'] : (!empty($job['database_done']) ? 'completed' : 'pending'),
            'database_phase' => isset($job['database_state']['phase']) ? (string) $job['database_state']['phase'] : '',
            'database_items_scanned' => (int) ($aggregate['database_items_scanned'] ?? 0),
            'error' => (string) ($job['error'] ?? ''),
        ];
        if ($deepDatabaseRunning) {
            $state['indeterminate'] = true;
            $state['stage_label'] = sprintf(
                /* translators: %s: current Deep database scan phase. */
                __('Deep database scan: %s', 'xerotact-shield-x-security'),
                ucwords(str_replace('_', ' ', (string) ($job['database_state']['phase'] ?? 'database')))
            );
            $state['files_metric_label'] = __('Database records', 'xerotact-shield-x-security');
            $state['files_label'] = sprintf(
                /* translators: %d: database records checked. */
                __('%d records checked', 'xerotact-shield-x-security'),
                (int) ($aggregate['database_items_scanned'] ?? 0)
            );
        }

        if (isset($job['result']) && is_array($job['result'])) {
            $state['result'] = $job['result'];
            $state['shadow_changed_items'] = isset($job['result']['shadow_changed_items']) && is_array($job['result']['shadow_changed_items']) ? $job['result']['shadow_changed_items'] : [];
        }

        return $state;
    }

    /**
     * @param array<int,string> $files
     * @return array{files:array<int,string>,files_external:bool,files_durable:bool,files_ref:string}
     */
    private function prepareProgressiveJobFiles(string $scanId, array $files): array
    {
        $files = array_values(array_map('strval', $files));
        if (count($files) <= self::PROGRESSIVE_EXTERNAL_FILE_LIST_THRESHOLD) {
            return [
                'files' => $files,
                'files_external' => false,
                'files_durable' => false,
                'files_ref' => '',
            ];
        }

        if ($this->durableJobs->save('scan_queue', $scanId, ['files' => $files], 86400)) {
            return [
                'files' => [],
                'files_external' => false,
                'files_durable' => true,
                'files_ref' => '',
            ];
        }

        $path = $this->scanJobFileListPath($scanId);
        $temporary = tempnam(dirname($path), 'scan-files-');
        if (!is_string($temporary)) {
            throw new \RuntimeException('Scan file list temporary file could not be created.');
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Internal private scan queue file; large queues stay out of repeatedly rewritten job JSON.
        $handle = fopen($temporary, 'wb');
        if (!is_resource($handle)) {
            throw new \RuntimeException('Scan file list could not be opened.');
        }

        try {
            foreach ($files as $file) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Internal private scan queue file.
                fwrite($handle, base64_encode($file) . "\n");
            }
        } finally {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Internal private scan queue file.
            fclose($handle);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Defense-in-depth for private scan queue metadata.
        chmod($temporary, 0600);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replacement inside SHIELD-X private storage.
        rename($temporary, $path);

        return [
            'files' => [],
            'files_external' => true,
            'files_durable' => false,
            'files_ref' => basename($path),
        ];
    }

    /**
     * @return array<int,string>
     */
    private function readDurableProgressiveJobFiles(string $scanId): array
    {
        $queue = $this->durableJobs->read('scan_queue', $scanId);
        $files = is_array($queue) && isset($queue['files']) && is_array($queue['files'])
            ? array_values(array_filter(array_map('strval', $queue['files'])))
            : [];
        if (empty($files)) {
            throw new \RuntimeException('Scan file list was not found.');
        }

        return $files;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function openProgressiveJobFileList(array $job): ?\SplFileObject
    {
        if (empty($job['files_external'])) {
            return null;
        }

        $path = $this->scanJobFileListPath((string) ($job['scan_id'] ?? ''));
        if (!is_file($path)) {
            throw new \RuntimeException('Scan file list was not found.');
        }

        $file = new \SplFileObject($path, 'rb');
        $file->setMaxLineLen(self::PROGRESSIVE_FILE_LIST_LINE_BYTES);
        $byteOffset = max(0, (int) ($job['files_byte_offset'] ?? 0));
        if ($byteOffset > 0) {
            $file->fseek($byteOffset);
        }

        return $file;
    }

    /**
     * @return array<int,string>
     */
    private function readProgressiveJobFileChunk(\SplFileObject $file, int $limit): array
    {
        $files = [];
        while (!$file->eof() && count($files) < $limit) {
            $line = trim((string) $file->fgets());
            if ($line === '') {
                continue;
            }

            $decoded = base64_decode($line, true);
            if (!is_string($decoded) || $decoded === '') {
                continue;
            }

            $files[] = $decoded;
        }

        return $files;
    }

    /**
     * Shadow scans are metadata-heavy and usually skip unchanged files. They
     * can safely use larger chunks than malware/content scans.
     *
     * @param array<string,mixed> $job
     */
    private function progressiveShadowMode(array $job): bool
    {
        return (string) ($job['scan_mode'] ?? '') === 'shadow'
            || ((string) ($job['redirect_page'] ?? '') === 'shield-x-backup' && empty($job['database_selected']));
    }

    private function scanJobDirectory(): string
    {
        $storage = $this->storage ?: FileStorage::fromWordPress();
        $storage->ensure();
        $directory = $storage->baseDir() . '/jobs/scans';
        if (!is_dir($directory)) {
            if (function_exists('wp_mkdir_p')) {
                wp_mkdir_p($directory);
            } else {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Unit tests run without WordPress; production uses wp_mkdir_p() above.
                mkdir($directory, 0755, true);
            }
        }

        return $directory;
    }

    private function scanJobPath(string $scanId): string
    {
        if (preg_match('/^[a-f0-9]{32}$/', $scanId) !== 1) {
            throw new \RuntimeException('Invalid scan job id.');
        }

        return $this->scanJobDirectory() . '/' . strtolower($scanId) . '.json';
    }

    private function scanJobFileListPath(string $scanId): string
    {
        if (preg_match('/^[a-f0-9]{32}$/', $scanId) !== 1) {
            throw new \RuntimeException('Invalid scan job id.');
        }

        return $this->scanJobDirectory() . '/' . strtolower($scanId) . '.files';
    }

    /**
     * @return array<string,mixed>|null
     */
    private function readScanJob(string $scanId): ?array
    {
        $durable = $this->durableJobs->read('scan', $scanId);
        if (is_array($durable)) {
            return $durable;
        }

        $path = $this->scanJobPath($scanId);
        if (!is_file($path)) {
            return null;
        }

        $raw = file_get_contents($path);
        $decoded = is_string($raw) ? \ShieldX\Support\Json::decodeArray($raw) : null;

        return is_array($decoded) ? $decoded : null;
    }

    private function deleteScanJob(string $scanId): void
    {
        $this->durableJobs->delete('scan', $scanId);
        $this->durableJobs->delete('scan_queue', $scanId);
        try {
            $paths = [
                $this->scanJobPath($scanId),
                $this->scanJobFileListPath($scanId),
            ];
        } catch (\Throwable $error) {
            return;
        }

        foreach ($paths as $path) {
            $this->deleteScanJobFile($path);
        }
    }

    private function deleteScanJobFile(string $path): void
    {
        if (!is_file($path)) {
            return;
        }
        if (function_exists('wp_delete_file')) {
            wp_delete_file($path);
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Unit tests can run without WordPress; production uses wp_delete_file() above.
        unlink($path);
    }

    private function reapAbandonedProgressiveJobs(string $activeScanId = ''): void
    {
        $cutoff = time() - self::PROGRESSIVE_JOB_TIMEOUT_SECONDS;
        $directory = $this->scanJobDirectory();
        $jobFiles = glob($directory . '/*.json');
        if (is_array($jobFiles)) {
            foreach ($jobFiles as $file) {
                $scanId = strtolower((string) preg_replace('/\.json$/', '', basename((string) $file)));
                if ($scanId === '' || $scanId === strtolower($activeScanId)) {
                    continue;
                }

                $modified = @filemtime((string) $file);
                if (!is_int($modified) || $modified > $cutoff) {
                    continue;
                }

                $job = $this->readScanJob($scanId);
                if (!is_array($job) || (string) ($job['status'] ?? 'running') !== 'running') {
                    $this->deleteScanJob($scanId);
                    continue;
                }

                $aggregate = isset($job['aggregate']) && is_array($job['aggregate']) ? $job['aggregate'] : [];
                $aggregateIssues = isset($aggregate['issues']) && is_array($aggregate['issues']) ? $aggregate['issues'] : [];
                $issuesFound = (int) ($aggregate['issues_found'] ?? count($aggregateIssues));
                $filesTotal = (int) ($job['files_discovered'] ?? $job['files_total'] ?? 0);
                $filesScanned = (int) ($aggregate['files_scanned'] ?? 0);
                $this->repository->finishScan($scanId, 'failed', $filesTotal, $filesScanned, $issuesFound, [
                    'scan_id' => $scanId,
                    'status' => 'failed',
                    'error' => 'Progressive scan job expired before completion.',
                    'files_total' => $filesTotal,
                    'files_scanned' => $filesScanned,
                    'issues_found' => $issuesFound,
                ]);
                $this->deleteScanJob($scanId);
            }
        }

        $queueFiles = glob($directory . '/*.files');
        if (is_array($queueFiles)) {
            foreach ($queueFiles as $file) {
                $modified = @filemtime((string) $file);
                $scanId = strtolower((string) preg_replace('/\.files$/', '', basename((string) $file)));
                if (!is_int($modified) || $modified > $cutoff || is_file($directory . '/' . $scanId . '.json')) {
                    continue;
                }

                $this->deleteScanJobFile((string) $file);
            }
        }

        $this->repository->failRunningScansOlderThan(gmdate('Y-m-d H:i:s', $cutoff), 'Progressive scan job expired before completion.');
    }

    /**
     * @param array<string,mixed> $job
     */
    private function writeScanJob(array $job): void
    {
        $scanId = (string) ($job['scan_id'] ?? '');
        $path = $this->scanJobPath($scanId);
        $durableWritten = $this->durableJobs->save('scan', $scanId, $job, self::PROGRESSIVE_JOB_TIMEOUT_SECONDS);
        $json = wp_json_encode($job);
        if (!is_string($json)) {
            if ($durableWritten) {
                return;
            }
            throw new \RuntimeException('Scan job could not be encoded.');
        }

        $temporary = tempnam(dirname($path), 'scan-');
        if (!is_string($temporary)) {
            if ($durableWritten) {
                return;
            }
            throw new \RuntimeException('Scan job temporary file could not be created.');
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Internal private storage write; atomic temp+rename prevents partial job JSON.
        $fileWritten = file_put_contents($temporary, $json) !== false;
        if ($fileWritten) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Defense-in-depth for private scan job metadata.
            chmod($temporary, 0600);
            // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replacement inside SHIELD-X private storage.
            $fileWritten = rename($temporary, $path);
        }
        if (!$fileWritten && is_file($temporary)) {
            $this->deleteScanJobFile($temporary);
        }
        if (!$fileWritten && !$durableWritten) {
            throw new \RuntimeException('Scan job could not be persisted.');
        }
    }

    private function beginShadowIndexBatch(): bool
    {
        if (!$this->shadowCopies) {
            return false;
        }

        $this->shadowCopies->beginIndexBatch();

        return true;
    }

    private function commitShadowIndexBatch(bool $started): void
    {
        if ($started && $this->shadowCopies) {
            $this->shadowCopies->commitIndexBatch();
        }
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function ensureTrustedShadow(string $path, string $absolutePath, string $hash, array $metadata): bool
    {
        if (!$this->shadowCopies || $path === '' || $absolutePath === '' || $hash === '') {
            return false;
        }

        if ($this->shadowCopies->hasTrustedCopy($path)) {
            return false;
        }

        $result = $this->shadowCopies->ensureTrusted($path, $absolutePath, $hash, $metadata);

        return !empty($result['ok']);
    }

    /**
     * @param array<string,mixed> $file
     */
    private function markShadowChange(array $file, string $knownHash, string $scanId, string $scanStartedAt): ?array
    {
        if (!$this->shadowCopies) {
            return null;
        }

        $file['scan_id'] = $scanId;
        $file['scan_started_at'] = $scanStartedAt;
        $result = $this->shadowCopies->markChanged(
            (string) ($file['path'] ?? ''),
            (string) ($file['absolute_path'] ?? ''),
            $knownHash,
            (string) ($file['hash'] ?? ''),
            $file
        );

        if (empty($result['ok']) || !isset($result['item']) || !is_array($result['item'])) {
            return null;
        }

        return $result['item'];
    }

    /**
     * @param array<int,array<string,mixed>> $items
     * @return array<int,array<string,mixed>>
     */
    private function shadowChangedItemSummaries(array $items): array
    {
        $summaries = [];

        foreach ($items as $item) {
            $trustedHash = (string) ($item['trusted_hash'] ?? '');
            $currentHash = (string) ($item['current_hash'] ?? '');
            if ($trustedHash === '' || $currentHash === '' || $trustedHash === $currentHash) {
                continue;
            }

            $summaries[] = [
                'path' => (string) ($item['path'] ?? ''),
                'status' => (string) ($item['status'] ?? 'pending'),
                'trusted_hash' => $trustedHash,
                'previous_hash' => (string) ($item['previous_hash'] ?? ''),
                'current_hash' => $currentHash,
                'trusted_at' => (string) ($item['trusted_at'] ?? ''),
                'changed_at' => (string) ($item['changed_at'] ?? ''),
                'last_seen_at' => (string) ($item['last_seen_at'] ?? ''),
                'last_scan_id' => (string) ($item['last_scan_id'] ?? ''),
                'last_scan_at' => (string) ($item['last_scan_at'] ?? ''),
                'shadow_available' => !empty($item['shadow_available']),
            ];
        }

        return $summaries;
    }

    /**
     * @param array<string,mixed> $file
     * @return array<string,mixed>
     */
    private function fileModifiedIssue(array $file, string $knownHash): array
    {
        $path = (string) ($file['path'] ?? '');

        return [
            'rule_id' => 'SX-SCAN-INTEGRITY-001',
            'type' => 'file_modified',
            'severity' => 'medium',
            'status' => 'active',
            'source' => 'scanner',
            'description' => 'Tracked file hash changed from the first SHIELD-X baseline.',
            'affected_path' => $path,
            'content_hash' => (string) ($file['hash'] ?? ''),
            'evidence' => 'baseline ' . substr($knownHash, 0, 12) . ' -> current ' . substr((string) ($file['hash'] ?? ''), 0, 12),
            'threat_id' => 'sx-' . substr(sha1('SX-SCAN-INTEGRITY-001|' . $path), 0, 29),
        ];
    }

    /**
     * @param array<string,mixed> $file
     * @return array<string,mixed>
     */
    private function symlinkOutsideRootIssue(array $file): array
    {
        $path = (string) ($file['path'] ?? '');

        return [
            'rule_id' => 'SX-SCAN-FILE-001',
            'type' => 'suspicious_symlink',
            'severity' => 'high',
            'status' => 'active',
            'source' => 'scanner',
            'description' => 'A symlinked file resolves outside the WordPress root and was not followed.',
            'affected_path' => $path,
            'evidence' => 'target outside WordPress root',
            'threat_id' => 'sx-' . substr(sha1('SX-SCAN-FILE-001|' . $path), 0, 29),
        ];
    }

    private static function newScanId(): string
    {
        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $scanId = substr(bin2hex(random_bytes(16)), 0, 32);
                if (preg_match('/^[a-f0-9]{32}$/', $scanId) === 1) {
                    return $scanId;
                }
            } catch (\Throwable $error) {
                continue;
            }
        }

        return substr(preg_replace('/[^a-z0-9_]/i', '', uniqid('scan_', true)) ?: 'scan_' . gmdate('YmdHis'), 0, 32);
    }
}
