<?php

namespace FirePhage\Security\Scanner;

use FirePhage\Security\FirePhage\Client;
use FirePhage\Security\Settings;
use WP_Error;

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

final class MalwareScanner
{
    public const OPTION_KEY = 'firephage_security_scan_state';
    public const BASELINE_OPTION_KEY = 'firephage_security_scan_baseline';
    public const CRON_HOOK = 'firephage_security_process_scan';
    public const MONITOR_CRON_HOOK = 'firephage_security_monitor_scan';
    public const MONITOR_SCHEDULE = 'firephage_security_every_minute';

    private const BATCH_DELAY_SECONDS = 5;
    private const MONITOR_INTERVAL_SECONDS = 60;
    private const STALL_TIMEOUT_SECONDS = 120;
    private const MAX_FILE_SIZE_BYTES = 3145728;
    private const MAX_DECODED_PAYLOAD_BYTES = 24576;
    private const MAX_DIRECTORY_PROFILE_FILES = 24;
    private const PREVIEW_MAX_BYTES = 32768;
    private const SCAN_BATCH_FILES = 200;
    private const SIGNATURE_CACHE_TTL = 6 * HOUR_IN_SECONDS;
    private const DISCOVERY_BATCH_ENTRIES = 2500;
    private const DISCOVERY_WRITE_BUFFER_LIMIT = 512;

    /**
     * @var array<string, string>
     */
    private $highConfidencePatterns = [];

    /**
     * @var array<string, string>
     */
    private $highConfidenceHashes = [];

    /**
     * @var array<string, array<string, string>>
     */
    private $repoSyncHashes = [
        'md5' => [],
        'sha1' => [],
        'sha256' => [],
    ];

    /**
     * @var array<string, true>
     */
    private $maliciousDomains = [];

    /**
     * @var array<string, string>
     */
    private $maliciousStrings = [];

    /**
     * @var array<string, array{label: string, score: int}>
     */
    private $heuristicPatterns = [];

    /**
     * @var string[]
     */
    private $allowedExtensions = ['php', 'phtml', 'php5', 'php7', 'php8', 'inc', 'js'];

    /**
     * @var array<string, string>|null
     */
    private $coreChecksums = null;

    /**
     * @var array<string, array<string, string>|null>
     */
    private $coreChecksumSets = [];

    /**
     * @var array<string, string|null>
     */
    private $detectedCoreVersions = [];

    /**
     * @var array<string, array<string, string>>
     */
    private $pluginChecksums = [];

    /**
     * @var array<string, array<string, string>>
     */
    private $themeChecksums = [];

    private $client = null;

    private $signatureFeedLoaded = false;

    /**
     * @var array<int, array<string, string>>|null
     */
    private $pluginPackages = null;

    /**
     * @var array<int, array<string, string>>|null
     */
    private $themePackages = null;

    /**
     * @var array<string, string>|null
     */
    private $baselineHashes = null;

    private $baselineDirty = false;

    /**
     * @var array<string, array<string, int>>
     */
    private $directoryProfiles = [];

    /**
     * @var string[]|null
     */
    private $exclusionPatterns = null;

    /**
     * @var array<string, array{mtime: int, size: int, sha256: string, sha1: string, md5: string}>|null
     */
    private $fileHashCache = null;

    private $fileHashCacheDirty = false;

    public function __construct()
    {
        $this->mergeSignaturePayload(SignatureBundle::manifest());
    }

    public function registerHooks(): void
    {
        add_action(self::CRON_HOOK, [$this, 'processBatch']);
        add_action(self::MONITOR_CRON_HOOK, [$this, 'monitorScan']);
        add_filter('cron_schedules', [$this, 'registerMonitorSchedule']);
    }

    /**
     * @param array<string, array<string, mixed>> $schedules
     * @return array<string, array<string, mixed>>
     */
    public function registerMonitorSchedule(array $schedules): array
    {
        if (! isset($schedules[self::MONITOR_SCHEDULE])) {
            $schedules[self::MONITOR_SCHEDULE] = [
                'interval' => self::MONITOR_INTERVAL_SECONDS,
                'display' => __('FirePhage Scan Monitor', 'firephage-security'),
            ];
        }

        return $schedules;
    }

    /**
     * @return array<string, mixed>
     */
    public function getState(): array
    {
        $defaults = [
            'status' => 'idle',
            'scan_id' => '',
            'started_at' => '',
            'updated_at' => '',
            'finished_at' => '',
            'stage' => 'idle',
            'paused_from_status' => '',
            'paused_from_stage' => '',
            'discovered_files' => 0,
            'scanned_files' => 0,
            'suspicious_files' => 0,
            'integrity_issues' => 0,
            'trusted_files' => 0,
            'baseline_seeded_files' => 0,
            'repository_verified_files' => 0,
            'skipped_files' => 0,
            'clean_files' => 0,
            'current_file' => '',
            'scan_mode' => 'deep',
            'queue_dirs' => [],
            'manifest_path' => '',
            'findings' => [],
            'last_error' => '',
        ];

        $state = get_option(self::OPTION_KEY, []);

        return is_array($state) ? array_merge($defaults, $state) : $defaults;
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    public function startScan(bool $forceNew = false, string $scanMode = 'deep')
    {
        $state = $this->getState();
        $scanMode = $scanMode === 'quick' ? 'quick' : 'deep';

        if (in_array($state['status'], ['discovering', 'scanning'], true)) {
            if ($forceNew || $this->hasStaleActiveScan($state)) {
                $state = $this->resetScanState($state);
                $forceNew = true;
            } else {
                return new WP_Error('scan_running', __('A malware scan is already running.', 'firephage-security'));
            }
        }

        if (! $forceNew && $this->canResumeScan($state)) {
            $state['status'] = (string) $state['paused_from_status'];
            $state['stage'] = (string) ($state['paused_from_stage'] !== '' ? $state['paused_from_stage'] : $state['paused_from_status']);
            $state['paused_from_status'] = '';
            $state['paused_from_stage'] = '';
            $state['updated_at'] = current_time('mysql');
            $state['finished_at'] = '';

            update_option(self::OPTION_KEY, $state, false);
            $this->beginMonitoring();
            $this->scheduleNextBatch();

            return $state;
        }

        if ($forceNew) {
            $this->cleanupManifest((string) ($state['manifest_path'] ?? ''));
        }

        $scanId = wp_generate_uuid4();
        $manifestPath = trailingslashit($this->ensureRuntimeDirectory()) . 'scan-' . $scanId . '.manifest';

        if (file_put_contents($manifestPath, '') === false) {
            return new WP_Error('manifest_create_failed', __('The scan manifest could not be created.', 'firephage-security'));
        }

        $newState = [
            'status' => 'discovering',
            'scan_id' => $scanId,
            'started_at' => current_time('mysql'),
            'updated_at' => current_time('mysql'),
            'finished_at' => '',
            'stage' => 'discovering',
            'paused_from_status' => '',
            'paused_from_stage' => '',
            'discovered_files' => 0,
            'scanned_files' => 0,
            'suspicious_files' => 0,
            'integrity_issues' => 0,
            'trusted_files' => 0,
            'baseline_seeded_files' => 0,
            'repository_verified_files' => 0,
            'skipped_files' => 0,
            'clean_files' => 0,
            'current_file' => '',
            'scan_mode' => $scanMode,
            'queue_dirs' => [
                [
                    'path' => untrailingslashit(ABSPATH),
                    'offset' => 0,
                ],
            ],
            'manifest_path' => $manifestPath,
            'findings' => [],
            'last_error' => '',
        ];

        update_option(self::OPTION_KEY, $newState, false);
        $this->beginMonitoring();
        $this->scheduleNextBatch();

        return $newState;
    }

    public function processBatch(): void
    {
        $state = $this->getState();

        if (! in_array($state['status'], ['discovering', 'scanning'], true)) {
            return;
        }

        try {
            if ($state['status'] === 'discovering') {
                $state = $this->discoverFiles($state);
            }

            if ($state['status'] === 'scanning') {
                $state = $this->scanFiles($state);
            }

            update_option(self::OPTION_KEY, $state, false);
            $this->persistBaseline();

            if (in_array($state['status'], ['discovering', 'scanning'], true)) {
                $this->scheduleNextBatch();
            } else {
                $this->persistFileHashCache();
                $this->endMonitoring();

                if ($state['status'] === 'completed') {
                    do_action('firephage_security_scan_completed', $state);
                }
            }
        } catch (\Throwable $throwable) {
            $state['status'] = 'failed';
            $state['last_error'] = $throwable->getMessage();
            $state['updated_at'] = current_time('mysql');
            $state['finished_at'] = current_time('mysql');

            update_option(self::OPTION_KEY, $state, false);
            $this->persistBaseline();
            $this->persistFileHashCache();
            $this->endMonitoring();
        }
    }

    public function clearFindings(): array
    {
        $state = $this->getState();
        $state['findings'] = [];
        update_option(self::OPTION_KEY, $state, false);

        return $state;
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    public function stopScan()
    {
        $state = $this->getState();

        if (! in_array($state['status'], ['discovering', 'scanning'], true)) {
            return new WP_Error('scan_not_running', __('There is no active malware scan to stop.', 'firephage-security'));
        }

        $pausedFromStatus = (string) $state['status'];
        $pausedFromStage = (string) $state['stage'];

        $state['status'] = 'stopped';
        $state['stage'] = 'stopped';
        $state['paused_from_status'] = $pausedFromStatus;
        $state['paused_from_stage'] = $pausedFromStage;
        $state['updated_at'] = current_time('mysql');
        $state['finished_at'] = current_time('mysql');
        $state['current_file'] = '';

        update_option(self::OPTION_KEY, $state, false);
        $this->persistFileHashCache();

        wp_clear_scheduled_hook(self::CRON_HOOK);
        $this->endMonitoring();

        return $state;
    }

    /**
     * @param array<string, mixed> $state
     */
    private function canResumeScan(array $state): bool
    {
        if (($state['status'] ?? '') !== 'stopped') {
            return false;
        }

        $pausedFromStatus = (string) ($state['paused_from_status'] ?? '');
        $manifestPath = (string) ($state['manifest_path'] ?? '');

        if (! in_array($pausedFromStatus, ['discovering', 'scanning'], true)) {
            return false;
        }

        return $manifestPath !== '' && file_exists($manifestPath) && is_readable($manifestPath);
    }

    /**
     * @param array<string, mixed> $state
     */
    private function hasStaleActiveScan(array $state): bool
    {
        if (! in_array((string) ($state['status'] ?? ''), ['discovering', 'scanning'], true)) {
            return false;
        }

        $manifestPath = (string) ($state['manifest_path'] ?? '');
        $updatedAt = strtotime((string) ($state['updated_at'] ?? '')) ?: 0;
        $hasRecentHeartbeat = $updatedAt > 0 && (time() - $updatedAt) < (self::STALL_TIMEOUT_SECONDS * 2);

        if ($manifestPath === '' || ! file_exists($manifestPath) || ! is_readable($manifestPath)) {
            return true;
        }

        return ! $hasRecentHeartbeat && ! wp_next_scheduled(self::CRON_HOOK);
    }

    /**
     * @param array<string, mixed> $state
     * @return array<string, mixed>
     */
    private function resetScanState(array $state): array
    {
        $this->cleanupManifest((string) ($state['manifest_path'] ?? ''));
        wp_clear_scheduled_hook(self::CRON_HOOK);
        $this->endMonitoring();

        $state['status'] = 'idle';
        $state['stage'] = 'idle';
        $state['paused_from_status'] = '';
        $state['paused_from_stage'] = '';
        $state['current_file'] = '';
        $state['manifest_path'] = '';
        $state['started_at'] = '';
        $state['updated_at'] = current_time('mysql');
        $state['finished_at'] = current_time('mysql');
        $state['last_error'] = '';
        $state['queue_dirs'] = [];

        update_option(self::OPTION_KEY, $state, false);

        return $state;
    }

    private function cleanupManifest(string $manifestPath): void
    {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Scanner manifest cleanup needs a direct writability check for the generated temp file.
        if ($manifestPath !== '' && file_exists($manifestPath) && is_writable($manifestPath)) {
            wp_delete_file($manifestPath);
        }
    }

    /**
     * @return array{file: string, content: string, truncated: bool}|WP_Error
     */
    public function previewFile(string $relativePath)
    {
        $relativePath = ltrim(wp_normalize_path($relativePath), '/');

        if ($relativePath === '') {
            return new WP_Error('invalid_file', __('A valid file is required for preview.', 'firephage-security'));
        }

        $absolutePath = trailingslashit(wp_normalize_path(ABSPATH)) . $relativePath;

        if (! $this->startsWith($absolutePath, trailingslashit(wp_normalize_path(ABSPATH)))) {
            return new WP_Error('invalid_file', __('The requested file is outside the WordPress installation.', 'firephage-security'));
        }

        if (! file_exists($absolutePath) || ! is_file($absolutePath) || ! is_readable($absolutePath)) {
            return new WP_Error('preview_unavailable', __('The requested file is not available for preview.', 'firephage-security'));
        }

        $contents = file_get_contents($absolutePath, false, null, 0, self::PREVIEW_MAX_BYTES + 1);

        if (! is_string($contents)) {
            return new WP_Error('preview_unavailable', __('The requested file could not be read.', 'firephage-security'));
        }

        $truncated = strlen($contents) > self::PREVIEW_MAX_BYTES;

        if ($truncated) {
            $contents = substr($contents, 0, self::PREVIEW_MAX_BYTES);
        }

        return [
            'file' => $relativePath,
            'content' => $contents,
            'truncated' => $truncated,
        ];
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    public function compareFile(string $relativePath, string $source)
    {
        $local = $this->previewFile($relativePath);

        if (is_wp_error($local)) {
            return $local;
        }

        $referenceContext = $this->resolveReferenceFileContext($relativePath, $source);

        if ($referenceContext === null) {
            return new WP_Error('compare_unavailable', __('A trusted comparison file is not available for this item.', 'firephage-security'));
        }

        $reference = $this->fetchReferenceFileFromFirePhage(
            $referenceContext['type'],
            $referenceContext['slug'],
            $referenceContext['version'],
            $referenceContext['path']
        );

        if (is_wp_error($reference)) {
            return $reference;
        }

        $referenceContent = isset($reference['content']) && is_string($reference['content']) ? $reference['content'] : '';

        if ($referenceContent === '') {
            return new WP_Error('compare_unavailable', __('The official reference file is not available for comparison.', 'firephage-security'));
        }

        $referencePreview = $this->truncatePreviewContent($referenceContent);

        return [
            'mode' => 'compare',
            'file' => $relativePath,
            'local' => $local,
            'reference' => [
                'content' => $referencePreview['content'],
                'truncated' => $referencePreview['truncated'],
                'type' => $referenceContext['type'],
                'slug' => $referenceContext['slug'],
                'version' => $referenceContext['version'],
                'path' => $referenceContext['path'],
            ],
        ];
    }

    /**
     * @return array{state: array<string, mixed>, restored: bool, message: string}
     */
    public function restoreIntegrityFile(string $relativePath, string $source): array
    {
        $state = $this->getState();
        $findings = is_array($state['findings']) ? $state['findings'] : [];
        $remainingFindings = [];
        $restored = false;

        foreach ($findings as $finding) {
            $findingFile = is_array($finding) ? (string) ($finding['file'] ?? '') : '';
            $findingSource = is_array($finding) ? (string) ($finding['source'] ?? '') : '';
            $isTarget = is_array($finding)
                && (($finding['type'] ?? '') !== 'malware')
                && $findingFile === $relativePath
                && $findingSource === $source
                && ! $restored;

            if (! $isTarget) {
                $remainingFindings[] = $finding;
                continue;
            }

            $result = $this->restoreIntegrityPath($relativePath, $source);

            if (is_wp_error($result)) {
                $remainingFindings[] = $finding;

                return [
                    'state' => $state,
                    'restored' => false,
                    'message' => $result->get_error_message(),
                ];
            }

            $restored = true;
        }

        if (! $restored) {
            return [
                'state' => $state,
                'restored' => false,
                'message' => __('The modified file is no longer available in the current findings list.', 'firephage-security'),
            ];
        }

        $state['findings'] = array_values($remainingFindings);
        $state['integrity_issues'] = max(0, (int) ($state['integrity_issues'] ?? 0) - 1);
        $state['updated_at'] = current_time('mysql');
        update_option(self::OPTION_KEY, $state, false);

        return [
            'state' => $state,
            'restored' => true,
            'message' => __('The official file was restored.', 'firephage-security'),
        ];
    }

    /**
     * @return array{state: array<string, mixed>, restored_files: int, skipped_files: int}
     */
    public function restoreAllIntegrityFiles(): array
    {
        $state = $this->getState();
        $findings = is_array($state['findings']) ? $state['findings'] : [];
        $remainingFindings = [];
        $restoredFiles = 0;
        $skippedFiles = 0;

        foreach ($findings as $finding) {
            $relativePath = is_array($finding) ? (string) ($finding['file'] ?? '') : '';
            $source = is_array($finding) ? (string) ($finding['source'] ?? '') : '';
            $isRestorable = is_array($finding)
                && (($finding['type'] ?? '') !== 'malware')
                && $this->isRestorableIntegritySource($source);

            if (! $isRestorable) {
                $remainingFindings[] = $finding;
                continue;
            }

            $result = $this->restoreIntegrityPath($relativePath, $source);

            if (is_wp_error($result)) {
                $remainingFindings[] = $finding;
                $skippedFiles++;
                continue;
            }

            $restoredFiles++;
        }

        $state['findings'] = array_values($remainingFindings);
        $state['integrity_issues'] = max(0, (int) ($state['integrity_issues'] ?? 0) - $restoredFiles);
        $state['updated_at'] = current_time('mysql');
        update_option(self::OPTION_KEY, $state, false);

        return [
            'state' => $state,
            'restored_files' => $restoredFiles,
            'skipped_files' => $skippedFiles,
        ];
    }

    /**
     * @return array{state: array<string, mixed>, deleted_files: int, skipped_files: int}
     */
    public function deleteSuspiciousFiles(): array
    {
        $state = $this->getState();
        $findings = is_array($state['findings']) ? $state['findings'] : [];
        $remainingFindings = [];
        $deletedFiles = 0;
        $skippedFiles = 0;

        foreach ($findings as $finding) {
            if (! is_array($finding) || (($finding['type'] ?? '') !== 'malware')) {
                $remainingFindings[] = $finding;
                continue;
            }

            $relativePath = isset($finding['file']) ? (string) $finding['file'] : '';

            if ($relativePath === '' || ! $this->isDeletableSuspiciousPath($relativePath)) {
                $remainingFindings[] = $finding;
                $skippedFiles++;
                continue;
            }

            $absolutePath = trailingslashit(wp_normalize_path(ABSPATH)) . ltrim($relativePath, '/');

            if (! file_exists($absolutePath)) {
                $deletedFiles++;
                continue;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Malware cleanup must verify the exact file can be removed before deletion.
            if (! is_file($absolutePath) || ! is_writable($absolutePath) || ! wp_delete_file($absolutePath)) {
                $remainingFindings[] = $finding;
                $skippedFiles++;
                continue;
            }

            $deletedFiles++;
        }

        $state['findings'] = array_values($remainingFindings);
        $state['suspicious_files'] = max(0, (int) ($state['suspicious_files'] ?? 0) - $deletedFiles);
        $state['updated_at'] = current_time('mysql');
        update_option(self::OPTION_KEY, $state, false);

        return [
            'state' => $state,
            'deleted_files' => $deletedFiles,
            'skipped_files' => $skippedFiles,
        ];
    }

    /**
     * @return array{state: array<string, mixed>, deleted: bool, message: string}
     */
    public function deleteSuspiciousFile(string $relativePath): array
    {
        $state = $this->getState();
        $findings = is_array($state['findings']) ? $state['findings'] : [];
        $remainingFindings = [];
        $deleted = false;

        foreach ($findings as $finding) {
            $findingFile = is_array($finding) ? (string) ($finding['file'] ?? '') : '';
            $isTarget = is_array($finding)
                && (($finding['type'] ?? '') === 'malware')
                && $findingFile === $relativePath
                && ! $deleted;

            if (! $isTarget) {
                $remainingFindings[] = $finding;
                continue;
            }

            if (! $this->isDeletableSuspiciousPath($relativePath)) {
                $remainingFindings[] = $finding;

                return [
                    'state' => $state,
                    'deleted' => false,
                    'message' => __('This file is protected and cannot be deleted from the findings table.', 'firephage-security'),
                ];
            }

            $absolutePath = trailingslashit(wp_normalize_path(ABSPATH)) . ltrim($relativePath, '/');

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Malware cleanup must verify the exact file can be removed before deletion.
            if (file_exists($absolutePath) && (! is_file($absolutePath) || ! is_writable($absolutePath) || ! wp_delete_file($absolutePath))) {
                $remainingFindings[] = $finding;

                return [
                    'state' => $state,
                    'deleted' => false,
                    'message' => __('The malicious file could not be deleted.', 'firephage-security'),
                ];
            }

            $deleted = true;
        }

        if (! $deleted) {
            return [
                'state' => $state,
                'deleted' => false,
                'message' => __('The malicious file is no longer available in the current findings list.', 'firephage-security'),
            ];
        }

        $state['findings'] = array_values($remainingFindings);
        $state['suspicious_files'] = max(0, (int) ($state['suspicious_files'] ?? 0) - 1);
        $state['updated_at'] = current_time('mysql');
        update_option(self::OPTION_KEY, $state, false);

        return [
            'state' => $state,
            'deleted' => true,
            'message' => __('The malicious file was deleted.', 'firephage-security'),
        ];
    }

    /**
     * @param array<int, string> $relativePaths
     * @return array{state: array<string, mixed>, deleted_files: int, skipped_files: int}
     */
    public function deleteSelectedSuspiciousFiles(array $relativePaths): array
    {
        $selected = array_values(array_unique(array_filter(array_map('strval', $relativePaths))));

        if ($selected === []) {
            return [
                'state' => $this->getState(),
                'deleted_files' => 0,
                'skipped_files' => 0,
            ];
        }

        $state = $this->getState();
        $findings = is_array($state['findings']) ? $state['findings'] : [];
        $remainingFindings = [];
        $deletedFiles = 0;
        $skippedFiles = 0;

        foreach ($findings as $finding) {
            $relativePath = is_array($finding) ? (string) ($finding['file'] ?? '') : '';
            $isSelected = is_array($finding)
                && (($finding['type'] ?? '') === 'malware')
                && in_array($relativePath, $selected, true);

            if (! $isSelected) {
                $remainingFindings[] = $finding;
                continue;
            }

            if (! $this->isDeletableSuspiciousPath($relativePath)) {
                $remainingFindings[] = $finding;
                $skippedFiles++;
                continue;
            }

            $absolutePath = trailingslashit(wp_normalize_path(ABSPATH)) . ltrim($relativePath, '/');

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Malware cleanup must verify the exact file can be removed before deletion.
            if (file_exists($absolutePath) && (! is_file($absolutePath) || ! is_writable($absolutePath) || ! wp_delete_file($absolutePath))) {
                $remainingFindings[] = $finding;
                $skippedFiles++;
                continue;
            }

            $deletedFiles++;
        }

        $state['findings'] = array_values($remainingFindings);
        $state['suspicious_files'] = max(0, (int) ($state['suspicious_files'] ?? 0) - $deletedFiles);
        $state['updated_at'] = current_time('mysql');
        update_option(self::OPTION_KEY, $state, false);

        return [
            'state' => $state,
            'deleted_files' => $deletedFiles,
            'skipped_files' => $skippedFiles,
        ];
    }

    public function monitorScan(): void
    {
        $state = $this->getState();

        if (! in_array($state['status'], ['discovering', 'scanning'], true)) {
            $this->endMonitoring();
            return;
        }

        $updatedAt = strtotime((string) $state['updated_at']);

        if ($updatedAt === false) {
            $this->scheduleNextBatch();
            return;
        }

        if ((time() - $updatedAt) >= self::STALL_TIMEOUT_SECONDS) {
            $this->scheduleNextBatch();
        }
    }

    /**
     * @param array<string, mixed> $state
     * @return array<string, mixed>
     */
    private function discoverFiles(array $state): array
    {
        $processedEntries = 0;
        $rawQueue = is_array($state['queue_dirs']) ? array_values($state['queue_dirs']) : [];
        $queue = [];

        foreach ($rawQueue as $entry) {
            if (is_string($entry) && $entry !== '') {
                $queue[] = [
                    'path' => $entry,
                    'offset' => 0,
                ];
                continue;
            }

            if (! is_array($entry)) {
                continue;
            }

            $path = isset($entry['path']) ? (string) $entry['path'] : '';

            if ($path === '') {
                continue;
            }

            $queue[] = [
                'path' => $path,
                'offset' => max(0, (int) ($entry['offset'] ?? 0)),
            ];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- The scanner appends a large discovery manifest incrementally to avoid loading every path into memory.
        $handle = fopen((string) $state['manifest_path'], 'ab');

        if ($handle === false) {
            throw new \RuntimeException('Unable to open scan manifest.');
        }

        $manifestBuffer = [];

        while ($queue !== [] && $processedEntries < self::DISCOVERY_BATCH_ENTRIES) {
            $entry = array_shift($queue);
            $directory = is_array($entry) ? (string) ($entry['path'] ?? '') : '';
            $offset = is_array($entry) ? max(0, (int) ($entry['offset'] ?? 0)) : 0;

            if ($directory === '' || ! is_dir($directory) || is_link($directory)) {
                continue;
            }

            $items = @scandir($directory);

            if (! is_array($items)) {
                continue;
            }

            $count = count($items);

            for ($index = $offset; $index < $count; $index++) {
                $item = $items[$index];

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

                $path = $directory . DIRECTORY_SEPARATOR . $item;

                if (is_link($path)) {
                    continue;
                }

                if (is_dir($path)) {
                    if (! $this->shouldSkipDirectory($path)) {
                        $queue[] = [
                            'path' => $path,
                            'offset' => 0,
                        ];
                    }
                } elseif ($this->shouldScanFile($path)) {
                    $manifestBuffer[] = wp_normalize_path($path);
                    $state['discovered_files']++;

                    if (count($manifestBuffer) >= self::DISCOVERY_WRITE_BUFFER_LIMIT) {
                        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Incremental manifest writes avoid excessive memory use on large WordPress installs.
                        fwrite($handle, implode("\n", $manifestBuffer) . "\n");
                        $manifestBuffer = [];
                    }
                }

                $processedEntries++;

                if ($processedEntries >= self::DISCOVERY_BATCH_ENTRIES) {
                    if (($index + 1) < $count) {
                        array_unshift($queue, [
                            'path' => $directory,
                            'offset' => $index + 1,
                        ]);
                    }

                    break;
                }
            }
        }

        if ($manifestBuffer !== []) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Incremental manifest writes avoid excessive memory use on large WordPress installs.
            fwrite($handle, implode("\n", $manifestBuffer) . "\n");
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Completes the direct manifest stream opened above.
        fclose($handle);

        $state['queue_dirs'] = $queue;
        $state['updated_at'] = current_time('mysql');

        if ($queue === []) {
            $state['status'] = 'scanning';
            $state['stage'] = 'scanning';
        }

        return $state;
    }

    /**
     * @param array<string, mixed> $state
     * @return array<string, mixed>
     */
    private function scanFiles(array $state): array
    {
        $manifest = new \SplFileObject((string) $state['manifest_path'], 'rb');
        $manifest->seek((int) $state['scanned_files']);
        $processed = 0;

        while (! $manifest->eof() && $processed < self::SCAN_BATCH_FILES) {
            $path = trim((string) $manifest->current());
            $manifest->next();

            if ($path === '') {
                continue;
            }

            $state['current_file'] = $this->relativePath($path);

            if (! file_exists($path) || ! is_readable($path)) {
                $state['skipped_files']++;
                $state['scanned_files']++;
                $processed++;
                continue;
            }

            if (filesize($path) > self::MAX_FILE_SIZE_BYTES) {
                $state['skipped_files']++;
                $state['scanned_files']++;
                $processed++;
                continue;
            }

            $analysis = $this->analyzeFile($path, (string) ($state['scan_mode'] ?? 'deep'));

            if ($analysis['outcome'] === 'trusted') {
                $state['trusted_files']++;

                if (($analysis['source'] ?? '') !== 'local_baseline') {
                    $state['repository_verified_files']++;
                }
            } elseif ($analysis['outcome'] === 'baselined') {
                $state['trusted_files']++;
                $state['baseline_seeded_files']++;
            } elseif ($analysis['outcome'] === 'integrity') {
                $state['integrity_issues']++;
                $state['findings'] = $this->appendFinding(
                    is_array($state['findings']) ? $state['findings'] : [],
                    $analysis['finding']
                );
            } elseif ($analysis['outcome'] === 'suspicious') {
                $state['suspicious_files']++;
                $state['findings'] = $this->appendFinding(
                    is_array($state['findings']) ? $state['findings'] : [],
                    $analysis['finding']
                );
            } else {
                $state['clean_files']++;
            }

            $state['scanned_files']++;
            $state['updated_at'] = current_time('mysql');
            $processed++;
        }

        if ($manifest->eof()) {
            $state['status'] = 'completed';
            $state['stage'] = 'completed';
            $state['finished_at'] = current_time('mysql');
            $state['current_file'] = '';
        }

        return $state;
    }

    /**
     * @return array<string, mixed>
     */
    private function analyzeFile(string $path, string $scanMode = 'deep'): array
    {
        $relativePath = $this->relativePath($path);
        $hashes = $this->hashFileFast($path);

        if ($hashes === null) {
            return ['outcome' => 'clean'];
        }

        $integrity = $this->evaluateIntegrity($relativePath, [
            'sha256' => $hashes['sha256'],
            'md5' => $hashes['md5'],
        ]);

        if ($integrity['status'] === 'trusted') {
            return [
                'outcome' => 'trusted',
                'source' => $integrity['source'],
            ];
        }

        $hashSuspicion = $this->evaluateHashIndicators($hashes['sha256'], $hashes['sha1'], $hashes['md5']);

        if ($hashSuspicion['matched']) {
            $reasons = $hashSuspicion['reasons'];

            if ($integrity['status'] === 'mismatch') {
                array_unshift($reasons, $this->integrityReason((string) $integrity['source']));
            }

            return [
                'outcome' => 'suspicious',
                'finding' => $this->makeFinding(
                    $relativePath,
                    'malware',
                    (string) $hashSuspicion['confidence'],
                    (string) ($hashSuspicion['source'] ?? ($integrity['source'] ?? 'high_confidence_hash')),
                    $reasons
                ),
            ];
        }

        if ($integrity['status'] === 'mismatch') {
            return [
                'outcome' => 'integrity',
                'finding' => $this->makeFinding(
                    $relativePath,
                    'integrity',
                    'low',
                    (string) $integrity['source'],
                    [$this->integrityReason((string) $integrity['source'])]
                ),
            ];
        }

        $contents = file_get_contents($path);

        if (! is_string($contents) || $contents === '') {
            return ['outcome' => 'clean'];
        }

        $suspicion = $this->evaluateSuspicion(
            $contents,
            $relativePath,
            $path,
            $hashes['sha256'],
            $hashes['sha1'],
            $hashes['md5'],
            $scanMode
        );

        if ($suspicion['matched']) {
            $reasons = $suspicion['reasons'];

            if ($integrity['status'] === 'mismatch') {
                array_unshift($reasons, $this->integrityReason((string) $integrity['source']));
            }

            return [
                'outcome' => 'suspicious',
                'finding' => $this->makeFinding(
                    $relativePath,
                    'malware',
                    (string) $suspicion['confidence'],
                    (string) ($suspicion['source'] ?? ($integrity['source'] ?? 'heuristic')),
                    $reasons
                ),
            ];
        }

        if ($this->shouldSeedBaseline($relativePath)) {
            $this->recordBaselineHash($relativePath, $hashes['sha256']);

            return [
                'outcome' => 'baselined',
            ];
        }

        return ['outcome' => 'clean'];
    }

    /**
     * @return array{matched: bool, confidence?: string, source?: string, reasons?: array<int, string>}
     */
    private function evaluateSuspicion(string $contents, string $relativePath, string $path, string $sha256Hash, string $sha1Hash, string $md5Hash, string $scanMode = 'deep'): array
    {
        $this->loadSignatureFeed();
        $matches = [];
        $scanMode = $scanMode === 'quick' ? 'quick' : 'deep';

        $hashMatch = $this->evaluateHashIndicators($sha256Hash, $sha1Hash, $md5Hash);

        if ($hashMatch['matched']) {
            return $hashMatch;
        }

        $matchedDomains = $this->matchMaliciousDomains($contents);

        if ($matchedDomains !== []) {
            return [
                'matched' => true,
                'confidence' => count($matchedDomains) >= 2 ? 'high' : 'medium',
                'reasons' => array_map(
                    static function (string $domain): string {
                        return 'malicious domain IOC: ' . $domain;
                    },
                    array_slice($matchedDomains, 0, 6)
                ),
                'source' => 'malicious_domain',
            ];
        }

        $matchedStrings = $this->matchMaliciousStrings($contents);

        if ($matchedStrings !== []) {
            return [
                'matched' => true,
                'confidence' => count($matchedStrings) >= 2 ? 'high' : 'medium',
                'reasons' => array_values(array_slice(array_unique($matchedStrings), 0, 6)),
                'source' => 'malicious_string',
            ];
        }

        if ($scanMode === 'quick') {
            return ['matched' => false];
        }

        foreach ($this->highConfidencePatterns as $pattern => $label) {
            if (preg_match($pattern, $contents) === 1) {
                $matches[] = $label;
            }
        }

        if ($matches !== []) {
            return [
                'matched' => true,
                'confidence' => 'high',
                'reasons' => array_values(array_unique($matches)),
                'source' => 'high_confidence',
            ];
        }

        $score = 0;
        $heuristicMatches = [];

        foreach ($this->heuristicPatterns as $pattern => $entry) {
            if (preg_match($pattern, $contents) !== 1) {
                continue;
            }

            $score += (int) ($entry['score'] ?? 1);
            $heuristicMatches[] = (string) ($entry['label'] ?? 'signature match');
        }

        if ($heuristicMatches !== [] && $score >= 2) {
            return [
                'matched' => true,
                'confidence' => $score >= 3 ? 'high' : 'medium',
                'reasons' => array_values(array_unique($heuristicMatches)),
                'source' => 'signature',
            ];
        }

        return ['matched' => false];
    }

    /**
     * @return array{sha256: string, sha1: string, md5: string}|null
     */
    private function hashFileFast(string $path): ?array
    {
        $relativePath = $this->relativePath($path);
        $stat = @stat($path);

        if (is_array($stat)) {
            $mtime = isset($stat['mtime']) ? (int) $stat['mtime'] : 0;
            $size = isset($stat['size']) ? (int) $stat['size'] : 0;
            $cached = $this->getCachedFileHashes($relativePath, $mtime, $size);

            if ($cached !== null) {
                return $cached;
            }
        } else {
            $mtime = 0;
            $size = 0;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Hashing large files uses streaming reads to avoid loading full files into memory.
        $handle = @fopen($path, 'rb');

        if (! is_resource($handle)) {
            return null;
        }

        $sha256 = hash_init('sha256');
        $sha1 = hash_init('sha1');
        $md5 = hash_init('md5');

        while (! feof($handle)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Hashing large files uses bounded streaming reads.
            $chunk = fread($handle, 65536);

            if ($chunk === false) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the direct file stream opened for bounded hashing.
                fclose($handle);

                return null;
            }

            if ($chunk === '') {
                continue;
            }

            hash_update($sha256, $chunk);
            hash_update($sha1, $chunk);
            hash_update($md5, $chunk);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the direct file stream opened for bounded hashing.
        fclose($handle);

        $hashes = [
            'sha256' => strtolower(hash_final($sha256)),
            'sha1' => strtolower(hash_final($sha1)),
            'md5' => strtolower(hash_final($md5)),
        ];

        if ($relativePath !== '' && $mtime > 0 && $size >= 0) {
            $this->storeCachedFileHashes($relativePath, $mtime, $size, $hashes);
        }

        return $hashes;
    }

    /**
     * @return array{sha256: string, sha1: string, md5: string}|null
     */
    private function getCachedFileHashes(string $relativePath, int $mtime, int $size): ?array
    {
        if ($relativePath === '' || $mtime <= 0 || $size < 0) {
            return null;
        }

        $cache = $this->getFileHashCache();

        if (! isset($cache[$relativePath])) {
            return null;
        }

        $entry = $cache[$relativePath];

        if (($entry['mtime'] ?? 0) !== $mtime || ($entry['size'] ?? -1) !== $size) {
            return null;
        }

        return [
            'sha256' => (string) ($entry['sha256'] ?? ''),
            'sha1' => (string) ($entry['sha1'] ?? ''),
            'md5' => (string) ($entry['md5'] ?? ''),
        ];
    }

    /**
     * @param array{sha256: string, sha1: string, md5: string} $hashes
     */
    private function storeCachedFileHashes(string $relativePath, int $mtime, int $size, array $hashes): void
    {
        if ($relativePath === '' || $mtime <= 0 || $size < 0) {
            return;
        }

        $cache = $this->getFileHashCache();
        $cache[$relativePath] = [
            'mtime' => $mtime,
            'size' => $size,
            'sha256' => $hashes['sha256'],
            'sha1' => $hashes['sha1'],
            'md5' => $hashes['md5'],
        ];
        $this->fileHashCache = $cache;
        $this->fileHashCacheDirty = true;
    }

    /**
     * @return array<string, array{mtime: int, size: int, sha256: string, sha1: string, md5: string}>
     */
    private function getFileHashCache(): array
    {
        if ($this->fileHashCache !== null) {
            return $this->fileHashCache;
        }

        $path = $this->fileHashCachePath();

        if ($path === '' || ! file_exists($path) || ! is_readable($path)) {
            $this->fileHashCache = [];
            return $this->fileHashCache;
        }

        $decoded = json_decode((string) file_get_contents($path), true);

        if (! is_array($decoded)) {
            $this->fileHashCache = [];
            return $this->fileHashCache;
        }

        $cache = [];

        foreach ($decoded as $relativePath => $entry) {
            if (! is_string($relativePath) || ! is_array($entry)) {
                continue;
            }

            $cache[$relativePath] = [
                'mtime' => (int) ($entry['mtime'] ?? 0),
                'size' => (int) ($entry['size'] ?? 0),
                'sha256' => strtolower((string) ($entry['sha256'] ?? '')),
                'sha1' => strtolower((string) ($entry['sha1'] ?? '')),
                'md5' => strtolower((string) ($entry['md5'] ?? '')),
            ];
        }

        $this->fileHashCache = $cache;

        return $this->fileHashCache;
    }

    private function persistFileHashCache(): void
    {
        if (! $this->fileHashCacheDirty || $this->fileHashCache === null) {
            return;
        }

        $path = $this->fileHashCachePath();

        if ($path === '') {
            return;
        }

        $encoded = wp_json_encode($this->fileHashCache);

        if (! is_string($encoded) || $encoded === '') {
            return;
        }

        if (file_put_contents($path, $encoded) === false) {
            return;
        }

        $this->fileHashCacheDirty = false;
    }

    private function fileHashCachePath(): string
    {
        return trailingslashit($this->ensureRuntimeDirectory()) . 'file-hash-cache.json';
    }

    /**
     * @return array{matched: bool, confidence?: string, source?: string, reasons?: array<int, string>}
     */
    private function evaluateHashIndicators(string $sha256Hash, string $sha1Hash, string $md5Hash): array
    {
        $this->loadSignatureFeed();

        if ($sha256Hash !== '' && isset($this->highConfidenceHashes[$sha256Hash])) {
            return [
                'matched' => true,
                'confidence' => 'high',
                'reasons' => [$this->highConfidenceHashes[$sha256Hash]],
                'source' => 'high_confidence_hash',
            ];
        }

        foreach ([
            'sha256' => $sha256Hash,
            'sha1' => $sha1Hash,
            'md5' => $md5Hash,
        ] as $algorithm => $hashValue) {
            if ($hashValue === '' || ! isset($this->repoSyncHashes[$algorithm][$hashValue])) {
                continue;
            }

            return [
                'matched' => true,
                'confidence' => 'high',
                'reasons' => [$this->repoSyncHashes[$algorithm][$hashValue]],
                'source' => 'high_confidence_hash',
            ];
        }

        return ['matched' => false];
    }

    /**
     * @return array{high: array<int, string>, scored: array<int, array{label: string, score: int}>}
     */
    private function decodedPayloadSignals(string $contents): array
    {
        $signals = [
            'high' => [],
            'scored' => [],
        ];

        if (! preg_match_all('/[A-Za-z0-9+\/=]{160,}/', $contents, $matches)) {
            return $signals;
        }

        foreach (array_slice($matches[0], 0, 6) as $candidate) {
            $decoded = base64_decode((string) $candidate, true);

            if (! is_string($decoded) || $decoded === '') {
                continue;
            }

            $decoded = substr($decoded, 0, self::MAX_DECODED_PAYLOAD_BYTES);

            if (preg_match('/(?:<\?php|\beval\s*\(|\bassert\s*\(|\bshell_exec\s*\(|\bpassthru\s*\(|\bproc_open\s*\(|\bsystem\s*\(|\bmove_uploaded_file\s*\(|\bnew\s+Function\s*\()/i', $decoded) === 1) {
                $signals['high'][] = 'decoded payload contains executable code markers';
                continue;
            }

            if (preg_match('/(?:phpinfo|FilesMan|auth_pass|multipart\/form-data|\/bin\/sh)/i', $decoded) === 1) {
                $signals['scored'][] = ['label' => 'decoded payload contains shell interface markers', 'score' => 3];
            }
        }

        return $signals;
    }

    /**
     * @return array{high: array<int, string>, scored: array<int, array{label: string, score: int}>}
     */
    private function pathSignals(string $relativePath, string $contents): array
    {
        $signals = [
            'high' => [],
            'scored' => [],
        ];

        if ($this->isSensitiveCoreFile($relativePath)) {
            if (preg_match('/https?:\/\/(?:cdn\.jsdelivr|unpkg|cdnjs)\.com/i', $contents) === 1) {
                $signals['high'][] = 'remote script injection inside a WordPress bootstrap file';
            }

            if (preg_match('/\bnew\s+Function\s*\(|\batob\s*\(|String\.fromCharCode\s*\(/i', $contents) === 1) {
                $signals['high'][] = 'obfuscated runtime code inside a WordPress bootstrap file';
            }
        }

        $basename = strtolower(basename($relativePath));

        if (preg_match('/^[a-z]{8}\.php$/', $basename) === 1 && ($this->isUntrustedExecutableLocation($relativePath) || $this->isUntrackedPluginOrThemeFile($relativePath))) {
            $signals['scored'][] = ['label' => 'randomized PHP filename', 'score' => 1];
        }

        if ($this->isUntrustedExecutableLocation($relativePath)) {
            $signals['scored'][] = ['label' => 'executable file in an untrusted location', 'score' => 3];
        }

        if ($this->isUntrackedPluginOrThemeFile($relativePath)) {
            $signals['scored'][] = ['label' => 'file is inside an untracked plugin or theme directory', 'score' => 1];
        }

        return $signals;
    }

    /**
     * @return array{high: array<int, string>, scored: array<int, array{label: string, score: int}>}
     */
    private function directorySignals(string $relativePath, string $path, string $contents): array
    {
        $signals = [
            'high' => [],
            'scored' => [],
        ];

        if ($this->shouldSuppressDirectoryHeuristics($relativePath)) {
            return $signals;
        }

        $profile = $this->getDirectoryProfile(dirname($path));

        if (($profile['loader_file_count'] ?? 0) >= 1 && ($profile['random_fragment_count'] ?? 0) >= 5) {
            $signals['high'][] = 'staged shell loader with many same-directory fragments';
        }

        if (($profile['webshell_marker_count'] ?? 0) >= 2) {
            $signals['scored'][] = ['label' => 'same directory contains multiple webshell interface markers', 'score' => 3];
        }

        if (($profile['command_exec_count'] ?? 0) >= 2) {
            $signals['scored'][] = ['label' => 'same directory contains multiple command execution paths', 'score' => 3];
        }

        if (($profile['random_fragment_count'] ?? 0) >= 4 && preg_match('/^[a-z]{8}\.php$/', strtolower(basename($relativePath))) === 1) {
            $signals['scored'][] = ['label' => 'randomized PHP fragment inside a suspicious directory cluster', 'score' => 2];
        }

        if ((substr_count($contents, 'include_once __DIR__') + substr_count($contents, 'require_once __DIR__')) >= 5) {
            $signals['high'][] = 'loader file assembles code from many local fragments';
        }

        return $signals;
    }

    /**
     * @return array<string, int>
     */
    private function getDirectoryProfile(string $directory): array
    {
        $normalizedDirectory = wp_normalize_path($directory);

        if (isset($this->directoryProfiles[$normalizedDirectory])) {
            return $this->directoryProfiles[$normalizedDirectory];
        }

        $profile = [
            'loader_file_count' => 0,
            'random_fragment_count' => 0,
            'webshell_marker_count' => 0,
            'command_exec_count' => 0,
        ];

        $items = @scandir($directory);

        if (! is_array($items)) {
            $this->directoryProfiles[$normalizedDirectory] = $profile;
            return $profile;
        }

        $sampledFiles = 0;

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

            $filePath = $directory . DIRECTORY_SEPARATOR . $item;

            if (! is_file($filePath) || ! $this->shouldScanFile($filePath) || ! is_readable($filePath)) {
                continue;
            }

            $sampledFiles++;

            if (preg_match('/^[a-z]{8}\.php$/', strtolower($item)) === 1) {
                $profile['random_fragment_count']++;
            }

            if ($sampledFiles > self::MAX_DIRECTORY_PROFILE_FILES) {
                break;
            }

            $contents = file_get_contents($filePath, false, null, 0, 32768);

            if (! is_string($contents) || $contents === '') {
                continue;
            }

            if ((substr_count($contents, 'include_once __DIR__') + substr_count($contents, 'require_once __DIR__')) >= 3) {
                $profile['loader_file_count']++;
            }

            if (preg_match('/(?:FilesMan|auth_pass|multipart\/form-data|phpinfo|\/etc\/passwd)/i', $contents) === 1) {
                $profile['webshell_marker_count']++;
            }

            if (preg_match('/\b(?:shell_exec|passthru|proc_open|popen|system|exec|fsockopen|stream_socket_client)\s*\(/i', $contents) === 1) {
                $profile['command_exec_count']++;
            }
        }

        $this->directoryProfiles[$normalizedDirectory] = $profile;

        return $profile;
    }

    /**
     * @param array<string, string> $hashes
     * @return array{status: string, source?: string}
     */
    private function evaluateIntegrity(string $relativePath, array $hashes): array
    {
        $canonicalCorePath = $this->canonicalCoreRelativePath($relativePath);
        $coreChecksums = $canonicalCorePath !== null ? $this->coreChecksumsForRelativePath($relativePath) : null;

        if ($canonicalCorePath !== null && is_array($coreChecksums) && isset($coreChecksums[$canonicalCorePath])) {
            return [
                'status' => $this->matchesKnownChecksum((string) $coreChecksums[$canonicalCorePath], $hashes) ? 'trusted' : 'mismatch',
                'source' => 'core_checksum',
            ];
        }

        $pluginContext = $this->resolvePluginContext($relativePath);

        if ($pluginContext !== null) {
            $checksums = $this->getPluginChecksums($pluginContext['slug'], $pluginContext['version']);

            if (isset($checksums[$pluginContext['package_relative']])) {
                return [
                    'status' => $this->matchesKnownChecksum((string) $checksums[$pluginContext['package_relative']], $hashes) ? 'trusted' : 'mismatch',
                    'source' => 'plugin_checksum',
                ];
            }
        }

        $themeContext = $this->resolveThemeContext($relativePath);

        if ($themeContext !== null) {
            $checksums = $this->getThemeChecksums($themeContext['slug'], $themeContext['version']);

            if (isset($checksums[$themeContext['package_relative']])) {
                return [
                    'status' => $this->matchesKnownChecksum((string) $checksums[$themeContext['package_relative']], $hashes) ? 'trusted' : 'mismatch',
                    'source' => 'theme_checksum',
                ];
            }
        }

        $baseline = $this->getBaselineHashes();

        if (isset($baseline[$relativePath])) {
            return [
                'status' => strtolower((string) $baseline[$relativePath]) === ($hashes['sha256'] ?? '') ? 'trusted' : 'mismatch',
                'source' => 'local_baseline',
            ];
        }

        return ['status' => 'unknown'];
    }

    private function scheduleNextBatch(): void
    {
        if (! wp_next_scheduled(self::CRON_HOOK)) {
            wp_schedule_single_event(time() + self::BATCH_DELAY_SECONDS, self::CRON_HOOK);
        }

        if (function_exists('spawn_cron')) {
            spawn_cron(time());
        }

        wp_remote_post(
            site_url('wp-cron.php?doing_wp_cron=' . rawurlencode((string) microtime(true))),
            [
                'timeout' => 1,
                'blocking' => false,
                'headers' => [
                    'Cache-Control' => 'no-cache',
                ],
                // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- This is the WordPress core local SSL verification filter.
                'sslverify' => apply_filters('https_local_ssl_verify', false),
            ]
        );
    }

    private function beginMonitoring(): void
    {
        if (! wp_next_scheduled(self::MONITOR_CRON_HOOK)) {
            wp_schedule_event(time() + self::MONITOR_INTERVAL_SECONDS, self::MONITOR_SCHEDULE, self::MONITOR_CRON_HOOK);
        }
    }

    private function endMonitoring(): void
    {
        wp_clear_scheduled_hook(self::MONITOR_CRON_HOOK);
    }

    private function ensureRuntimeDirectory(): string
    {
        $uploadDir = wp_upload_dir();
        $runtimeDir = trailingslashit($uploadDir['basedir']) . 'firephage-security';

        if (! wp_mkdir_p($runtimeDir)) {
            throw new \RuntimeException('Unable to create the FirePhage runtime directory.');
        }

        return $runtimeDir;
    }

    private function shouldSkipDirectory(string $path): bool
    {
        $normalized = wp_normalize_path($path);

        if ($this->matchesConfiguredExclusion($normalized, true)) {
            return true;
        }

        foreach (['/cache/', '/uploads/', '/upgrade/', '/backups/', '/ai1wm-backups/', '/node_modules/', '/vendor/bin/', '/vendor/composer/', '/vendor/symfony/', '/vendor/psr/', '/.git/'] as $segment) {
            if ($this->contains($normalized, $segment)) {
                return true;
            }
        }

        return false;
    }

    private function shouldScanFile(string $path): bool
    {
        $normalized = wp_normalize_path($path);

        if ($this->matchesConfiguredExclusion($normalized, false)) {
            return false;
        }

        if ($this->endsWith($normalized, '.min.js')) {
            return false;
        }

        if ($this->startsWith($normalized, wp_normalize_path(FIREPHAGE_SECURITY_PATH))) {
            return false;
        }

        return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), $this->allowedExtensions, true);
    }

    private function shouldSuppressDirectoryHeuristics(string $relativePath): bool
    {
        $normalized = '/' . ltrim(wp_normalize_path($relativePath), '/');

        foreach (['/vendor/', '/tests/', '/test/', '/dist/', '/build/'] as $segment) {
            if ($this->contains($normalized, $segment)) {
                return true;
            }
        }

        $basename = strtolower(basename($normalized));

        return in_array($basename, ['bootstrap.php', 'autoload.php', 'autoload_real.php', 'classloader.php', 'installedversions.php'], true);
    }

    private function matchesConfiguredExclusion(string $absolutePath, bool $isDirectory): bool
    {
        $relative = '/' . ltrim($this->relativePath($absolutePath), '/');

        if ($relative === '/' || $relative === '') {
            return false;
        }

        $candidate = $isDirectory ? trailingslashit(wp_normalize_path($relative)) : wp_normalize_path($relative);

        foreach ($this->configuredExclusionPatterns() as $pattern) {
            if ($this->matchesWildcardPattern($pattern, $candidate)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return string[]
     */
    private function configuredExclusionPatterns(): array
    {
        if ($this->exclusionPatterns !== null) {
            return $this->exclusionPatterns;
        }

        $settings = get_option(Settings::OPTION_KEY, []);
        $raw = is_array($settings) ? (string) ($settings['malware_scan_exclusions'] ?? '') : '';
        $parts = preg_split('/[\r\n,]+/', $raw) ?: [];
        $patterns = [];

        foreach ($parts as $part) {
            $pattern = trim(wp_normalize_path((string) $part));

            if ($pattern === '') {
                continue;
            }

            $patterns[] = '/' . ltrim($pattern, '/');
        }

        $this->exclusionPatterns = array_values(array_unique($patterns));

        return $this->exclusionPatterns;
    }

    private function matchesWildcardPattern(string $pattern, string $candidate): bool
    {
        $regex = '#^' . str_replace(['\*', '\?'], ['.*', '.'], preg_quote(wp_normalize_path($pattern), '#')) . '$#i';

        return (bool) preg_match($regex, wp_normalize_path($candidate));
    }

    private function loadSignatureFeed(): void
    {
        if ($this->signatureFeedLoaded) {
            return;
        }

        $this->signatureFeedLoaded = true;
        $settings = get_option(Settings::OPTION_KEY, []);

        if (! is_array($settings) || (($settings['use_firephage_signature_feed'] ?? '1') !== '1')) {
            return;
        }

        $serviceUrl = (string) ($settings['checksum_service_url'] ?? '');
        $freeToken = (string) ($settings['free_signature_token'] ?? '');

        if ($serviceUrl === '' || $freeToken === '') {
            return;
        }

        $transientKey = 'firephage_security_remote_signatures_' . md5($serviceUrl . '|' . $freeToken);
        $cached = get_transient($transientKey);

        if (is_array($cached)) {
            $this->mergeSignaturePayload($cached);
            return;
        }

        $response = $this->getClient()->fetchProtectedSignatures($serviceUrl, $freeToken);

        if (is_wp_error($response) || ! is_array($response)) {
            return;
        }

        set_transient($transientKey, $response, self::SIGNATURE_CACHE_TTL);
        $this->mergeSignaturePayload($response);
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    public function refreshSignatureFeed()
    {
        $settings = get_option(Settings::OPTION_KEY, []);

        if (! is_array($settings) || (($settings['use_firephage_signature_feed'] ?? '1') !== '1')) {
            return new WP_Error('signature_feed_disabled', __('FirePhage signature updates are currently disabled.', 'firephage-security'));
        }

        $serviceUrl = (string) ($settings['checksum_service_url'] ?? '');
        $freeToken = (string) ($settings['free_signature_token'] ?? '');

        if ($serviceUrl === '' || $freeToken === '') {
            return new WP_Error('missing_signature_token', __('A verified free FirePhage signature token is required before signatures can be refreshed.', 'firephage-security'));
        }

        $transientKey = 'firephage_security_remote_signatures_' . md5($serviceUrl . '|' . $freeToken);
        delete_transient($transientKey);

        $response = $this->getClient()->fetchProtectedSignatures($serviceUrl, $freeToken);

        if (is_wp_error($response) || ! is_array($response)) {
            return $response;
        }

        set_transient($transientKey, $response, self::SIGNATURE_CACHE_TTL);

        return $response;
    }

    /**
     * @param array<string, mixed> $payload
     */
    private function mergeSignaturePayload(array $payload): void
    {
        $highConfidenceHashes = isset($payload['high_confidence_hashes']) && is_array($payload['high_confidence_hashes'])
            ? $payload['high_confidence_hashes']
            : [];
        $repoSyncHashes = isset($payload['repo_sync_hashes']) && is_array($payload['repo_sync_hashes'])
            ? $payload['repo_sync_hashes']
            : [];
        $maliciousDomains = isset($payload['malicious_domains']) && is_array($payload['malicious_domains'])
            ? $payload['malicious_domains']
            : [];
        $maliciousStrings = isset($payload['malicious_strings']) && is_array($payload['malicious_strings'])
            ? $payload['malicious_strings']
            : [];
        $highConfidence = isset($payload['high_confidence_patterns']) && is_array($payload['high_confidence_patterns'])
            ? $payload['high_confidence_patterns']
            : [];
        $heuristics = isset($payload['heuristic_patterns']) && is_array($payload['heuristic_patterns'])
            ? $payload['heuristic_patterns']
            : [];

        foreach ($highConfidenceHashes as $entry) {
            if (! is_array($entry)) {
                continue;
            }

            $sha256 = isset($entry['sha256']) ? strtolower(trim((string) $entry['sha256'])) : '';
            $label = isset($entry['label']) ? trim((string) $entry['label']) : '';

            if ($sha256 === '' || $label === '' || preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
                continue;
            }

            $this->highConfidenceHashes[$sha256] = $label;
        }

        foreach ($repoSyncHashes as $entry) {
            if (! is_array($entry)) {
                continue;
            }

            $algorithm = strtolower(trim((string) ($entry['algorithm'] ?? '')));
            $hashValue = strtolower(trim((string) ($entry['hash_value'] ?? '')));
            $label = trim((string) ($entry['label'] ?? 'known malware hash'));
            switch ($algorithm) {
                case 'md5':
                    $expectedLength = 32;
                    break;

                case 'sha1':
                    $expectedLength = 40;
                    break;

                case 'sha256':
                    $expectedLength = 64;
                    break;

                default:
                    $expectedLength = 0;
                    break;
            }

            if ($expectedLength === 0 || $hashValue === '' || strlen($hashValue) !== $expectedLength || preg_match('/^[a-f0-9]+$/', $hashValue) !== 1) {
                continue;
            }

            $this->repoSyncHashes[$algorithm][$hashValue] = $label;
        }

        foreach ($maliciousDomains as $domain) {
            if (! is_string($domain)) {
                continue;
            }

            $domain = strtolower(trim($domain));

            if ($domain === '' || preg_match('/^(?:[a-z0-9-]+\.)+[a-z]{2,24}$/', $domain) !== 1) {
                continue;
            }

            $this->maliciousDomains[$domain] = true;
        }

        foreach ($maliciousStrings as $entry) {
            if (! is_array($entry)) {
                continue;
            }

            $needle = (string) ($entry['needle'] ?? '');
            $label = trim((string) ($entry['label'] ?? 'malicious string match'));

            if ($needle === '' || $label === '') {
                continue;
            }

            $this->maliciousStrings[$needle] = $label;
        }

        foreach ($highConfidence as $entry) {
            if (! is_array($entry)) {
                continue;
            }

            $pattern = isset($entry['pattern']) ? (string) $entry['pattern'] : '';
            $label = isset($entry['label']) ? (string) $entry['label'] : '';

            if ($pattern === '' || $label === '' || ! $this->isValidRegex($pattern)) {
                continue;
            }

            $this->highConfidencePatterns[$pattern] = $label;
        }

        foreach ($heuristics as $entry) {
            if (! is_array($entry)) {
                continue;
            }

            $pattern = isset($entry['pattern']) ? (string) $entry['pattern'] : '';
            $label = isset($entry['label']) ? (string) $entry['label'] : '';
            $score = isset($entry['score']) ? (int) $entry['score'] : 0;

            if ($pattern === '' || $label === '' || $score < 1 || ! $this->isValidRegex($pattern)) {
                continue;
            }

            $this->heuristicPatterns[$pattern] = [
                'label' => $label,
                'score' => $score,
            ];
        }
    }

    /**
     * @return array<int, string>
     */
    private function matchMaliciousDomains(string $contents): array
    {
        if ($this->maliciousDomains === [] || $contents === '') {
            return [];
        }

        if (! $this->contains($contents, '.') && ! $this->contains($contents, 'http')) {
            return [];
        }

        preg_match_all(
            '/https?:\/\/([^\/\s"\'<]+)|\b((?:[a-z0-9-]+\.)+[a-z]{2,24})\b/i',
            $contents,
            $matches,
            PREG_SET_ORDER
        );

        if ($matches === []) {
            return [];
        }

        $found = [];

        foreach ($matches as $match) {
            $domain = strtolower((string) ($match[1] !== '' ? $match[1] : $match[2]));
            $domain = preg_replace('/:\d+$/', '', $domain);
            $domain = preg_replace('/^www\./', '', $domain);
            $domain = rtrim($domain, '.');

            if (! is_string($domain) || $domain === '') {
                continue;
            }

            if (! isset($this->maliciousDomains[$domain])) {
                continue;
            }

            $found[$domain] = true;
        }

        return array_keys($found);
    }

    /**
     * @return array<int, string>
     */
    private function matchMaliciousStrings(string $contents): array
    {
        if ($this->maliciousStrings === [] || $contents === '') {
            return [];
        }

        $matched = [];

        foreach ($this->maliciousStrings as $needle => $label) {
            if ($needle === '' || strpos($contents, $needle) === false) {
                continue;
            }

            $matched[] = $label;
        }

        return $matched;
    }

    private function isValidRegex(string $pattern): bool
    {
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Temporarily suppresses warnings while validating user-provided regex syntax.
        $previousHandler = set_error_handler(
            static function (): bool {
                return true;
            }
        );

        $result = preg_match($pattern, '');

        restore_error_handler();

        return $result !== false;
    }

    private function shouldSeedBaseline(string $relativePath): bool
    {
        if ($this->canonicalCoreRelativePath($relativePath) !== null) {
            return false;
        }

        if ($this->isUntrustedExecutableLocation($relativePath) || $this->isUntrackedPluginOrThemeFile($relativePath)) {
            return false;
        }

        return true;
    }

    private function isSensitiveCoreFile(string $relativePath): bool
    {
        $canonicalCorePath = $this->canonicalCoreRelativePath($relativePath);

        if ($canonicalCorePath === null) {
            return false;
        }

        return in_array(
            $canonicalCorePath,
            [
                'index.php',
                'wp-blog-header.php',
                'wp-comments-post.php',
                'wp-config.php',
                'wp-cron.php',
                'wp-links-opml.php',
                'wp-load.php',
                'wp-login.php',
                'wp-settings.php',
                'xmlrpc.php',
            ],
            true
        );
    }

    private function isDeletableSuspiciousPath(string $relativePath): bool
    {
        if ($this->isSensitiveCoreFile($relativePath)) {
            return false;
        }

        $canonicalCorePath = $this->canonicalCoreRelativePath($relativePath);

        if ($canonicalCorePath !== null && ($this->startsWith($canonicalCorePath, 'wp-admin/') || $this->startsWith($canonicalCorePath, 'wp-includes/'))) {
            return false;
        }

        return preg_match('/\.(?:php|phtml|php5|php7|php8|inc|js)$/i', $relativePath) === 1;
    }

    private function isUntrustedExecutableLocation(string $relativePath): bool
    {
        if (! preg_match('/\.(?:php|phtml|php5|php7|php8|inc|js)$/i', $relativePath)) {
            return false;
        }

        if ($this->canonicalCoreRelativePath($relativePath) !== null) {
            return false;
        }

        if (! $this->contains($relativePath, '/')) {
            return true;
        }

        return $this->startsWith($relativePath, 'wp-content/') === false
            && $this->startsWith($relativePath, 'wp-admin/') === false
            && $this->startsWith($relativePath, 'wp-includes/') === false;
    }

    private function isUntrackedPluginOrThemeFile(string $relativePath): bool
    {
        if ($this->startsWith($relativePath, 'wp-content/plugins/')) {
            return $this->resolvePluginContext($relativePath) === null;
        }

        if ($this->startsWith($relativePath, 'wp-content/themes/')) {
            return $this->resolveThemeContext($relativePath) === null;
        }

        return false;
    }

    private function canonicalCoreRelativePath(string $relativePath): ?string
    {
        $normalized = ltrim(wp_normalize_path($relativePath), '/');

        if ($normalized === '') {
            return null;
        }

        if ($this->startsWith($normalized, 'wp-admin/') || $this->startsWith($normalized, 'wp-includes/')) {
            return $normalized;
        }

        $segments = explode('/', $normalized);

        foreach (['wp-admin', 'wp-includes'] as $coreDirectory) {
            $index = array_search($coreDirectory, $segments, true);

            if ($index === false) {
                continue;
            }

            return implode('/', array_slice($segments, $index));
        }

        $basename = basename($normalized);
        $rootFiles = [
            'index.php',
            'wp-activate.php',
            'wp-blog-header.php',
            'wp-comments-post.php',
            'wp-config-sample.php',
            'wp-config.php',
            'wp-cron.php',
            'wp-links-opml.php',
            'wp-load.php',
            'wp-login.php',
            'wp-mail.php',
            'wp-settings.php',
            'wp-signup.php',
            'wp-trackback.php',
            'xmlrpc.php',
        ];

        if (! in_array($basename, $rootFiles, true)) {
            return null;
        }

        if ($normalized === $basename) {
            return $basename;
        }

        $prefix = trim(substr($normalized, 0, -strlen('/' . $basename)), '/');

        if ($prefix === '' || $this->startsWith($prefix, 'wp-content/')) {
            return null;
        }

        $absolutePrefix = trailingslashit(wp_normalize_path(ABSPATH)) . $prefix;

        if (is_dir($absolutePrefix . '/wp-admin') && is_dir($absolutePrefix . '/wp-includes')) {
            return $basename;
        }

        return null;
    }

    private function coreRootPrefix(string $relativePath): string
    {
        $normalized = ltrim(wp_normalize_path($relativePath), '/');

        if ($normalized === '') {
            return '';
        }

        if ($this->startsWith($normalized, 'wp-admin/') || $this->startsWith($normalized, 'wp-includes/')) {
            return '';
        }

        $segments = explode('/', $normalized);

        foreach (['wp-admin', 'wp-includes'] as $coreDirectory) {
            $index = array_search($coreDirectory, $segments, true);

            if ($index === false) {
                continue;
            }

            return implode('/', array_slice($segments, 0, $index));
        }

        $basename = basename($normalized);
        $rootFiles = [
            'index.php',
            'wp-activate.php',
            'wp-blog-header.php',
            'wp-comments-post.php',
            'wp-config-sample.php',
            'wp-config.php',
            'wp-cron.php',
            'wp-links-opml.php',
            'wp-load.php',
            'wp-login.php',
            'wp-mail.php',
            'wp-settings.php',
            'wp-signup.php',
            'wp-trackback.php',
            'xmlrpc.php',
        ];

        if (! in_array($basename, $rootFiles, true) || $normalized === $basename) {
            return '';
        }

        return trim(substr($normalized, 0, -strlen('/' . $basename)), '/');
    }

    /**
     * @return array<string, string>|null
     */
    private function coreChecksumsForRelativePath(string $relativePath): ?array
    {
        $detectedVersion = $this->detectCoreVersionForPrefix($this->coreRootPrefix($relativePath));

        if ($detectedVersion !== null) {
            $checksums = $this->getCoreChecksums($detectedVersion);

            if (is_array($checksums)) {
                return $checksums;
            }
        }

        return $this->getCoreChecksums();
    }

    private function detectCoreVersionForPrefix(string $coreRootPrefix): ?string
    {
        $cacheKey = $coreRootPrefix !== '' ? $coreRootPrefix : '__live__';

        if (array_key_exists($cacheKey, $this->detectedCoreVersions)) {
            return $this->detectedCoreVersions[$cacheKey];
        }

        if ($coreRootPrefix === '') {
            require_once ABSPATH . WPINC . '/version.php';

            global $wp_version;

            $this->detectedCoreVersions[$cacheKey] = is_string($wp_version) && $wp_version !== '' ? $wp_version : null;

            return $this->detectedCoreVersions[$cacheKey];
        }

        $versionFile = trailingslashit(wp_normalize_path(ABSPATH)) . $coreRootPrefix . '/wp-includes/version.php';

        if (! is_readable($versionFile)) {
            $this->detectedCoreVersions[$cacheKey] = null;

            return null;
        }

        $contents = file_get_contents($versionFile);

        if (! is_string($contents) || $contents === '') {
            $this->detectedCoreVersions[$cacheKey] = null;

            return null;
        }

        if (preg_match('/\\$wp_version\\s*=\\s*[\'"]([^\'"]+)[\'"]\\s*;/', $contents, $matches) !== 1) {
            $this->detectedCoreVersions[$cacheKey] = null;

            return null;
        }

        $version = trim((string) ($matches[1] ?? ''));
        $this->detectedCoreVersions[$cacheKey] = $version !== '' ? $version : null;

        return $this->detectedCoreVersions[$cacheKey];
    }

    private function recordBaselineHash(string $relativePath, string $hash): void
    {
        $baseline = $this->getBaselineHashes();

        if (($baseline[$relativePath] ?? null) === $hash) {
            return;
        }

        $baseline[$relativePath] = $hash;
        $this->baselineHashes = $baseline;
        $this->baselineDirty = true;
    }

    private function persistBaseline(): void
    {
        if (! $this->baselineDirty || $this->baselineHashes === null) {
            return;
        }

        update_option(self::BASELINE_OPTION_KEY, $this->baselineHashes, false);
        $this->baselineDirty = false;
    }

    /**
     * @return array<string, string>
     */
    private function getBaselineHashes(): array
    {
        if ($this->baselineHashes !== null) {
            return $this->baselineHashes;
        }

        $baseline = get_option(self::BASELINE_OPTION_KEY, []);
        $this->baselineHashes = is_array($baseline) ? $baseline : [];

        return $this->baselineHashes;
    }

    /**
     * @return array<string, string>|null
     */
    private function getCoreChecksums(?string $version = null): ?array
    {
        $cacheKey = $version !== null && trim($version) !== '' ? trim($version) : '__live__';

        if (array_key_exists($cacheKey, $this->coreChecksumSets)) {
            return $this->coreChecksumSets[$cacheKey];
        }

        require_once ABSPATH . 'wp-admin/includes/update.php';
        require_once ABSPATH . WPINC . '/version.php';

        global $wp_local_package;
        global $wp_version;

        $locale = is_string($wp_local_package) && $wp_local_package !== '' ? $wp_local_package : 'en_US';
        $targetVersion = $cacheKey === '__live__' ? (string) $wp_version : $cacheKey;
        $checksums = get_core_checksums($targetVersion, $locale);

        if (! is_array($checksums)) {
            $this->coreChecksumSets[$cacheKey] = null;
            return null;
        }

        $normalized = [];

        foreach ($checksums as $path => $hash) {
            $normalized[ltrim((string) $path, '/')] = strtolower((string) $hash);
        }

        $this->coreChecksumSets[$cacheKey] = $normalized;

        if ($cacheKey === '__live__') {
            $this->coreChecksums = $normalized;
        }

        return $normalized;
    }

    /**
     * @return array<int, array<string, string>>
     */
    private function getPluginPackages(): array
    {
        if ($this->pluginPackages !== null) {
            return $this->pluginPackages;
        }

        require_once ABSPATH . 'wp-admin/includes/plugin.php';

        $packages = [];

        foreach (get_plugins() as $pluginFile => $pluginData) {
            $directory = dirname($pluginFile);
            $packageRoot = $directory === '.' ? basename($pluginFile) : $directory;
            $slug = $directory === '.' ? basename($pluginFile, '.php') : basename($directory);
            $prefix = 'wp-content/plugins/' . trim($packageRoot, '/') . '/';

            if ($directory === '.') {
                $prefix = 'wp-content/plugins/';
            }

            $packages[] = [
                'slug' => $slug,
                'version' => (string) ($pluginData['Version'] ?? ''),
                'prefix' => $prefix,
                'main_file' => $pluginFile,
                'package_root' => $packageRoot,
            ];
        }

        usort($packages, [$this, 'comparePackagePrefixLength']);

        $this->pluginPackages = $packages;

        return $this->pluginPackages;
    }

    /**
     * @return array<string, string>|null
     */
    private function resolvePluginContext(string $relativePath): ?array
    {
        foreach ($this->getPluginPackages() as $package) {
            $prefix = (string) $package['prefix'];

            if (! $this->startsWith($relativePath, $prefix)) {
                continue;
            }

            $packageRelative = ltrim(substr($relativePath, strlen($prefix)), '/');

            if ($package['package_root'] === basename((string) $package['main_file']) && $packageRelative === '') {
                $packageRelative = basename((string) $package['main_file']);
            }

            return [
                'slug' => (string) $package['slug'],
                'version' => (string) $package['version'],
                'package_relative' => $packageRelative,
            ];
        }

        return null;
    }

    /**
     * @return array<int, array<string, string>>
     */
    private function getThemePackages(): array
    {
        if ($this->themePackages !== null) {
            return $this->themePackages;
        }

        require_once ABSPATH . 'wp-admin/includes/theme.php';

        $packages = [];

        foreach (wp_get_themes() as $slug => $theme) {
            $stylesheet = (string) $theme->get_stylesheet();
            $version = (string) $theme->get('Version');

            if ($stylesheet === '' || $version === '') {
                continue;
            }

            $packages[] = [
                'slug' => $stylesheet,
                'version' => $version,
                'prefix' => 'wp-content/themes/' . trim($stylesheet, '/') . '/',
            ];
        }

        usort($packages, [$this, 'comparePackagePrefixLength']);

        $this->themePackages = $packages;

        return $this->themePackages;
    }

    /**
     * @return array<string, string>|null
     */
    private function resolveThemeContext(string $relativePath): ?array
    {
        foreach ($this->getThemePackages() as $package) {
            $prefix = (string) $package['prefix'];

            if (! $this->startsWith($relativePath, $prefix)) {
                continue;
            }

            return [
                'slug' => (string) $package['slug'],
                'version' => (string) $package['version'],
                'package_relative' => ltrim(substr($relativePath, strlen($prefix)), '/'),
            ];
        }

        return null;
    }

    /**
     * @return array<string, string>
     */
    private function getPluginChecksums(string $slug, string $version): array
    {
        $cacheKey = $slug . ':' . $version;

        if (isset($this->pluginChecksums[$cacheKey])) {
            return $this->pluginChecksums[$cacheKey];
        }

        $transientKey = 'firephage_security_plugin_checksums_' . md5($cacheKey);
        $cached = get_transient($transientKey);

        if (is_array($cached)) {
            $this->pluginChecksums[$cacheKey] = $cached;
            return $cached;
        }

        if ($slug === '' || $version === '') {
            $this->pluginChecksums[$cacheKey] = [];
            return [];
        }

        $firePhageChecksums = $this->fetchChecksumsFromFirePhage('plugin', $slug, $version);

        if ($firePhageChecksums !== []) {
            set_transient($transientKey, $firePhageChecksums, WEEK_IN_SECONDS);
            $this->pluginChecksums[$cacheKey] = $firePhageChecksums;

            return $firePhageChecksums;
        }

        $response = wp_remote_get(
            'https://downloads.wordpress.org/plugin-checksums/' . rawurlencode($slug) . '/' . rawurlencode($version) . '.json',
            [
                'timeout' => 10,
                'headers' => ['Accept' => 'application/json'],
            ]
        );

        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            $this->pluginChecksums[$cacheKey] = [];
            return [];
        }

        $payload = json_decode(wp_remote_retrieve_body($response), true);
        $files = is_array($payload['files'] ?? null) ? $payload['files'] : [];
        $checksums = [];

        foreach ($files as $path => $hashes) {
            if (! is_array($hashes)) {
                continue;
            }

            $checksum = $hashes['sha256'] ?? $hashes['md5'] ?? null;

            if (! is_string($checksum) || $checksum === '') {
                continue;
            }

            $checksums[ltrim((string) $path, '/')] = strtolower($checksum);
        }

        set_transient($transientKey, $checksums, WEEK_IN_SECONDS);
        $this->pluginChecksums[$cacheKey] = $checksums;

        return $checksums;
    }

    /**
     * @return array<string, string>
     */
    private function getThemeChecksums(string $slug, string $version): array
    {
        $cacheKey = $slug . ':' . $version;

        if (isset($this->themeChecksums[$cacheKey])) {
            return $this->themeChecksums[$cacheKey];
        }

        $transientKey = 'firephage_security_theme_checksums_' . md5($cacheKey);
        $cached = get_transient($transientKey);

        if (is_array($cached)) {
            $this->themeChecksums[$cacheKey] = $cached;
            return $cached;
        }

        if ($slug === '' || $version === '') {
            $this->themeChecksums[$cacheKey] = [];
            return [];
        }

        $firePhageChecksums = $this->fetchChecksumsFromFirePhage('theme', $slug, $version);

        if ($firePhageChecksums !== []) {
            set_transient($transientKey, $firePhageChecksums, WEEK_IN_SECONDS);
            $this->themeChecksums[$cacheKey] = $firePhageChecksums;

            return $firePhageChecksums;
        }

        $response = wp_remote_get(
            'https://api.wordpress.org/themes/checksums/1.0/?slug=' . rawurlencode($slug) . '&version=' . rawurlencode($version),
            [
                'timeout' => 10,
                'headers' => ['Accept' => 'application/json'],
            ]
        );

        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            $this->themeChecksums[$cacheKey] = [];
            return [];
        }

        $payload = json_decode(wp_remote_retrieve_body($response), true);
        $files = is_array($payload['checksums'] ?? null) ? $payload['checksums'] : [];
        $checksums = [];

        foreach ($files as $path => $checksum) {
            if (! is_string($checksum) || $checksum === '') {
                continue;
            }

            $checksums[ltrim((string) $path, '/')] = strtolower($checksum);
        }

        set_transient($transientKey, $checksums, WEEK_IN_SECONDS);
        $this->themeChecksums[$cacheKey] = $checksums;

        return $checksums;
    }

    private function isRestorableIntegritySource(string $source): bool
    {
        return in_array($source, ['core_checksum', 'plugin_checksum', 'theme_checksum'], true);
    }

    /**
     * @return true|WP_Error
     */
    private function restoreIntegrityPath(string $relativePath, string $source)
    {
        if (! $this->isRestorableIntegritySource($source)) {
            return new WP_Error('restore_unavailable', __('This file cannot be restored from an official package.', 'firephage-security'));
        }

        $referenceContext = $this->resolveReferenceFileContext($relativePath, $source);

        if ($referenceContext === null) {
            return new WP_Error('restore_unavailable', __('A trusted restore file is not available for this item.', 'firephage-security'));
        }

        $reference = $this->fetchReferenceFileFromFirePhage(
            $referenceContext['type'],
            $referenceContext['slug'],
            $referenceContext['version'],
            $referenceContext['path']
        );

        if (is_wp_error($reference)) {
            return $reference;
        }

        $referenceContent = isset($reference['content']) && is_string($reference['content']) ? $reference['content'] : '';

        if ($referenceContent === '') {
            return new WP_Error('restore_unavailable', __('The official reference file is not available for restore.', 'firephage-security'));
        }

        $absolutePath = trailingslashit(wp_normalize_path(ABSPATH)) . ltrim(wp_normalize_path($relativePath), '/');
        $rootPath = trailingslashit(wp_normalize_path(ABSPATH));

        if (! $this->startsWith($absolutePath, $rootPath)) {
            return new WP_Error('invalid_file', __('The requested file is outside the WordPress installation.', 'firephage-security'));
        }

        $directory = dirname($absolutePath);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Restore requires checking whether the target directory is writable before replacing a file.
        if (! is_dir($directory) || ! is_writable($directory)) {
            return new WP_Error('restore_unavailable', __('The destination directory is not writable.', 'firephage-security'));
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Restore requires checking whether an existing target file is writable before replacement.
        if (file_exists($absolutePath) && (! is_file($absolutePath) || ! is_writable($absolutePath))) {
            return new WP_Error('restore_unavailable', __('The local file is not writable.', 'firephage-security'));
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Restores a single verified WordPress file directly at the requested path.
        if (file_put_contents($absolutePath, $referenceContent) === false) {
            return new WP_Error('restore_unavailable', __('The official file could not be written locally.', 'firephage-security'));
        }

        return true;
    }

    /**
     * @return array<string, string>|null
     */
    private function resolveReferenceFileContext(string $relativePath, string $source): ?array
    {
        switch ($source) {
            case 'core_checksum':
                $version = $this->detectCoreVersionForPrefix($this->coreRootPrefix($relativePath));

                if (! is_string($version) || $version === '') {
                    $version = get_bloginfo('version');
                }

                if (! is_string($version) || $version === '') {
                    return null;
                }

                return [
                    'type' => 'core',
                    'slug' => '',
                    'version' => $version,
                    'path' => ltrim($relativePath, '/'),
                ];

            case 'plugin_checksum':
                $pluginContext = $this->resolvePluginContext($relativePath);

                if ($pluginContext === null) {
                    return null;
                }

                return [
                    'type' => 'plugin',
                    'slug' => (string) $pluginContext['slug'],
                    'version' => (string) $pluginContext['version'],
                    'path' => (string) $pluginContext['package_relative'],
                ];

            case 'theme_checksum':
                $themeContext = $this->resolveThemeContext($relativePath);

                if ($themeContext === null) {
                    return null;
                }

                return [
                    'type' => 'theme',
                    'slug' => (string) $themeContext['slug'],
                    'version' => (string) $themeContext['version'],
                    'path' => (string) $themeContext['package_relative'],
                ];
        }

        return null;
    }

    /**
     * @param array<int, array<string, mixed>> $findings
     * @param array<string, mixed> $finding
     * @return array<int, array<string, mixed>>
     */
    private function appendFinding(array $findings, array $finding): array
    {
        $findings[] = $finding;

        return $findings;
    }

    /**
     * @param array<int, string> $reasons
     * @return array<string, mixed>
     */
    private function makeFinding(string $relativePath, string $type, string $confidence, string $source, array $reasons): array
    {
        return [
            'file' => $relativePath,
            'type' => $type,
            'confidence' => $confidence,
            'source' => $source,
            'reasons' => array_values(array_unique($reasons)),
        ];
    }

    private function integrityReason(string $source): string
    {
        switch ($source) {
            case 'core_checksum':
                return __('Does not match official WordPress core checksums.', 'firephage-security');

            case 'plugin_checksum':
                return __('Does not match the official WordPress.org plugin package checksums.', 'firephage-security');

            case 'theme_checksum':
                return __('Does not match the official WordPress.org theme package checksums.', 'firephage-security');

            case 'local_baseline':
                return __('Differs from the local clean-file baseline captured by earlier scans.', 'firephage-security');

            default:
                return __('File integrity differs from the expected baseline.', 'firephage-security');
        }
    }

    private function relativePath(string $path): string
    {
        $normalizedRoot = trailingslashit(wp_normalize_path(ABSPATH));
        $normalizedPath = wp_normalize_path($path);

        if ($this->startsWith($normalizedPath, $normalizedRoot)) {
            return ltrim(substr($normalizedPath, strlen($normalizedRoot)), '/');
        }

        return $normalizedPath;
    }

    /**
     * @param array<string, string> $hashes
     */
    private function matchesKnownChecksum(string $expectedChecksum, array $hashes): bool
    {
        $normalized = strtolower(trim($expectedChecksum));

        switch (strlen($normalized)) {
            case 32:
                return ($hashes['md5'] ?? '') === $normalized;

            case 64:
                return ($hashes['sha256'] ?? '') === $normalized;

            default:
                return false;
        }
    }

    /**
     * @return array<string, string>
     */
    private function fetchChecksumsFromFirePhage(string $type, string $slug, string $version): array
    {
        $settings = get_option(Settings::OPTION_KEY, []);

        if (! is_array($settings) || (($settings['use_firephage_checksum_cache'] ?? '1') !== '1')) {
            return [];
        }

        $serviceUrl = isset($settings['checksum_service_url']) && is_string($settings['checksum_service_url']) && $settings['checksum_service_url'] !== ''
            ? $settings['checksum_service_url']
            : 'https://firephage.com';

        $response = $this->getClient()->fetchPublicChecksums($serviceUrl, $type, $slug, $version);

        if (is_wp_error($response)) {
            return [];
        }

        $checksums = $response['checksums'] ?? null;

        return is_array($checksums) ? $checksums : [];
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    private function fetchReferenceFileFromFirePhage(string $type, string $slug, string $version, string $path)
    {
        $settings = get_option(Settings::OPTION_KEY, []);

        $serviceUrl = isset($settings['checksum_service_url']) && is_string($settings['checksum_service_url']) && $settings['checksum_service_url'] !== ''
            ? $settings['checksum_service_url']
            : 'https://firephage.com';

        return $this->getClient()->fetchPublicReferenceFile($serviceUrl, $type, $version, $path, $slug);
    }

    /**
     * @return array{content: string, truncated: bool}
     */
    private function truncatePreviewContent(string $content): array
    {
        $truncated = strlen($content) > self::PREVIEW_MAX_BYTES;

        if ($truncated) {
            $content = substr($content, 0, self::PREVIEW_MAX_BYTES);
        }

        return [
            'content' => $content,
            'truncated' => $truncated,
        ];
    }

    private function startsWith(string $haystack, string $needle): bool
    {
        return $needle === '' || strpos($haystack, $needle) === 0;
    }

    private function contains(string $haystack, string $needle): bool
    {
        return $needle !== '' && strpos($haystack, $needle) !== false;
    }

    private function endsWith(string $haystack, string $needle): bool
    {
        if ($needle === '') {
            return true;
        }

        return substr($haystack, -strlen($needle)) === $needle;
    }

    /**
     * @param array<string, string> $left
     * @param array<string, string> $right
     */
    private function comparePackagePrefixLength(array $left, array $right): int
    {
        return strlen($right['prefix']) <=> strlen($left['prefix']);
    }

    private function getClient(): Client
    {
        if ($this->client === null) {
            $this->client = new Client();
        }

        return $this->client;
    }
}
