<?php

declare(strict_types=1);

namespace ShieldX\Maintenance;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_mkdir,WordPress.WP.AlternativeFunctions.file_system_operations_is_writable,WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.file_system_operations_copy,WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Restore points intentionally stage file/database backups and adjust plugin-owned archive permissions.

use ShieldX\Core\FileTypeSettings;
use ShieldX\Storage\FileStorage;
use ShieldX\Storage\DurableJobStore;
use ShieldX\Storage\FilesystemAdapter;
use ShieldX\Support\WordPressPaths;

final class RestorePointManager
{
    private const MAX_FILES = 100000;
    private const MAX_BYTES = 2147483648;
    private const PREVIEW_MAX_BYTES = 262144;
    private const MAX_RESTORE_JSON_BYTES = 268435456;
    private const SNAPSHOT_SUFFIX = '.shieldx-snapshot';

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

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

    /** @var DatabaseBackupManager */
    private $databaseBackups;

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

    /** @var RestorePointSettings */
    private $settings;

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

    /** @var FilesystemAdapter */
    private $filesystem;

    /** @var int */
    private $maxFiles = self::MAX_FILES;

    /** @var int */
    private $maxBytes = self::MAX_BYTES;

    public function __construct(FileStorage $storage, DatabaseBackupManager $databaseBackups, string $rootDir = '', ?RestorePointSettings $settings = null, ?FileTypeSettings $fileTypes = null, ?DurableJobStore $durableJobs = null)
    {
        $this->storage = $storage;
        $this->databaseBackups = $databaseBackups;
        $this->durableJobs = $durableJobs ?: new DurableJobStore();
        $this->rootDir = rtrim(str_replace('\\', '/', $rootDir), '/');
        $this->settings = $settings ?: RestorePointSettings::fromWordPress();
        $this->fileTypes = $fileTypes ?: FileTypeSettings::fromWordPress();
        $this->filesystem = FilesystemAdapter::fromWordPress();
    }

    public static function fromWordPress(): self
    {
        return new self(FileStorage::fromWordPress(), DatabaseBackupManager::fromWordPress(), '', RestorePointSettings::fromWordPress(), FileTypeSettings::fromWordPress(), new DurableJobStore());
    }

    /**
     * @param array<int,string> $paths
     * @return array<string,mixed>
     */
    public function create(string $reason, array $paths = [], int $userId = 0, bool $includeDatabase = true, bool $forceFullSiteSnapshot = false): array
    {
        if (!$this->settings->enabled()) {
            return [
                'ok' => false,
                'message' => 'Restore points are disabled by policy.',
                'restore_id' => '',
                'path' => '',
                'files' => 0,
                'bytes_exported' => 0,
                'database_backup_id' => '',
                'truncated' => false,
            ];
        }

        $this->storage->ensure();
        $restoreId = $this->newRestoreId();
        $directory = $this->restoreDirectory() . '/' . $restoreId;
        $filesDirectory = $directory . '/files';
        $this->ensureDirectory($filesDirectory);

        $this->maxFiles = min(self::MAX_FILES, $this->settings->maxFiles());
        $this->maxBytes = min(self::MAX_BYTES, $this->settings->maxBytes());
        $includeDatabase = $includeDatabase && $this->settings->includeDatabase();
        $selectedPaths = $this->expandBackupTargets($paths, $forceFullSiteSnapshot);
        $baseManifest = empty($paths) && !$forceFullSiteSnapshot ? $this->latestFullSiteManifest() : null;
        $baseFileIndex = is_array($baseManifest) ? $this->manifestFileIndex($baseManifest) : [];
        $incremental = empty($paths) && !empty($baseFileIndex);
        $manifest = [
            'restore_id' => $restoreId,
            'format' => 'shieldx-restore-point-v1',
            'created_at' => gmdate('c'),
            'created_by' => $userId,
            'reason' => $this->sanitizeReason($reason),
            'site_url' => function_exists('site_url') ? site_url() : '',
            'root' => $this->rootPath(),
            'include_database' => $includeDatabase,
            'database_backup' => null,
            'snapshot_scope' => empty($paths) ? 'full_site' : 'selected_paths',
            'snapshot_mode' => $incremental ? 'incremental' : 'full',
            'file_export_mode' => $forceFullSiteSnapshot ? 'full_root_policy' : 'policy',
            'full_root_export' => $forceFullSiteSnapshot,
            'base_restore_id' => $incremental ? (string) ($baseManifest['restore_id'] ?? '') : '',
            'hash_algorithm' => 'sha256',
            'files' => [],
            'file_index' => [],
            'files_count' => 0,
            'hashes_count' => 0,
            'indexed_files_count' => 0,
            'metadata_checked_count' => 0,
            'metadata_skipped_count' => 0,
            'hash_checks_count' => 0,
            'file_type_policy' => $this->backupFileTypePolicySummary(),
            'bytes_exported' => 0,
            'truncated' => false,
            'limits' => [
                'max_files' => $this->maxFiles,
                'max_bytes' => $this->maxBytes,
            ],
        ];

        if ($includeDatabase) {
            // Restore points are recovery artifacts, so their database side must
            // remain complete even when the file side is incremental.
            $dbBackup = $this->databaseBackups->create([], 20000, false, 0, true);
            $manifest['database_backup'] = [
                'ok' => !empty($dbBackup['ok']),
                'backup_id' => (string) ($dbBackup['backup_id'] ?? ''),
                'path' => (string) ($dbBackup['path'] ?? ''),
                'message' => (string) ($dbBackup['message'] ?? ''),
                'warning_count' => max(0, (int) ($dbBackup['warning_count'] ?? 0)),
                'warning_details' => $this->sanitizeDatabaseWarningDetails((array) ($dbBackup['warning_details'] ?? [])),
            ];
        }

        foreach ($selectedPaths as $path) {
            $this->capturePath($path, $filesDirectory, $manifest, $baseFileIndex, $incremental);
            if (!empty($manifest['truncated'])) {
                break;
            }
        }
        if ($incremental && empty($manifest['truncated'])) {
            $this->captureDeletedFiles($selectedPaths, $manifest, $baseFileIndex);
        }

        $manifest['files_count'] = count((array) $manifest['files']);
        $manifest['hashes_count'] = count(array_filter((array) $manifest['files'], static function ($file): bool {
            return is_array($file) && (string) ($file['hash'] ?? '') !== '';
        }));
        $manifest['indexed_files_count'] = count((array) $manifest['file_index']);
        $this->writeManifest($restoreId, $manifest);
        $this->enforceRetention();

        $result = [
            'ok' => true,
            'message' => 'Restore point created.',
            'restore_id' => $restoreId,
            'path' => $directory,
            'files' => (int) $manifest['files_count'],
            'bytes_exported' => (int) $manifest['bytes_exported'],
            'database_backup_id' => (string) ($manifest['database_backup']['backup_id'] ?? ''),
            'truncated' => !empty($manifest['truncated']),
        ];

        return BackupStorageManager::fromWordPress()->handleRestorePointCreated($result);
    }

    /**
     * @param array<int,string> $paths
     * @return array<string,mixed>
     */
    public function startProgressiveCreate(string $reason, array $paths = [], int $userId = 0, bool $includeDatabase = true, bool $forceFullSiteSnapshot = false): array
    {
        if (!$this->settings->enabled()) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => 'Restore points are disabled by policy.',
                'restore_id' => '',
            ];
        }

        $this->storage->ensure();
        $restoreId = $this->newRestoreId();
        $directory = $this->restoreDirectory() . '/' . $restoreId;
        $filesDirectory = $directory . '/files';
        $this->ensureDirectory($filesDirectory);

        $this->maxFiles = min(self::MAX_FILES, $this->settings->maxFiles());
        $this->maxBytes = min(self::MAX_BYTES, $this->settings->maxBytes());
        $includeDatabase = $includeDatabase && $this->settings->includeDatabase();
        $selectedPaths = $this->expandBackupTargets($paths, $forceFullSiteSnapshot);
        $baseManifest = empty($paths) && !$forceFullSiteSnapshot ? $this->latestFullSiteManifest() : null;
        $baseFileIndex = is_array($baseManifest) ? $this->manifestFileIndex($baseManifest) : [];
        $incremental = empty($paths) && !empty($baseFileIndex);
        $manifest = [
            'restore_id' => $restoreId,
            'format' => 'shieldx-restore-point-v1',
            'created_at' => gmdate('c'),
            'created_by' => $userId,
            'reason' => $this->sanitizeReason($reason),
            'site_url' => function_exists('site_url') ? site_url() : '',
            'root' => $this->rootPath(),
            'include_database' => $includeDatabase,
            'database_backup' => null,
            'snapshot_scope' => empty($paths) ? 'full_site' : 'selected_paths',
            'snapshot_mode' => $incremental ? 'incremental' : 'full',
            'file_export_mode' => $forceFullSiteSnapshot ? 'full_root_policy' : 'policy',
            'full_root_export' => $forceFullSiteSnapshot,
            'base_restore_id' => $incremental ? (string) ($baseManifest['restore_id'] ?? '') : '',
            'hash_algorithm' => 'sha256',
            'files' => [],
            'file_index' => [],
            'files_count' => 0,
            'hashes_count' => 0,
            'indexed_files_count' => 0,
            'metadata_checked_count' => 0,
            'metadata_skipped_count' => 0,
            'hash_checks_count' => 0,
            'file_type_policy' => $this->backupFileTypePolicySummary(),
            'bytes_exported' => 0,
            'truncated' => false,
            'limits' => [
                'max_files' => $this->maxFiles,
                'max_bytes' => $this->maxBytes,
            ],
        ];

        // Creating a browser-visible job must not wait for a potentially slow
        // database connection or table discovery. The first progressive batch
        // initializes the database export after this durable job state has
        // already been returned to the administrator.
        $phase = $includeDatabase ? 'database_prepare' : 'files';
        $dbBackupId = '';
        $dbProgress = [];

        $job = [
            'backup_type' => 'restore_point',
            'restore_id' => $restoreId,
            'status' => 'running',
            'phase' => $phase,
            'created_at' => gmdate('c'),
            'updated_at' => gmdate('c'),
            'started_at' => microtime(true),
            'directory' => $directory,
            'files_directory' => $filesDirectory,
            'selected_paths' => $selectedPaths,
            'pending_paths' => array_reverse($selectedPaths),
            'base_file_index' => $baseFileIndex,
            'base_file_keys' => array_keys($baseFileIndex),
            'deleted_cursor' => 0,
            'deleted_done' => !$incremental,
            'incremental' => $incremental,
            'database_backup_id' => $dbBackupId,
            'database_progress' => $dbProgress,
            'files_seen' => 0,
            'directories_seen' => 0,
            'batches' => 0,
            'manifest' => $manifest,
        ];

        if (!$this->writeRestoreJob($restoreId, $job)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => 'Could not write restore point job state.',
                'restore_id' => $restoreId,
            ];
        }

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

    /**
     * @return array<string,mixed>
     */
    public function runProgressiveCreateBatch(string $restoreId, int $batchFiles = 2500, int $maxSeconds = 8): array
    {
        $job = $this->readRestoreJob($restoreId);
        if (!$job) {
            return [
                'ok' => false,
                'status' => 'failed',
                'restore_id' => $restoreId,
                'message' => 'Restore point job was not found.',
            ];
        }

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

        $this->maxFiles = min(self::MAX_FILES, $this->settings->maxFiles());
        $this->maxBytes = min(self::MAX_BYTES, $this->settings->maxBytes());
        $started = microtime(true);
        $batchFiles = max(10, min(2500, $batchFiles));
        $maxSeconds = max(2, min(20, $maxSeconds));
        $restoreId = (string) ($job['restore_id'] ?? $restoreId);
        $manifest = isset($job['manifest']) && is_array($job['manifest']) ? $job['manifest'] : [];

        try {
            if ((string) ($job['phase'] ?? 'files') === 'database_prepare') {
                $dbProgress = $this->databaseBackups->startProgressiveBackup([], 20000, false, true);
                $dbBackupId = (string) ($dbProgress['backup_id'] ?? '');

                if ($dbBackupId === '' || (string) ($dbProgress['status'] ?? '') === 'failed') {
                    $job['status'] = 'failed';
                    $job['message'] = (string) ($dbProgress['message'] ?? 'Database backup could not be started.');
                    $job['database_progress'] = $dbProgress;
                    $job['updated_at'] = gmdate('c');
                    $this->writeRestoreJob($restoreId, $job);

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

                $job['database_backup_id'] = $dbBackupId;
                $job['database_progress'] = $dbProgress;
                $job['phase'] = 'database';
                $job['updated_at'] = gmdate('c');
                if (!$this->writeRestoreJob($restoreId, $job)) {
                    $job['status'] = 'failed';
                    $job['message'] = 'Could not persist restore point progress.';

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

                // Return the selected-table count before the first export
                // batch. This lets the browser render real backup progress
                // immediately, even when exporting the first table is slow.
                return $this->publicProgressiveCreateState($job);
            }

            if ((string) ($job['phase'] ?? 'files') === 'database') {
                $dbBackupId = (string) ($job['database_backup_id'] ?? '');
                $dbProgress = $dbBackupId !== '' ? $this->databaseBackups->runProgressiveBackupBatch($dbBackupId, 500, $maxSeconds) : [];
                $job['database_progress'] = $dbProgress;

                if ((string) ($dbProgress['status'] ?? '') === 'running') {
                    $job['batches'] = (int) ($job['batches'] ?? 0) + 1;
                    $job['updated_at'] = gmdate('c');
                    if (!$this->writeRestoreJob($restoreId, $job)) {
                        $job['status'] = 'failed';
                        $job['message'] = 'Could not persist restore point progress.';

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

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

                $manifest['database_backup'] = [
                    'ok' => !empty($dbProgress['ok']) && (string) ($dbProgress['status'] ?? '') === 'completed',
                    'backup_id' => (string) ($dbProgress['backup_id'] ?? $dbBackupId),
                    'path' => (string) ($dbProgress['path'] ?? ''),
                    'message' => (string) ($dbProgress['message'] ?? ''),
                    'warning_count' => max(0, (int) ($dbProgress['warning_count'] ?? 0)),
                    'warning_details' => $this->sanitizeDatabaseWarningDetails((array) ($dbProgress['warning_details'] ?? [])),
                ];
                $job['manifest'] = $manifest;
                $job['phase'] = 'files';
            }

            $processedFiles = 0;
            $processedEntries = 0;
            $pending = isset($job['pending_paths']) && is_array($job['pending_paths']) ? array_values(array_map('strval', $job['pending_paths'])) : [];
            $filesDirectory = (string) ($job['files_directory'] ?? ($this->restoreDirectory() . '/' . $restoreId . '/files'));
            $baseFileIndex = isset($job['base_file_index']) && is_array($job['base_file_index']) ? $job['base_file_index'] : [];
            $incremental = !empty($job['incremental']);

            while (!empty($pending) && $processedFiles < $batchFiles && $processedEntries < ($batchFiles * 20)) {
                $path = (string) array_pop($pending);
                $absolute = $this->normalizePath($path);
                $processedEntries++;
                if ($absolute === '') {
                    continue;
                }

                if (is_dir($absolute)) {
                    $job['directories_seen'] = (int) ($job['directories_seen'] ?? 0) + 1;
                    $children = $this->directoryChildren($absolute);
                    foreach ($children as $child) {
                        $pending[] = $child;
                    }
                } elseif (is_file($absolute)) {
                    $this->captureFile($absolute, $filesDirectory, $manifest, $baseFileIndex, $incremental);
                    $processedFiles++;
                    $job['files_seen'] = (int) ($job['files_seen'] ?? 0) + 1;
                }

                if (!empty($manifest['truncated']) || (microtime(true) - $started) >= $maxSeconds) {
                    break;
                }
            }

            $job['pending_paths'] = $pending;
            $job['manifest'] = $manifest;

            if (!empty($manifest['truncated'])) {
                $job['deleted_done'] = true;
            }

            if (empty($pending) && empty($manifest['truncated']) && empty($job['deleted_done'])) {
                $this->processDeletedFileBatch($job, $manifest, $batchFiles, $maxSeconds, $started);
                $job['manifest'] = $manifest;
            }

            $job['batches'] = (int) ($job['batches'] ?? 0) + 1;
            $job['updated_at'] = gmdate('c');

            if ((empty($job['pending_paths']) || !empty($manifest['truncated'])) && !empty($job['deleted_done'])) {
                return $this->completeProgressiveCreate($job);
            }

            if (!$this->writeRestoreJob($restoreId, $job)) {
                $job['status'] = 'failed';
                $job['message'] = 'Could not persist restore point progress.';

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

            return $this->publicProgressiveCreateState($job);
        } catch (\Throwable $error) {
            $job['status'] = 'failed';
            $job['message'] = 'Restore point failed during batch processing.';
            $job['updated_at'] = gmdate('c');
            if (!$this->writeRestoreJob($restoreId, $job)) {
                $job['message'] = 'Restore point failed during batch processing and could not persist failure state.';
            }

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

    /**
     * @return array<int,array<string,mixed>>
     */
    public function recent(int $limit = 8): array
    {
        $files = glob($this->restoreDirectory() . '/restore-*/manifest.json');
        if (!is_array($files)) {
            return BackupStorageManager::fromWordPress()->mergeRestorePoints([], $limit);
        }

        usort($files, static function (string $left, string $right): int {
            return self::restorePointPathTimestamp($right) <=> self::restorePointPathTimestamp($left);
        });

        $items = [];
        foreach (array_slice($files, 0, max(1, min(50, $limit))) as $file) {
            $manifest = $this->readJson($file);
            if (empty($manifest)) {
                continue;
            }

            $items[] = [
                'restore_id' => (string) ($manifest['restore_id'] ?? basename(dirname($file))),
                'created_at' => (string) ($manifest['created_at'] ?? ''),
                'reason' => (string) ($manifest['reason'] ?? ''),
                'files_count' => (int) ($manifest['files_count'] ?? count((array) ($manifest['files'] ?? []))),
                'hashes_count' => (int) ($manifest['hashes_count'] ?? 0),
                'indexed_files_count' => (int) ($manifest['indexed_files_count'] ?? count((array) ($manifest['file_index'] ?? []))),
                'bytes_exported' => (int) ($manifest['bytes_exported'] ?? 0),
                'snapshot_scope' => (string) ($manifest['snapshot_scope'] ?? ''),
                'snapshot_mode' => (string) ($manifest['snapshot_mode'] ?? 'full'),
                'file_export_mode' => (string) ($manifest['file_export_mode'] ?? 'policy'),
                'base_restore_id' => (string) ($manifest['base_restore_id'] ?? ''),
                'database_backup_id' => (string) ($manifest['database_backup']['backup_id'] ?? ''),
                'database_warning_count' => max(0, (int) ($manifest['database_backup']['warning_count'] ?? 0)),
                'database_warning_details' => $this->sanitizeDatabaseWarningDetails((array) ($manifest['database_backup']['warning_details'] ?? [])),
                'truncated' => !empty($manifest['truncated']),
                'restored_at' => (string) ($manifest['restored_at'] ?? ''),
            ];
        }

        return BackupStorageManager::fromWordPress()->mergeRestorePoints($items, $limit);
    }

    /**
     * @return array<string,mixed>
     */
    public function deleteBeforeDate(string $beforeDate): array
    {
        $cutoff = $this->restorePointCleanupCutoff($beforeDate);
        if ($cutoff === null) {
            return [
                'ok' => false,
                'message' => 'Choose a valid before date.',
            ];
        }

        $directory = $this->restoreDirectory();
        if (!is_dir($directory)) {
            return [
                'ok' => true,
                'deleted_restore_points' => 0,
                'skipped_restore_points' => 0,
                'skipped_active_restore_points' => 0,
                'skipped_required_restore_points' => 0,
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'failed_files' => 0,
                'before_date' => $beforeDate,
            ];
        }

        $root = realpath($directory);
        if (!is_string($root) || $root === '') {
            return [
                'ok' => false,
                'message' => 'Restore point storage could not be inspected.',
            ];
        }

        $directories = glob($directory . '/restore-*', GLOB_ONLYDIR);
        if (!is_array($directories)) {
            return [
                'ok' => false,
                'message' => 'Restore point storage could not be listed.',
            ];
        }

        $deletedRestorePoints = 0;
        $skippedRestorePoints = 0;
        $skippedActiveRestorePoints = 0;
        $skippedRequiredRestorePoints = 0;
        $deletedFiles = 0;
        $deletedBytes = 0;
        $failedFiles = 0;
        $restorePoints = [];
        $fileByRestoreId = [];

        foreach ($directories as $restoreDirectory) {
            $restorePoint = $this->restorePointInfoFromDirectory($restoreDirectory);
            if (!is_array($restorePoint)) {
                continue;
            }

            $created = strtotime((string) ($restorePoint['created_at'] ?? ''));
            if (!is_int($created)) {
                continue;
            }

            $restorePoints[] = [
                'restore_id' => (string) ($restorePoint['restore_id'] ?? ''),
                'directory' => $restoreDirectory,
                'created' => $created,
                'manifest_file' => $restoreDirectory . '/manifest.json',
            ];

            $restoreId = (string) ($restorePoint['restore_id'] ?? '');
            if ($restoreId !== '') {
                $fileByRestoreId[$restoreId] = $restoreDirectory . '/manifest.json';
            }
        }

        usort($restorePoints, static function (array $left, array $right): int {
            $comparison = (int) ($right['created'] ?? 0) <=> (int) ($left['created'] ?? 0);
            if ($comparison !== 0) {
                return $comparison;
            }

            return strcmp((string) ($right['restore_id'] ?? ''), (string) ($left['restore_id'] ?? ''));
        });

        $requiredRestorePoints = [];
        foreach ($restorePoints as $restorePoint) {
            if ((int) ($restorePoint['created'] ?? 0) < $cutoff) {
                continue;
            }

            $manifestFile = (string) ($restorePoint['manifest_file'] ?? '');
            if ($manifestFile !== '') {
                $this->markRestorePointAndAncestorsRetained($manifestFile, $fileByRestoreId, $requiredRestorePoints);
            }
        }

        foreach ($restorePoints as $restorePoint) {
            $created = (int) ($restorePoint['created'] ?? 0);
            if ($created >= $cutoff) {
                continue;
            }

            $restoreDirectory = (string) ($restorePoint['directory'] ?? '');
            $restoreId = (string) ($restorePoint['restore_id'] ?? '');
            if ($this->hasActiveProgressiveRestorePointJob($restoreDirectory, $root)) {
                $skippedRestorePoints++;
                $skippedActiveRestorePoints++;
                continue;
            }

            if ($restoreId !== '' && isset($requiredRestorePoints[$restoreId])) {
                $skippedRestorePoints++;
                $skippedRequiredRestorePoints++;
                continue;
            }

            $deleted = $this->deleteDirectoryInsideRoot($restoreDirectory, $root);
            $deletedFiles += max(0, (int) ($deleted['deleted_files'] ?? 0));
            $deletedBytes += max(0, (int) ($deleted['deleted_bytes'] ?? 0));
            $failedFiles += max(0, (int) ($deleted['failed_files'] ?? 0));

            if (!empty($deleted['ok'])) {
                $deletedRestorePoints++;
            }
        }

        return [
            'ok' => $failedFiles === 0,
            'deleted_restore_points' => $deletedRestorePoints,
            'skipped_restore_points' => $skippedRestorePoints,
            'skipped_active_restore_points' => $skippedActiveRestorePoints,
            'skipped_required_restore_points' => $skippedRequiredRestorePoints,
            'deleted_files' => $deletedFiles,
            'deleted_bytes' => $deletedBytes,
            'failed_files' => $failedFiles,
            'before_date' => $beforeDate,
            'message' => $failedFiles > 0 ? 'Some restore point files could not be deleted.' : 'Restore point cleanup completed.',
        ];
    }

    /**
     * Delete one local restore point after proving no other restore point
     * depends on it.
     *
     * @return array<string,mixed>
     */
    public function deleteById(string $restoreId): array
    {
        $restoreId = trim($restoreId);
        if ($restoreId === '' || $this->restorePointInfoFromDirectory($restoreId) === null) {
            return [
                'ok' => false,
                'message' => 'Choose a valid restore point.',
            ];
        }

        $directory = $this->restoreDirectory();
        $root = is_dir($directory) ? realpath($directory) : false;
        if (!is_string($root) || $root === '') {
            return [
                'ok' => false,
                'message' => 'Restore point storage could not be inspected.',
            ];
        }

        $targetDirectory = $directory . '/' . $restoreId;
        $targetReal = realpath($targetDirectory);
        if (
            !is_string($targetReal)
            || $targetReal === ''
            || is_link($targetDirectory)
            || basename(str_replace('\\', '/', $targetReal)) !== $restoreId
            || !$this->pathInsideRootByString($targetReal, $root)
            || !is_dir($targetReal)
        ) {
            return [
                'ok' => false,
                'message' => 'The local restore point was not found.',
            ];
        }

        if ($this->hasActiveProgressiveRestorePointJob($targetReal, $root)) {
            return [
                'ok' => false,
                'message' => 'An in-progress restore point cannot be deleted.',
            ];
        }

        $manifestFiles = glob($directory . '/restore-*/manifest.json');
        if (!is_array($manifestFiles)) {
            return [
                'ok' => false,
                'message' => 'Restore point dependencies could not be inspected.',
            ];
        }

        $fileByRestoreId = [];
        foreach ($manifestFiles as $manifestFile) {
            $candidateDirectory = dirname($manifestFile);
            $candidate = $this->restorePointInfoFromDirectory($candidateDirectory);
            if (!is_array($candidate)) {
                continue;
            }

            $candidateId = (string) ($candidate['restore_id'] ?? '');
            if ($candidateId !== '') {
                $fileByRestoreId[$candidateId] = $manifestFile;
            }
        }

        if (!isset($fileByRestoreId[$restoreId])) {
            return [
                'ok' => false,
                'message' => 'The restore point manifest was not found.',
            ];
        }

        $requiredRestorePoints = [];
        foreach ($fileByRestoreId as $candidateId => $manifestFile) {
            if ($candidateId === $restoreId) {
                continue;
            }

            if (!is_array($this->readJson($manifestFile))) {
                return [
                    'ok' => false,
                    'message' => 'Restore point dependencies could not be verified safely.',
                ];
            }

            $this->markRestorePointAndAncestorsRetained($manifestFile, $fileByRestoreId, $requiredRestorePoints);
        }

        if (isset($requiredRestorePoints[$restoreId])) {
            return [
                'ok' => false,
                'message' => 'This restore point is required by a newer incremental restore point and cannot be deleted.',
            ];
        }

        $deleted = $this->deleteDirectoryInsideRoot($targetReal, $root);

        return [
            'ok' => !empty($deleted['ok']),
            'restore_id' => $restoreId,
            'deleted_restore_points' => !empty($deleted['ok']) ? 1 : 0,
            'deleted_files' => max(0, (int) ($deleted['deleted_files'] ?? 0)),
            'deleted_bytes' => max(0, (int) ($deleted['deleted_bytes'] ?? 0)),
            'failed_files' => max(0, (int) ($deleted['failed_files'] ?? 0)),
            'message' => !empty($deleted['ok'])
                ? 'The local restore point was deleted.'
                : 'Some restore point files could not be deleted.',
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    public function details(string $restoreId, int $page = 0, int $pageSize = 0, string $search = ''): ?array
    {
        $manifest = $this->manifestById($restoreId);
        if (!$manifest) {
            return null;
        }

        $manifestRestoreId = (string) ($manifest['restore_id'] ?? $restoreId);
        $filesRoot = $this->restoreDirectory() . '/' . $manifestRestoreId . '/files';
        $manifestFiles = (array) ($manifest['files'] ?? []);
        $paginated = $pageSize > 0;
        $pageSize = $paginated ? max(1, min(100, $pageSize)) : 0;
        $search = strtolower(trim($search));
        $fileTotal = 0;
        $fileMatched = 0;
        $fileHashes = 0;

        foreach ($manifestFiles as $file) {
            if (!is_array($file)) {
                continue;
            }

            $fileTotal++;
            if ((string) ($file['hash'] ?? '') !== '') {
                $fileHashes++;
            }
            if ($this->restoreDetailFileMatchesSearch($file, $search)) {
                $fileMatched++;
            }
        }

        $fileTotalPages = $paginated ? max(1, (int) ceil($fileMatched / $pageSize)) : 1;
        $filePage = $paginated ? min(max(1, $page), $fileTotalPages) : 1;
        $fileOffset = $paginated ? ($filePage - 1) * $pageSize : 0;
        $fileEnd = $paginated ? $fileOffset + $pageSize : PHP_INT_MAX;
        $files = [];
        $matchedIndex = 0;

        foreach ($manifestFiles as $file) {
            if (!is_array($file) || !$this->restoreDetailFileMatchesSearch($file, $search)) {
                continue;
            }

            if ($matchedIndex < $fileOffset || $matchedIndex >= $fileEnd) {
                $matchedIndex++;
                continue;
            }

            $backupPath = $this->safeBackupPath((string) ($file['backup_path'] ?? ''), $filesRoot);
            $files[] = [
                'relative_path' => (string) ($file['relative_path'] ?? ''),
                'existed' => !empty($file['existed']),
                'size' => (int) ($file['size'] ?? 0),
                'modified_at' => (string) ($file['modified_at'] ?? ''),
                'hash' => (string) ($file['hash'] ?? ''),
                'hash_algorithm' => (string) ($file['hash_algorithm'] ?? ($manifest['hash_algorithm'] ?? 'sha256')),
                'backup_relative_path' => (string) ($file['backup_relative_path'] ?? ''),
                'backup_available' => $backupPath !== '' && is_file($backupPath),
                'change_type' => (string) ($file['change_type'] ?? 'full'),
            ];
            $matchedIndex++;
        }

        $database = null;
        $databaseBackup = isset($manifest['database_backup']) && is_array($manifest['database_backup']) ? $manifest['database_backup'] : [];
        $databaseBackupId = (string) ($databaseBackup['backup_id'] ?? '');
        if ($databaseBackupId !== '') {
            $backup = $this->databaseBackups->backupById($databaseBackupId);
            $tables = [];
            if (is_array($backup)) {
                foreach ((array) ($backup['tables'] ?? []) as $table) {
                    if (!is_array($table)) {
                        continue;
                    }

                    $tables[] = [
                        'name' => (string) ($table['name'] ?? ''),
                        'rows_exported' => (int) ($table['rows_exported'] ?? 0),
                        'truncated' => !empty($table['truncated']),
                    ];
                }
            }

            $database = [
                'backup_id' => $databaseBackupId,
                'ok' => !empty($databaseBackup['ok']),
                'message' => (string) ($databaseBackup['message'] ?? ''),
                'created_at' => is_array($backup) ? (string) ($backup['created_at'] ?? '') : '',
                'table_count' => is_array($backup) ? (int) ($backup['table_count'] ?? count($tables)) : 0,
                'rows_exported' => is_array($backup) ? (int) ($backup['rows_exported'] ?? 0) : 0,
                'size' => is_array($backup) && isset($backup['_path']) && is_file((string) $backup['_path']) ? (int) filesize((string) $backup['_path']) : 0,
                'warning_count' => max(0, (int) ($databaseBackup['warning_count'] ?? ($backup['warning_count'] ?? 0))),
                'warning_details' => $this->sanitizeDatabaseWarningDetails((array) ($databaseBackup['warning_details'] ?? ($backup['warning_details'] ?? []))),
                'tables' => $tables,
                'available' => is_array($backup),
            ];
        }

        return [
            'restore_id' => $manifestRestoreId,
            'format' => (string) ($manifest['format'] ?? ''),
            'created_at' => (string) ($manifest['created_at'] ?? ''),
            'created_by' => (int) ($manifest['created_by'] ?? 0),
            'reason' => (string) ($manifest['reason'] ?? ''),
            'site_url' => (string) ($manifest['site_url'] ?? ''),
            'snapshot_scope' => (string) ($manifest['snapshot_scope'] ?? ''),
            'snapshot_mode' => (string) ($manifest['snapshot_mode'] ?? 'full'),
            'base_restore_id' => (string) ($manifest['base_restore_id'] ?? ''),
            'hash_algorithm' => (string) ($manifest['hash_algorithm'] ?? 'sha256'),
            'files' => $files,
            'files_paginated' => $paginated,
            'files_total' => $fileTotal,
            'files_matched' => $fileMatched,
            'file_page' => $filePage,
            'file_page_size' => $pageSize,
            'file_total_pages' => $fileTotalPages,
            'files_count' => (int) ($manifest['files_count'] ?? $fileTotal),
            'hashes_count' => (int) ($manifest['hashes_count'] ?? $fileHashes),
            'indexed_files_count' => (int) ($manifest['indexed_files_count'] ?? count((array) ($manifest['file_index'] ?? []))),
            'metadata_checked_count' => (int) ($manifest['metadata_checked_count'] ?? 0),
            'metadata_skipped_count' => (int) ($manifest['metadata_skipped_count'] ?? 0),
            'hash_checks_count' => (int) ($manifest['hash_checks_count'] ?? 0),
            'bytes_exported' => (int) ($manifest['bytes_exported'] ?? 0),
            'truncated' => !empty($manifest['truncated']),
            'limits' => isset($manifest['limits']) && is_array($manifest['limits']) ? $manifest['limits'] : [],
            'database' => $database,
            'restored_at' => (string) ($manifest['restored_at'] ?? ''),
            'last_restore_ok' => isset($manifest['last_restore_ok']) ? (bool) $manifest['last_restore_ok'] : null,
            'last_restore_errors' => isset($manifest['last_restore_errors']) && is_array($manifest['last_restore_errors']) ? array_values(array_map('strval', $manifest['last_restore_errors'])) : [],
            'last_restore_protected_paths' => isset($manifest['last_restore_protected_paths']) && is_array($manifest['last_restore_protected_paths']) ? array_values(array_map('strval', $manifest['last_restore_protected_paths'])) : [],
        ];
    }

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

        $haystack = strtolower(implode(' ', [
            (string) ($file['relative_path'] ?? ''),
            (string) ($file['backup_relative_path'] ?? ''),
            (string) ($file['hash'] ?? ''),
        ]));

        return strpos($haystack, $search) !== false;
    }

    /**
     * @return array<string,mixed>
     */
    public function previewFile(string $restoreId, string $relativePath): array
    {
        $manifest = $this->manifestById($restoreId);
        if (!$manifest) {
            return [
                'ok' => false,
                'error' => 'Restore point was not found or its manifest is invalid.',
            ];
        }

        $relativePath = trim(str_replace('\\', '/', $relativePath), '/');
        if ($relativePath === '' || strpos($relativePath, '..') !== false) {
            return [
                'ok' => false,
                'error' => 'The requested restore file path is not allowed.',
            ];
        }

        $manifestRestoreId = (string) ($manifest['restore_id'] ?? $restoreId);
        $filesRoot = $this->restoreDirectory() . '/' . $manifestRestoreId . '/files';
        $matched = null;
        foreach ((array) ($manifest['files'] ?? []) as $file) {
            if (!is_array($file) || (string) ($file['relative_path'] ?? '') !== $relativePath) {
                continue;
            }

            $matched = $file;
            break;
        }

        if (!$matched) {
            return [
                'ok' => false,
                'error' => 'The requested file is not part of this restore point.',
                'restore_id' => $manifestRestoreId,
                'relative_path' => $relativePath,
            ];
        }

        if (empty($matched['existed'])) {
            return [
                'ok' => false,
                'error' => 'This restore point records that the file did not exist, so there is no snapshot to preview.',
                'restore_id' => $manifestRestoreId,
                'relative_path' => $relativePath,
            ];
        }

        $backupPath = $this->safeBackupPath((string) ($matched['backup_path'] ?? ''), $filesRoot);
        if ($backupPath === '' || !is_file($backupPath) || !is_readable($backupPath)) {
            return [
                'ok' => false,
                'error' => 'The backed-up file snapshot is missing or unreadable.',
                'restore_id' => $manifestRestoreId,
                'relative_path' => $relativePath,
            ];
        }

        $size = filesize($backupPath);
        $modified = filemtime($backupPath);
        $preview = $this->readSnapshotPreview($backupPath);

        return [
            'ok' => true,
            'restore_id' => $manifestRestoreId,
            'relative_path' => $relativePath,
            'size' => is_int($size) ? $size : (int) ($matched['size'] ?? 0),
            'modified' => is_int($modified) ? $modified : 0,
            'hash' => (string) ($matched['hash'] ?? ''),
            'contents' => (string) ($preview['contents'] ?? ''),
        ];
    }

    /**
     * @param array<string,mixed> $options
     * @return array<string,mixed>
     */
    public function restore(string $restoreId, int $userId = 0, array $options = []): array
    {
        $restoreSource = $this->sanitizeRestoreSource((string) ($options['restore_source'] ?? 'auto'));
        $includeDatabase = !array_key_exists('include_database', $options) || !empty($options['include_database']);
        $storage = BackupStorageManager::fromWordPress();
        if ($restoreSource !== 'auto') {
            $prepared = $storage->ensureRestorePointLocalFromSource($restoreId, $restoreSource, $includeDatabase);
            if (empty($prepared['ok'])) {
                return [
                    'ok' => false,
                    'message' => (string) ($prepared['message'] ?? 'Requested restore point storage source is not available.'),
                ];
            }
        }

        $manifest = $this->manifestById($restoreId, $restoreSource === 'auto' ? 'auto' : 'local');
        if (!$manifest) {
            return [
                'ok' => false,
                'message' => 'Restore point was not found.',
            ];
        }

        $manifestRestoreId = (string) ($manifest['restore_id'] ?? $restoreId);
        $includeFiles = !array_key_exists('include_files', $options) || !empty($options['include_files']);
        $fileSelection = array_key_exists('file_paths', $options)
            ? array_fill_keys($this->cleanRelativePaths((array) $options['file_paths']), true)
            : null;

        $manifestChain = $includeFiles ? $this->restoreManifestChain($manifest) : [$manifest];
        if ($includeFiles && empty($manifestChain)) {
            return [
                'ok' => false,
                'message' => 'Restore point cannot be restored because its incremental baseline chain is incomplete.',
                'restore_id' => $manifestRestoreId,
                'files_restored' => 0,
                'files_removed' => 0,
                'files_skipped' => 0,
                'protected_files_skipped' => 0,
                'file_errors' => ['incremental_chain'],
                'database' => [
                    'ok' => false,
                    'message' => 'Database restore was not started because the file restore chain is incomplete.',
                    'skipped' => true,
                ],
            ];
        }

        $snapshotIntegrityErrors = [];
        foreach ($manifestChain as $chainManifest) {
            if (!is_array($chainManifest)) {
                continue;
            }

            $chainRestoreId = (string) ($chainManifest['restore_id'] ?? '');
            if ($chainRestoreId === '') {
                continue;
            }

            $snapshotIntegrityErrors = array_merge(
                $snapshotIntegrityErrors,
                $this->restoreSnapshotIntegrityErrors($chainManifest, $this->restoreDirectory() . '/' . $chainRestoreId . '/files', $includeFiles, $fileSelection)
            );
        }

        if (!empty($snapshotIntegrityErrors)) {
            return [
                'ok' => false,
                'message' => 'Restore point aborted because one or more backed-up files failed integrity verification.',
                'restore_id' => $manifestRestoreId,
                'files_restored' => 0,
                'files_removed' => 0,
                'files_skipped' => 0,
                'protected_files_skipped' => 0,
                'file_errors' => $snapshotIntegrityErrors,
                'database' => [
                    'ok' => false,
                    'message' => 'Database restore was not started because file snapshot integrity verification failed.',
                    'skipped' => true,
                ],
            ];
        }

        $selectedTables = array_key_exists('table_names', $options) ? $this->cleanTableNames((array) $options['table_names']) : null;
        $allowTruncatedDatabaseRestore = !empty($options['allow_truncated_database_restore']);
        $databaseResult = ['ok' => true, 'message' => 'No database backup in restore point.', 'skipped' => false];
        $databaseBackupId = (string) ($manifest['database_backup']['backup_id'] ?? '');
        if ($databaseBackupId !== '' && $includeDatabase) {
            if (is_array($selectedTables) && empty($selectedTables)) {
                $databaseResult = [
                    'ok' => true,
                    'message' => 'Database restore skipped because no tables were selected.',
                    'backup_id' => $databaseBackupId,
                    'tables' => 0,
                    'rows_restored' => 0,
                    'skipped' => true,
                ];
            } else {
                $databaseResult = $this->databaseBackups->restore(
                    $databaseBackupId,
                    $selectedTables,
                    $allowTruncatedDatabaseRestore,
                    $restoreSource !== 'local'
                );
                if (empty($databaseResult['ok'])) {
                    return [
                        'ok' => false,
                        'message' => 'Restore point aborted because the database restore failed before file changes were applied.',
                        'restore_id' => $manifestRestoreId,
                        'files_restored' => 0,
                        'files_removed' => 0,
                        'files_skipped' => 0,
                        'protected_files_skipped' => 0,
                        'file_errors' => [],
                        'database' => $databaseResult,
                    ];
                }
            }
        } elseif ($databaseBackupId !== '' && !$includeDatabase) {
            $databaseResult = [
                'ok' => true,
                'message' => 'Database restore skipped.',
                'backup_id' => $databaseBackupId,
                'tables' => 0,
                'rows_restored' => 0,
                'skipped' => true,
            ];
        }

        $filesRestored = 0;
        $filesRemoved = 0;
        $filesSkipped = 0;
        $errors = [];
        $protectedSkipped = [];

        foreach ($this->restoreFileEntriesFromManifestChain($manifestChain) as $file) {
            if (!is_array($file)) {
                continue;
            }

            $relative = (string) ($file['relative_path'] ?? '');
            if (!$includeFiles || (is_array($fileSelection) && !isset($fileSelection[$relative]))) {
                $filesSkipped++;
                continue;
            }

            if ($this->isProtectedRestorePath($relative)) {
                $filesSkipped++;
                $protectedSkipped[] = $relative;
                continue;
            }

            $target = $this->absolutePath($relative);
            if ($target === '') {
                $errors[] = $relative;
                continue;
            }

            $existed = !empty($file['existed']);
            if (!$existed) {
                // Restore points are policy-filtered overlays, not complete
                // filesystem mirrors. A missing snapshot must never delete a
                // live file that may have been excluded by backup policy.
                $filesSkipped++;
                continue;
            }

            $filesRoot = (string) ($file['_files_root'] ?? ($this->restoreDirectory() . '/' . $manifestRestoreId . '/files'));
            $backupPath = $this->safeBackupPath((string) ($file['backup_path'] ?? ''), $filesRoot);
            if ($backupPath === '' || !is_file($backupPath)) {
                $errors[] = $relative;
                continue;
            }

            $this->ensureDirectory(dirname($target));
            if ($this->copyWithTemporaryWrite($backupPath, $target)) {
                $filesRestored++;
            } else {
                $errors[] = $relative;
            }
        }

        $ok = empty($errors) && !empty($databaseResult['ok']);
        $manifest['restored_at'] = gmdate('c');
        $manifest['restored_by'] = $userId;
        $manifest['last_restore_ok'] = $ok;
        $manifest['last_restore_errors'] = $errors;
        $manifest['last_restore_protected_paths'] = $protectedSkipped;
        $manifest['last_restore_selection'] = [
            'files_selected' => is_array($fileSelection) ? count($fileSelection) : null,
            'files_skipped' => $filesSkipped,
            'protected_files_skipped' => count($protectedSkipped),
            'database_included' => $includeDatabase,
            'tables_selected' => is_array($selectedTables) ? count($selectedTables) : null,
        ];
        $this->writeManifest((string) ($manifest['restore_id'] ?? $restoreId), $manifest);

        return [
            'ok' => $ok,
            'message' => $ok ? 'Restore point restored.' : 'Restore point completed with errors.',
            'restore_id' => (string) ($manifest['restore_id'] ?? $restoreId),
            'files_restored' => $filesRestored,
            'files_removed' => $filesRemoved,
            'files_skipped' => $filesSkipped,
            'protected_files_skipped' => count($protectedSkipped),
            'file_errors' => $errors,
            'database' => $databaseResult,
        ];
    }

    /**
     * Build a safe, display-only preflight summary for the manual WP-CLI
     * restore route. It deliberately performs the same source, manifest,
     * chain, snapshot, and database-completeness checks before a user can
     * confirm a destructive restore.
     *
     * @return array<string,mixed>
     */
    public function manualRestoreSummary(string $restoreId, string $restoreSource = 'auto'): array
    {
        $restoreSource = strtolower(trim($restoreSource));
        if (!in_array($restoreSource, ['auto', 'local', 'google'], true)) {
            return [
                'ok' => false,
                'message' => 'Restore source must be auto, local, or google.',
            ];
        }

        $storage = BackupStorageManager::fromWordPress();
        if ($restoreSource !== 'auto') {
            $prepared = $storage->ensureRestorePointLocalFromSource($restoreId, $restoreSource, true);
            if (empty($prepared['ok'])) {
                return [
                    'ok' => false,
                    'message' => (string) ($prepared['message'] ?? 'Requested restore point storage source is not available.'),
                ];
            }
        }

        $manifest = $this->manifestById($restoreId, $restoreSource === 'auto' ? 'auto' : 'local');
        if (!$manifest) {
            return [
                'ok' => false,
                'message' => 'Restore point was not found.',
            ];
        }

        $chain = $this->restoreManifestChain($manifest);
        if ($chain === []) {
            return [
                'ok' => false,
                'message' => 'Restore point cannot be restored because its incremental baseline chain is incomplete.',
            ];
        }

        $integrityErrors = [];
        foreach ($chain as $chainManifest) {
            if (!is_array($chainManifest)) {
                continue;
            }

            $chainRestoreId = (string) ($chainManifest['restore_id'] ?? '');
            if ($chainRestoreId === '' || !empty($chainManifest['truncated'])) {
                $integrityErrors[] = $chainRestoreId !== '' ? $chainRestoreId : 'incremental_chain';
                continue;
            }

            $integrityErrors = array_merge(
                $integrityErrors,
                $this->restoreSnapshotIntegrityErrors(
                    $chainManifest,
                    $this->restoreDirectory() . '/' . $chainRestoreId . '/files',
                    true,
                    null
                )
            );
        }

        if ($integrityErrors !== []) {
            return [
                'ok' => false,
                'message' => 'Restore point contains snapshots that failed integrity verification or were truncated.',
                'file_errors' => array_values(array_unique($integrityErrors)),
            ];
        }

        $filesTotal = 0;
        $protectedFiles = 0;
        foreach ($this->restoreFileEntriesFromManifestChain($chain) as $file) {
            if (!is_array($file)) {
                continue;
            }

            $relative = trim(str_replace('\\', '/', (string) ($file['relative_path'] ?? '')), '/');
            if ($relative === '') {
                continue;
            }

            if ($this->isProtectedRestorePath($relative)) {
                $protectedFiles++;
                continue;
            }

            $filesTotal++;
        }

        $database = [
            'included' => false,
            'backup_id' => '',
            'tables' => 0,
        ];
        $databaseBackupId = (string) ($manifest['database_backup']['backup_id'] ?? '');
        if ($databaseBackupId === '') {
            return [
                'ok' => false,
                'message' => 'Manual full restore requires the complete database backup recorded with this restore point.',
            ];
        }

        $backup = $this->databaseBackups->backupById($databaseBackupId, $restoreSource !== 'local');
        $backupPath = is_array($backup) ? (string) ($backup['_path'] ?? '') : '';
        $tables = is_array($backup) && isset($backup['tables']) && is_array($backup['tables']) ? $backup['tables'] : [];
        $complete = is_array($backup)
            && (string) ($backup['row_export_mode'] ?? '') === 'complete'
            && $tables !== [];
        foreach ($tables as $table) {
            if (is_array($table) && !empty($table['truncated'])) {
                $complete = false;
                break;
            }
        }

        if (!$complete || $backupPath === '' || !is_file($backupPath) || !is_readable($backupPath)) {
            return [
                'ok' => false,
                'message' => 'The complete database backup required for manual full restore is unavailable or truncated.',
            ];
        }

        $database = [
            'included' => true,
            'backup_id' => $databaseBackupId,
            'tables' => count($tables),
        ];

        return [
            'ok' => true,
            'restore_id' => (string) ($manifest['restore_id'] ?? $restoreId),
            'restore_source' => $restoreSource,
            'created_at' => (string) ($manifest['created_at'] ?? ''),
            'reason' => (string) ($manifest['reason'] ?? ''),
            'snapshot_mode' => (string) ($manifest['snapshot_mode'] ?? 'full'),
            'files_total' => $filesTotal,
            'protected_files' => $protectedFiles,
            'database' => $database,
        ];
    }

    /**
     * Execute the manual WP-CLI full-site restore only after its complete
     * preflight passed. The lower-level restore method repeats integrity checks
     * immediately before writes to protect against changes after confirmation.
     *
     * @return array<string,mixed>
     */
    public function restoreManualFullSite(string $restoreId, int $userId = 0, string $restoreSource = 'auto'): array
    {
        $summary = $this->manualRestoreSummary($restoreId, $restoreSource);
        if (empty($summary['ok'])) {
            return [
                'ok' => false,
                'message' => (string) ($summary['message'] ?? 'Manual full restore preflight failed.'),
                'preflight' => $summary,
            ];
        }

        $result = $this->restore($restoreId, $userId, [
            'restore_source' => (string) ($summary['restore_source'] ?? $restoreSource),
        ]);
        $result['preflight'] = $summary;

        return $result;
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array<int,array<string,mixed>>
     */
    private function restoreManifestChain(array $manifest): array
    {
        $chain = [];
        $seen = [];
        $cursor = $manifest;

        for ($depth = 0; $depth < 50; $depth++) {
            $restoreId = (string) ($cursor['restore_id'] ?? '');
            if ($restoreId === '' || isset($seen[$restoreId])) {
                return [];
            }

            $seen[$restoreId] = true;
            array_unshift($chain, $cursor);

            if ((string) ($cursor['snapshot_mode'] ?? 'full') !== 'incremental') {
                return $chain;
            }

            $baseRestoreId = (string) ($cursor['base_restore_id'] ?? '');
            if ($baseRestoreId === '') {
                return [];
            }

            $base = $this->manifestById($baseRestoreId);
            if (empty($base)) {
                return [];
            }

            $cursor = $base;
        }

        return [];
    }

    /**
     * Iterate the effective file set without duplicating every manifest entry.
     * A normal full restore yields directly from its single manifest. For an
     * incremental chain, retain only compact source locations while preserving
     * the latest entry for each relative path.
     *
     * @param array<int,array<string,mixed>> $chain
     * @return iterable<int,array<string,mixed>>
     */
    private function restoreFileEntriesFromManifestChain(array $chain): iterable
    {
        if (count($chain) === 1) {
            $manifest = reset($chain);
            if (!is_array($manifest)) {
                return;
            }

            $restoreId = (string) ($manifest['restore_id'] ?? '');
            if ($restoreId === '') {
                return;
            }

            $filesRoot = $this->restoreDirectory() . '/' . $restoreId . '/files';
            foreach ((array) ($manifest['files'] ?? []) as $file) {
                if (!is_array($file)) {
                    continue;
                }
                $relative = trim(str_replace('\\', '/', (string) ($file['relative_path'] ?? '')), '/');
                if ($relative === '') {
                    continue;
                }

                $file['_files_root'] = $filesRoot;
                yield $file;
            }

            return;
        }

        $locations = [];
        foreach ($chain as $manifestIndex => $manifest) {
            if (!is_array($manifest)) {
                continue;
            }
            foreach ((array) ($manifest['files'] ?? []) as $fileIndex => $file) {
                if (!is_array($file)) {
                    continue;
                }
                $relative = trim(str_replace('\\', '/', (string) ($file['relative_path'] ?? '')), '/');
                if ($relative !== '') {
                    $locations[$relative] = [$manifestIndex, $fileIndex];
                }
            }
        }

        foreach ($locations as $location) {
            $manifestIndex = $location[0] ?? null;
            $fileIndex = $location[1] ?? null;
            $manifest = $manifestIndex !== null && isset($chain[$manifestIndex]) && is_array($chain[$manifestIndex])
                ? $chain[$manifestIndex]
                : [];
            $file = $fileIndex !== null && isset($manifest['files'][$fileIndex]) && is_array($manifest['files'][$fileIndex])
                ? $manifest['files'][$fileIndex]
                : [];
            $restoreId = (string) ($manifest['restore_id'] ?? '');
            if ($file === [] || $restoreId === '') {
                continue;
            }

            $file['_files_root'] = $this->restoreDirectory() . '/' . $restoreId . '/files';
            yield $file;
        }
    }

    /**
     * Return one integrity-checked restore-file slice without writing to the
     * WordPress root. Recovery uses this to verify a complete snapshot first,
     * then restore it in bounded requests instead of risking a timed-out
     * all-in-one restore.
     *
     * @return array{ok:bool,message:string,file_paths:array<int,string>,total_files:int,has_database:bool,file_errors:array<int,string>}
     */
    public function progressiveRestoreBatchPlan(string $restoreId, int $offset = 0, int $limit = 150): array
    {
        $manifest = $this->manifestById($restoreId);
        if (!$manifest) {
            return [
                'ok' => false,
                'message' => 'Restore point was not found.',
                'file_paths' => [],
                'total_files' => 0,
                'has_database' => false,
                'file_errors' => [],
            ];
        }

        $offset = max(0, $offset);
        $limit = max(1, min(1000, $limit));
        $manifestChain = $this->restoreManifestChain($manifest);
        if (empty($manifestChain)) {
            return [
                'ok' => false,
                'message' => 'Restore point cannot be restored because its incremental baseline chain is incomplete.',
                'file_paths' => [],
                'total_files' => 0,
                'has_database' => false,
                'file_errors' => ['incremental_chain'],
            ];
        }

        $entries = [];
        foreach ($this->restoreFileEntriesFromManifestChain($manifestChain) as $file) {
            if (!is_array($file)) {
                continue;
            }

            $paths = $this->cleanRelativePaths([(string) ($file['relative_path'] ?? '')]);
            if ($paths === [] || empty($file['existed'])) {
                continue;
            }

            $entries[] = [
                'relative_path' => $paths[0],
                'file' => $file,
            ];
        }

        $batch = array_slice($entries, $offset, $limit);
        $paths = [];
        $errors = [];
        foreach ($batch as $entry) {
            $relative = (string) ($entry['relative_path'] ?? '');
            $file = isset($entry['file']) && is_array($entry['file']) ? $entry['file'] : [];
            if ($relative === '') {
                continue;
            }

            $paths[] = $relative;
            if ($this->isProtectedRestorePath($relative) || empty($file['existed'])) {
                continue;
            }

            $filesRoot = (string) ($file['_files_root'] ?? '');
            $backupPath = $this->safeBackupPath((string) ($file['backup_path'] ?? ''), $filesRoot);
            if ($backupPath === '' || !is_file($backupPath) || !is_readable($backupPath) || !$this->snapshotHashMatchesManifest($backupPath, $file)) {
                $errors[] = $relative;
            }
        }

        return [
            'ok' => $errors === [],
            'message' => $errors === [] ? 'Restore snapshot batch verified.' : 'Restore point contains files that failed integrity verification.',
            'file_paths' => $paths,
            'total_files' => count($entries),
            'has_database' => (string) ($manifest['database_backup']['backup_id'] ?? '') !== '',
            'file_errors' => $errors,
        ];
    }

    /**
     * Build an integrity-checked immutable plan for the out-of-root Recovery
     * runtime. Absolute paths are written only into the signed private vault
     * job and are never returned by the browser controller.
     *
     * @return array<string,mixed>
     */
    public function isolatedRestorePlan(string $restoreId): array
    {
        $manifest = $this->manifestById($restoreId);
        if (!$manifest) {
            return [
                'ok' => false,
                'message' => 'Restore point was not found.',
            ];
        }
        if (!empty($manifest['truncated'])) {
            return [
                'ok' => false,
                'message' => 'A truncated restore point cannot be used for an isolated full restore.',
            ];
        }

        $chain = $this->restoreManifestChain($manifest);
        if ($chain === []) {
            return [
                'ok' => false,
                'message' => 'Restore point cannot be restored because its incremental baseline chain is incomplete.',
            ];
        }

        $integrityErrors = [];
        foreach ($chain as $chainManifest) {
            if (!is_array($chainManifest)) {
                continue;
            }
            $chainRestoreId = (string) ($chainManifest['restore_id'] ?? '');
            if ($chainRestoreId === '' || !empty($chainManifest['truncated'])) {
                $integrityErrors[] = $chainRestoreId !== '' ? $chainRestoreId : 'incremental_chain';
                continue;
            }
            $integrityErrors = array_merge(
                $integrityErrors,
                $this->restoreSnapshotIntegrityErrors(
                    $chainManifest,
                    $this->restoreDirectory() . '/' . $chainRestoreId . '/files',
                    true,
                    null
                )
            );
        }
        if ($integrityErrors !== []) {
            return [
                'ok' => false,
                'message' => 'Restore point contains snapshots that failed integrity verification.',
                'file_errors' => array_values(array_unique($integrityErrors)),
            ];
        }

        $manifestChain = [];
        foreach ($chain as $chainManifest) {
            if (!is_array($chainManifest)) {
                continue;
            }
            $manifestChain[] = [
                'restore_id' => (string) ($chainManifest['restore_id'] ?? ''),
                'snapshot_mode' => (string) ($chainManifest['snapshot_mode'] ?? 'full'),
                'base_restore_id' => (string) ($chainManifest['base_restore_id'] ?? ''),
                'manifest_hmac' => (string) ($chainManifest['manifest_hmac'] ?? ''),
            ];
        }

        $fileTypePolicy = isset($manifest['file_type_policy']) && is_array($manifest['file_type_policy'])
            ? $manifest['file_type_policy']
            : [];
        $allowedFileTypes = array_fill_keys(
            FileTypeSettings::sanitizeTypes((array) ($fileTypePolicy['selected'] ?? [])),
            true
        );
        $files = [];
        foreach ($this->restoreFileEntriesFromManifestChain($chain) as $file) {
            if (!is_array($file)) {
                continue;
            }
            $relative = trim(str_replace('\\', '/', (string) ($file['relative_path'] ?? '')), '/');
            if (
                $relative === ''
                || empty($file['existed'])
                || $this->isProtectedRecoveryRuntimePath($relative)
                || !isset($allowedFileTypes[FileTypeSettings::tokenForPath($relative)])
            ) {
                continue;
            }

            $entry = [
                'relative_path' => $relative,
                'existed' => !empty($file['existed']),
                'source_path' => '',
                'size' => max(0, (int) ($file['size'] ?? 0)),
                'hash' => strtolower((string) ($file['hash'] ?? '')),
                'mode' => 0,
            ];
            if (!empty($entry['existed'])) {
                $filesRoot = (string) ($file['_files_root'] ?? '');
                $source = $this->safeBackupPath((string) ($file['backup_path'] ?? ''), $filesRoot);
                if ($source === '' || !is_file($source) || !is_readable($source)) {
                    return [
                        'ok' => false,
                        'message' => 'A restore snapshot became unavailable while the isolated plan was being prepared.',
                        'file_errors' => [$relative],
                    ];
                }
                $entry['source_path'] = $source;
            }
            $files[] = $entry;
        }
        if ($files === []) {
            return [
                'ok' => false,
                'message' => 'Restore point does not contain files that can be restored.',
            ];
        }

        $database = [
            'included' => false,
            'backup_id' => '',
            'backup_path' => '',
            'backup_sha256' => '',
            'backup_size' => 0,
            'row_export_mode' => '',
            'table_count' => 0,
            'rows_exported' => 0,
        ];
        $databaseBackupId = (string) ($manifest['database_backup']['backup_id'] ?? '');
        if ($databaseBackupId !== '') {
            $backup = $this->databaseBackups->restorePackageMetadata($databaseBackupId, true, true);
            $backupPath = is_array($backup) ? (string) ($backup['backup_path'] ?? '') : '';
            if (!is_array($backup) || $backupPath === '' || !is_file($backupPath) || !is_readable($backupPath)) {
                return [
                    'ok' => false,
                    'message' => 'The complete database backup metadata required for isolated full restore is unavailable. Create a new full backup with the current SHIELD-X version.',
                ];
            }

            $database = [
                'included' => true,
                'backup_id' => $databaseBackupId,
                'backup_path' => $backupPath,
                'backup_sha256' => (string) ($backup['backup_sha256'] ?? ''),
                'backup_size' => max(0, (int) ($backup['backup_size'] ?? 0)),
                'row_export_mode' => (string) ($backup['row_export_mode'] ?? ''),
                'table_count' => max(0, (int) ($backup['table_count'] ?? 0)),
                'rows_exported' => max(0, (int) ($backup['rows_exported'] ?? 0)),
            ];
        }

        return [
            'ok' => true,
            'restore_id' => (string) ($manifest['restore_id'] ?? $restoreId),
            'site_root' => $this->rootPath(),
            'site_url' => function_exists('site_url') ? (string) site_url('/') : '',
            'files' => $files,
            'database' => $database,
            'manifest_chain' => $manifestChain,
            'file_type_policy' => $fileTypePolicy,
        ];
    }

    /**
     * Verify every selected snapshot before a restore writes anything to the
     * WordPress root. The manifest HMAC protects the expected hash; this check
     * protects the private snapshot file itself between creation and restore.
     *
     * @param array<string,mixed>      $manifest
     * @param array<string,bool>|null $fileSelection
     * @return array<int,string>
     */
    private function restoreSnapshotIntegrityErrors(array $manifest, string $filesRoot, bool $includeFiles, ?array $fileSelection): array
    {
        if (!$includeFiles) {
            return [];
        }

        $errors = [];
        foreach ((array) ($manifest['files'] ?? []) as $file) {
            if (!is_array($file)) {
                continue;
            }

            $relative = (string) ($file['relative_path'] ?? '');
            if ((is_array($fileSelection) && !isset($fileSelection[$relative])) || $this->isProtectedRestorePath($relative) || empty($file['existed'])) {
                continue;
            }

            $backupPath = $this->safeBackupPath((string) ($file['backup_path'] ?? ''), $filesRoot);
            if ($backupPath === '' || !is_file($backupPath) || !is_readable($backupPath) || !$this->snapshotHashMatchesManifest($backupPath, $file)) {
                $errors[] = $relative;
            }
        }

        return array_values(array_unique($errors));
    }

    /**
     * @param array<string,mixed> $file
     */
    private function snapshotHashMatchesManifest(string $backupPath, array $file): bool
    {
        $algorithm = strtolower((string) ($file['hash_algorithm'] ?? 'sha256'));
        $expected = strtolower((string) ($file['hash'] ?? ''));
        if ($algorithm !== 'sha256' || preg_match('/^[a-f0-9]{64}$/', $expected) !== 1) {
            return false;
        }

        $actual = hash_file('sha256', $backupPath);

        return is_string($actual) && hash_equals($expected, strtolower($actual));
    }

    /**
     * @param array<int,string> $paths
     * @return array<int,string>
     */
    public function updatePathsFromHookExtra(array $hookExtra): array
    {
        $paths = $this->defaultCriticalPaths();
        $content = $this->contentPath();
        $type = strtolower((string) ($hookExtra['type'] ?? ''));

        if ($content !== '') {
            foreach (['plugin', 'plugins'] as $key) {
                foreach ($this->hookExtraItems($hookExtra, $key) as $pluginFile) {
                    $slug = trim(str_replace('\\', '/', $pluginFile), '/');
                    $slug = strpos($slug, '/') !== false ? substr($slug, 0, strpos($slug, '/')) : preg_replace('/\.php$/i', '', $slug);
                    if (is_string($slug) && $slug !== '') {
                        $paths[] = $content . '/plugins/' . $slug;
                    }
                }
            }

            foreach (['theme', 'themes'] as $key) {
                foreach ($this->hookExtraItems($hookExtra, $key) as $theme) {
                    $theme = trim($theme);
                    if ($theme !== '') {
                        $paths[] = $content . '/themes/' . $theme;
                    }
                }
            }
        }

        if ($type === 'core') {
            $paths[] = $this->rootPath() . '/wp-admin';
            $paths[] = $this->rootPath() . '/wp-includes';
        }

        return array_values(array_unique(array_filter(array_map('strval', $paths))));
    }

    /**
     * @return array<int,string>
     */
    public function defaultCriticalPaths(): array
    {
        $root = $this->rootPath();
        $content = $this->contentPath();
        $paths = [
            $root . '/.htaccess',
            $root . '/index.php',
        ];

        if ($content !== '') {
            $paths[] = $content . '/.htaccess';
            $paths[] = $content . '/uploads/.htaccess';
            $paths[] = $content . '/mu-plugins';
        }

        return $paths;
    }

    public function restoreDirectory(): string
    {
        return $this->storage->baseDir() . '/restore-points';
    }

    /**
     * @return array<string,mixed>
     */
    public function localRestorePointResult(string $restoreId): array
    {
        $restoreId = strtolower(trim($restoreId));
        if (preg_match('/^restore-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $restoreId) !== 1) {
            return [
                'ok' => false,
                'status' => 'failed',
                'restore_id' => $restoreId,
                'message' => 'Restore point identifier is invalid.',
            ];
        }

        $directory = $this->restoreDirectory() . '/' . $restoreId;
        $manifest = $this->readJson($directory . '/manifest.json');
        if (empty($manifest) || !$this->manifestHmacValid($manifest)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'restore_id' => $restoreId,
                'message' => 'Local restore point files were not found or failed integrity verification.',
            ];
        }

        $filesCount = (int) ($manifest['files_count'] ?? count((array) ($manifest['files'] ?? [])));
        $metadataSkipped = (int) ($manifest['metadata_skipped_count'] ?? 0);
        $hashChecks = (int) ($manifest['hash_checks_count'] ?? 0);

        return [
            'ok' => true,
            'type' => 'restore_point',
            'status' => 'completed',
            'restore_id' => $restoreId,
            'message' => 'Restore point created.',
            'path' => $directory,
            'files' => $filesCount,
            'bytes_exported' => (int) ($manifest['bytes_exported'] ?? 0),
            'metadata_skipped' => $metadataSkipped,
            'hash_checks' => $hashChecks,
            'database_backup_id' => (string) ($manifest['database_backup']['backup_id'] ?? ''),
            'database_warning_count' => max(0, (int) ($manifest['database_backup']['warning_count'] ?? 0)),
            'database_warning_details' => $this->sanitizeDatabaseWarningDetails((array) ($manifest['database_backup']['warning_details'] ?? [])),
            'truncated' => !empty($manifest['truncated']),
            'percent' => 100,
            'files_processed' => $metadataSkipped + $hashChecks,
            'files_total' => max(1, $metadataSkipped + $hashChecks),
            /* translators: %d: number of files captured in the restore point. */
            'files_label' => sprintf(__('%d files captured', 'xerotact-shield-x-security'), $filesCount),
            'batches' => 0,
            'batches_label' => __('Ready', 'xerotact-shield-x-security'),
            'scope_label' => __('Restore point: local snapshot is ready.', 'xerotact-shield-x-security'),
        ];
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function completeProgressiveCreate(array $job): array
    {
        $restoreId = (string) ($job['restore_id'] ?? '');
        $manifest = isset($job['manifest']) && is_array($job['manifest']) ? $job['manifest'] : [];
        $manifest['files_count'] = count((array) ($manifest['files'] ?? []));
        $manifest['hashes_count'] = count(array_filter((array) ($manifest['files'] ?? []), static function ($file): bool {
            return is_array($file) && (string) ($file['hash'] ?? '') !== '';
        }));
        $manifest['indexed_files_count'] = count((array) ($manifest['file_index'] ?? []));

        $this->writeManifest($restoreId, $manifest);
        $this->enforceRetention();

        $job['status'] = 'completed';
        $job['manifest'] = $manifest;
        $job['message'] = 'Restore point created.';
        $job['updated_at'] = gmdate('c');
        $this->removeRestoreJob($restoreId);

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

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function publicProgressiveCreateState(array $job): array
    {
        $manifest = isset($job['manifest']) && is_array($job['manifest']) ? $job['manifest'] : [];
        $pending = isset($job['pending_paths']) && is_array($job['pending_paths']) ? count($job['pending_paths']) : 0;
        $baseKeys = isset($job['base_file_keys']) && is_array($job['base_file_keys']) ? count($job['base_file_keys']) : 0;
        $deletedCursor = max(0, (int) ($job['deleted_cursor'] ?? 0));
        $filesSeen = max(0, (int) ($job['files_seen'] ?? 0));
        $dbProgress = isset($job['database_progress']) && is_array($job['database_progress']) ? $job['database_progress'] : [];
        $status = (string) ($job['status'] ?? 'running');
        $phase = (string) ($job['phase'] ?? 'files');
        $databasePreparing = $phase === 'database_prepare';
        $databaseExporting = $phase === 'database';
        $databaseLiveMetrics = $databaseExporting && (
            (int) ($dbProgress['pages_processed'] ?? 0) > 0
            || (int) ($dbProgress['rows_exported'] ?? 0) > 0
        );
        $databaseTablesProcessed = max(0, (int) ($dbProgress['tables'] ?? 0));
        $databaseTablesTotal = max(0, (int) ($dbProgress['table_count'] ?? 0));
        $databaseRowsExported = max(0, (int) ($dbProgress['rows_exported'] ?? 0));

        if ($status === 'completed') {
            $percent = 100;
        } elseif ($databasePreparing) {
            $percent = 1;
        } elseif ($phase === 'database') {
            $percent = max(1, min(30, (int) floor(max(0, min(100, (int) ($dbProgress['percent'] ?? 0))) * 0.3)));
        } else {
            $remainingDeletes = empty($job['deleted_done']) ? max(0, $baseKeys - $deletedCursor) : 0;
            $totalGuess = max(1, $filesSeen + $pending + $remainingDeletes);
            $percent = min(99, max(5, (int) floor(($filesSeen / $totalGuess) * 95)));
        }

        $filesCount = count((array) ($manifest['files'] ?? []));
        $bytesExported = (int) ($manifest['bytes_exported'] ?? 0);
        $metadataSkipped = (int) ($manifest['metadata_skipped_count'] ?? 0);
        $hashChecks = (int) ($manifest['hash_checks_count'] ?? 0);
        $fullRootExport = !empty($manifest['full_root_export']);
        $batches = max(0, (int) ($job['batches'] ?? 0));

        return [
            'ok' => $status !== 'failed',
            'type' => 'restore_point',
            'status' => $status,
            'restore_id' => (string) ($job['restore_id'] ?? ''),
            'message' => (string) ($job['message'] ?? ''),
            'path' => (string) ($job['directory'] ?? ''),
            'files' => $filesCount,
            'bytes_exported' => $bytesExported,
            'metadata_skipped' => $metadataSkipped,
            'hash_checks' => $hashChecks,
            'database_backup_id' => (string) ($manifest['database_backup']['backup_id'] ?? $job['database_backup_id'] ?? ''),
            'database_warning_count' => max(0, (int) ($manifest['database_backup']['warning_count'] ?? 0)),
            'database_warning_details' => $this->sanitizeDatabaseWarningDetails((array) ($manifest['database_backup']['warning_details'] ?? [])),
            'truncated' => !empty($manifest['truncated']),
            'percent' => max(0, min(100, $percent)),
            // The first database request only selects tables. Once an export
            // page is checkpointed, its table/throughput metrics are real and
            // the browser must show determinate progress instead of a spinner.
            'indeterminate' => $status === 'running' && ($databasePreparing || ($databaseExporting && !$databaseLiveMetrics)),
            'stage_label' => $databasePreparing
                ? __('Preparing the complete database export before files are captured.', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? __('Exporting database tables before files are captured.', 'xerotact-shield-x-security')
                    : ''),
            'percent_label' => ($databasePreparing || ($databaseExporting && !$databaseLiveMetrics))
                ? __('Database', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? (string) ($dbProgress['percent_label'] ?? __('Database', 'xerotact-shield-x-security'))
                    : __('Progress', 'xerotact-shield-x-security')),
            'percent_value_label' => $databasePreparing
                ? __('Starting', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? ($databaseLiveMetrics
                        ? (string) ($dbProgress['percent_value_label'] ?? __('Working', 'xerotact-shield-x-security'))
                        : __('Working', 'xerotact-shield-x-security'))
                    : ''),
            'files_metric_label' => ($databasePreparing || ($databaseExporting && !$databaseLiveMetrics))
                ? __('Tables', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? (string) ($dbProgress['files_metric_label'] ?? __('Table progress', 'xerotact-shield-x-security'))
                    : __('Files', 'xerotact-shield-x-security')),
            'batches_metric_label' => ($databasePreparing || ($databaseExporting && !$databaseLiveMetrics))
                ? __('Rows', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? (string) ($dbProgress['batches_metric_label'] ?? __('Throughput', 'xerotact-shield-x-security'))
                    : __('Batches', 'xerotact-shield-x-security')),
            'files_processed' => $databaseExporting ? $databaseTablesProcessed : ($filesSeen + $deletedCursor),
            'files_total' => $databaseExporting
                ? max($databaseTablesTotal, 1)
                : max($filesSeen + $pending + (empty($job['deleted_done']) ? $baseKeys : 0), 1),
            'files_label' => $databasePreparing
                ? __('Preparing database export', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? (string) ($dbProgress['files_label'] ?? __('Preparing database tables', 'xerotact-shield-x-security'))
                    : ($fullRootExport
                ? sprintf(
                    /* translators: %d: number of files captured in the full backup. */
                    __('%d files captured', 'xerotact-shield-x-security'),
                    $filesCount
                )
                : sprintf(
                    /* translators: %d: number of file metadata records checked for an incremental restore point. */
                    __('%d metadata checks', 'xerotact-shield-x-security'),
                    $filesSeen
                ))),
            'batches' => $databaseExporting ? $databaseRowsExported : $batches,
            'batches_label' => $databasePreparing
                ? __('Queued', 'xerotact-shield-x-security')
                : ($databaseExporting
                    ? (string) ($dbProgress['batches_label'] ?? __('Preparing rows', 'xerotact-shield-x-security'))
                    : sprintf(
                        /* translators: %d: number of progressive backup batches completed. */
                        __('%d completed', 'xerotact-shield-x-security'),
                        $batches
                    )),
            'scope_label' => $databasePreparing
                ? ($fullRootExport
                    ? __('Full backup: preparing the complete database export.', 'xerotact-shield-x-security')
                    : __('Restore point: preparing the database export.', 'xerotact-shield-x-security'))
                : ($databaseExporting
                    ? (string) ($dbProgress['scope_label'] ?? ($fullRootExport
                        ? __('Full backup: exporting the complete database first.', 'xerotact-shield-x-security')
                        : __('Restore point: exporting database first.', 'xerotact-shield-x-security')))
                : ($fullRootExport
                    ? sprintf(
                        /* translators: 1: number of files captured, 2: number of files checked. */
                        __('Full backup: captured %1$d files after checking %2$d files.', 'xerotact-shield-x-security'),
                        $filesCount,
                        $filesSeen
                    )
                    : sprintf(
                        /* translators: 1: number of files skipped by unchanged metadata, 2: number of files hashed. */
                        __('Restore point: skipped %1$d unchanged files, hashed %2$d changed candidates.', 'xerotact-shield-x-security'),
                        $metadataSkipped,
                        $hashChecks
                    ))),
        ];
    }

    /**
     * @return array<int,string>
     */
    private function directoryChildren(string $directory): array
    {
        $items = scandir($directory);
        if (!is_array($items)) {
            return [];
        }

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

            $path = $directory . '/' . $item;
            if (is_link($path) || $this->isInsideStorage($path)) {
                continue;
            }

            if (is_dir($path) || is_file($path)) {
                $children[] = $path;
            }
        }

        return array_reverse($children);
    }

    /**
     * @param array<string,mixed> $job
     * @param array<string,mixed> $manifest
     */
    private function processDeletedFileBatch(array &$job, array &$manifest, int $batchFiles, int $maxSeconds, float $started): void
    {
        $keys = isset($job['base_file_keys']) && is_array($job['base_file_keys']) ? array_values(array_map('strval', $job['base_file_keys'])) : [];
        $baseFileIndex = isset($job['base_file_index']) && is_array($job['base_file_index']) ? $job['base_file_index'] : [];
        $selectedPaths = isset($job['selected_paths']) && is_array($job['selected_paths']) ? array_values(array_map('strval', $job['selected_paths'])) : [];
        $cursor = max(0, (int) ($job['deleted_cursor'] ?? 0));
        $processed = 0;

        while ($cursor < count($keys) && $processed < $batchFiles) {
            $relative = trim(str_replace('\\', '/', (string) $keys[$cursor]), '/');
            $previous = isset($baseFileIndex[$relative]) && is_array($baseFileIndex[$relative]) ? $baseFileIndex[$relative] : [];
            $this->captureDeletedFileCandidate($relative, $previous, $selectedPaths, $manifest);
            $cursor++;
            $processed++;

            if (!empty($manifest['truncated']) || (microtime(true) - $started) >= $maxSeconds) {
                break;
            }
        }

        $job['deleted_cursor'] = $cursor;
        $job['deleted_done'] = $cursor >= count($keys) || !empty($manifest['truncated']);
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function capturePath(string $path, string $filesDirectory, array &$manifest, array $baseFileIndex = [], bool $incremental = false): void
    {
        $absolute = $this->normalizePath($path);
        if ($absolute === '') {
            return;
        }

        if (is_dir($absolute)) {
            $this->captureDirectory($absolute, $filesDirectory, $manifest, $baseFileIndex, $incremental);
            return;
        }

        $this->captureFile($absolute, $filesDirectory, $manifest, $baseFileIndex, $incremental);
    }

    /**
     * @param array<int,mixed> $paths
     * @return array<int,string>
     */
    private function cleanRelativePaths(array $paths): array
    {
        $clean = [];
        foreach ($paths as $path) {
            $path = trim(str_replace('\\', '/', (string) $path), '/');
            $path = preg_replace('#/+#', '/', $path);
            $path = is_string($path) ? str_replace("\0", '', $path) : '';
            if ($path === '' || strpos($path, '..') !== false) {
                continue;
            }

            $clean[] = $path;
        }

        return array_values(array_unique($clean));
    }

    /**
     * @param array<int,mixed> $tables
     * @return array<int,string>
     */
    private function cleanTableNames(array $tables): array
    {
        $clean = [];
        foreach ($tables as $table) {
            $table = trim((string) $table);
            if (preg_match('/^[A-Za-z0-9_]+$/', $table) === 1) {
                $clean[] = $table;
            }
        }

        return array_values(array_unique($clean));
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function captureDirectory(string $directory, string $filesDirectory, array &$manifest, array $baseFileIndex = [], bool $incremental = false): void
    {
        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::SELF_FIRST
            );
        } catch (\UnexpectedValueException $error) {
            $manifest['scan_errors_count'] = (int) ($manifest['scan_errors_count'] ?? 0) + 1;

            return;
        }

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

                $path = $file->getPathname();
                if ($this->isInsideStorage($path)) {
                    continue;
                }

                $this->captureFile($path, $filesDirectory, $manifest, $baseFileIndex, $incremental);
                if (!empty($manifest['truncated'])) {
                    break;
                }
            }
        } catch (\UnexpectedValueException $error) {
            $manifest['scan_errors_count'] = (int) ($manifest['scan_errors_count'] ?? 0) + 1;
        }
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function captureFile(string $path, string $filesDirectory, array &$manifest, array $baseFileIndex = [], bool $incremental = false): void
    {
        if (is_file($path) || is_link($path)) {
            $path = $this->safeSnapshotSourcePath($path);
            if ($path === '') {
                return;
            }
        } elseif (!$this->pathHasNoSymlinks($path) || $this->isInsideStorage($path)) {
            return;
        }

        $relative = $this->relativePath($path);
        if ($relative === '' || $this->isInsideStorage($path)) {
            return;
        }
        if ($this->isProtectedRecoveryRuntimePath($relative)) {
            return;
        }

        // wp-config.php can contain database credentials and site salts. It is
        // intentionally excluded unless the administrator opted into a full
        // site snapshot policy.
        $fullRootExport = !empty($manifest['full_root_export']);
        if ($relative === 'wp-config.php' && !$fullRootExport && !$this->settings->allowFullSiteSnapshots()) {
            return;
        }

        if (!$this->fileTypes->allowsPath($relative, FileTypeSettings::CONTEXT_BACKUP)) {
            return;
        }

        clearstatcache(true, $path);
        $exists = is_file($path);
        $size = $exists ? (int) filesize($path) : 0;
        $modifiedAt = $exists ? $this->fileModifiedAt($path) : '';
        $previousIndex = isset($baseFileIndex[$relative]) && is_array($baseFileIndex[$relative]) ? $baseFileIndex[$relative] : null;
        $currentMetadata = [
            'relative_path' => $relative,
            'existed' => $exists,
            'size' => $size,
            'modified_at' => $modifiedAt,
            'hash_algorithm' => 'sha256',
            'hash' => '',
        ];
        $manifest['metadata_checked_count'] = (int) ($manifest['metadata_checked_count'] ?? 0) + 1;

        if ($incremental && $this->sameFileMetadata($currentMetadata, $previousIndex) && $this->canSkipIncrementalHash($relative)) {
            $currentMetadata['hash'] = (string) ($previousIndex['hash'] ?? '');
            $manifest['file_index'][$relative] = $currentMetadata;
            $manifest['metadata_skipped_count'] = (int) ($manifest['metadata_skipped_count'] ?? 0) + 1;
            return;
        }

        $hash = $exists ? (string) hash_file('sha256', $path) : '';
        if ($exists) {
            $manifest['hash_checks_count'] = (int) ($manifest['hash_checks_count'] ?? 0) + 1;
        }

        $currentIndex = $currentMetadata;
        $currentIndex['hash'] = $hash;
        $manifest['file_index'][$relative] = $currentIndex;

        if ($incremental && $this->sameFileState($currentIndex, $previousIndex)) {
            return;
        }

        if (count((array) $manifest['files']) >= $this->maxFiles || (int) $manifest['bytes_exported'] >= $this->maxBytes) {
            $manifest['truncated'] = true;
            return;
        }

        if ($exists && ((int) $manifest['bytes_exported'] + $size) > $this->maxBytes) {
            $manifest['truncated'] = true;
            return;
        }

        $backupPath = '';
        $backupRelative = '';
        if ($exists) {
            $backupRelative = $this->snapshotRelativePath($relative);
            if ($backupRelative === '') {
                return;
            }

            $backupPath = $filesDirectory . '/' . $backupRelative;
            $this->ensureDirectory(dirname($backupPath));
            if (!$this->safeSnapshotDestination($backupPath, $filesDirectory) || !$this->copySnapshotFile($path, $backupPath)) {
                return;
            }
        }

        $manifest['files'][] = [
            'relative_path' => $relative,
            'backup_path' => $backupRelative,
            'backup_relative_path' => $backupRelative,
            'existed' => $exists,
            'size' => $size,
            'modified_at' => $modifiedAt,
            'hash_algorithm' => 'sha256',
            'hash' => $hash,
            'change_type' => $this->changeType($currentIndex, $previousIndex),
        ];
        $manifest['bytes_exported'] = (int) $manifest['bytes_exported'] + $size;
    }

    private function canSkipIncrementalHash(string $relative): bool
    {
        $relative = strtolower(trim(str_replace('\\', '/', $relative), '/'));
        if ($relative === 'wp-config.php' || $relative === '.htaccess' || $relative === 'web.config' || $relative === 'index.php') {
            return false;
        }

        $extension = strtolower((string) pathinfo($relative, PATHINFO_EXTENSION));
        if (in_array($extension, ['php', 'phtml', 'phar', 'js', 'htaccess'], true)) {
            return false;
        }

        return true;
    }

    /**
     * @param array<int,string> $paths
     * @return array<int,string>
     */
    private function expandBackupTargets(array $paths, bool $forceFullSiteSnapshot = false): array
    {
        if ($forceFullSiteSnapshot && empty($paths)) {
            return $this->fullSitePaths();
        }

        $fallback = $this->settings->allowFullSiteSnapshots()
            ? $this->fullSitePaths()
            : $this->defaultCriticalPaths();
        $targets = empty($paths) ? $this->settings->defaultPaths($fallback) : $paths;

        return array_values(array_unique(array_filter(array_map('strval', $targets))));
    }

    /**
     * @param array<int,string> $selectedPaths
     * @param array<string,array<string,mixed>> $baseFileIndex
     * @param array<string,mixed> $manifest
     */
    private function captureDeletedFiles(array $selectedPaths, array &$manifest, array $baseFileIndex): void
    {
        foreach ($baseFileIndex as $relative => $previous) {
            $this->captureDeletedFileCandidate((string) $relative, is_array($previous) ? $previous : [], $selectedPaths, $manifest);
            if (!empty($manifest['truncated'])) {
                return;
            }
        }
    }

    /**
     * @param array<string,mixed> $previous
     * @param array<int,string> $selectedPaths
     * @param array<string,mixed> $manifest
     */
    private function captureDeletedFileCandidate(string $relative, array $previous, array $selectedPaths, array &$manifest): void
    {
        $relative = trim(str_replace('\\', '/', (string) $relative), '/');
        if ($relative === '' || isset($manifest['file_index'][$relative]) || !$this->relativePathMatchesTargets($relative, $selectedPaths)) {
            return;
        }

        if (!$this->fileTypes->allowsPath($relative, FileTypeSettings::CONTEXT_BACKUP)) {
            return;
        }

        $absolute = $this->absolutePath($relative);
        if ($absolute !== '' && file_exists($absolute)) {
            return;
        }

        $currentIndex = [
            'relative_path' => $relative,
            'existed' => false,
            'size' => 0,
            'modified_at' => '',
            'hash_algorithm' => 'sha256',
            'hash' => '',
        ];
        $manifest['file_index'][$relative] = $currentIndex;

        if (empty($previous['existed'])) {
            return;
        }

        if (count((array) $manifest['files']) >= $this->maxFiles) {
            $manifest['truncated'] = true;
            return;
        }

        $manifest['files'][] = [
            'relative_path' => $relative,
            'backup_path' => '',
            'backup_relative_path' => '',
            'existed' => false,
            'size' => 0,
            'modified_at' => '',
            'hash_algorithm' => 'sha256',
            'hash' => '',
            'change_type' => 'deleted',
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function backupFileTypePolicySummary(): array
    {
        return [
            'context' => FileTypeSettings::CONTEXT_BACKUP,
            'selected' => $this->fileTypes->selected(FileTypeSettings::CONTEXT_BACKUP),
            'media_selected' => array_values(array_intersect($this->fileTypes->selected(FileTypeSettings::CONTEXT_BACKUP), FileTypeSettings::mediaTypes())),
        ];
    }

    /**
     * Full-site incremental restore points need a complete full-site index.
     * A selected-path or truncated manifest would make unrelated files look new
     * and force unnecessary hashing on the next restore point.
     *
     * @return array<string,mixed>|null
     */
    private function latestFullSiteManifest(): ?array
    {
        $files = glob($this->restoreDirectory() . '/restore-*/manifest.json');
        if (!is_array($files) || empty($files)) {
            return null;
        }

        usort($files, static function (string $left, string $right): int {
            return self::restorePointPathTimestamp($right) <=> self::restorePointPathTimestamp($left);
        });

        foreach ($files as $file) {
            $manifest = $this->readJson($file);
            if (empty($manifest) || !$this->manifestHmacValid($manifest)) {
                continue;
            }

            if ((string) ($manifest['snapshot_scope'] ?? '') !== 'full_site' || !empty($manifest['truncated'])) {
                continue;
            }

            if (empty($manifest['file_index']) || !is_array($manifest['file_index'])) {
                continue;
            }

            return $manifest;
        }

        return null;
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array<string,array<string,mixed>>
     */
    private function manifestFileIndex(array $manifest): array
    {
        $index = [];
        $source = isset($manifest['file_index']) && is_array($manifest['file_index']) ? $manifest['file_index'] : [];

        if (!empty($source)) {
            foreach ($source as $relative => $file) {
                if (!is_array($file)) {
                    continue;
                }

                $relative = trim(str_replace('\\', '/', (string) ($file['relative_path'] ?? $relative)), '/');
                if ($relative !== '') {
                    $index[$relative] = $this->fileIndexEntry($relative, $file);
                }
            }

            return $index;
        }

        foreach ((array) ($manifest['files'] ?? []) as $file) {
            if (!is_array($file)) {
                continue;
            }

            $relative = trim(str_replace('\\', '/', (string) ($file['relative_path'] ?? '')), '/');
            if ($relative !== '') {
                $index[$relative] = $this->fileIndexEntry($relative, $file);
            }
        }

        return $index;
    }

    /**
     * @param array<string,mixed> $file
     * @return array<string,mixed>
     */
    private function fileIndexEntry(string $relative, array $file): array
    {
        return [
            'relative_path' => $relative,
            'existed' => !empty($file['existed']),
            'size' => (int) ($file['size'] ?? 0),
            'modified_at' => (string) ($file['modified_at'] ?? ''),
            'hash_algorithm' => (string) ($file['hash_algorithm'] ?? 'sha256'),
            'hash' => (string) ($file['hash'] ?? ''),
        ];
    }

    /**
     * @param array<string,mixed>|null $previous
     * @param array<string,mixed> $current
     */
    private function sameFileState(array $current, ?array $previous): bool
    {
        if ($previous === null) {
            return false;
        }

        return !empty($current['existed']) === !empty($previous['existed'])
            && (string) ($current['hash'] ?? '') === (string) ($previous['hash'] ?? '')
            && (int) ($current['size'] ?? 0) === (int) ($previous['size'] ?? 0);
    }

    /**
     * @param array<string,mixed>|null $previous
     * @param array<string,mixed> $current
     */
    private function sameFileMetadata(array $current, ?array $previous): bool
    {
        if ($previous === null || (string) ($previous['hash'] ?? '') === '') {
            return false;
        }

        return !empty($current['existed']) === !empty($previous['existed'])
            && (int) ($current['size'] ?? 0) === (int) ($previous['size'] ?? 0)
            && (string) ($current['modified_at'] ?? '') === (string) ($previous['modified_at'] ?? '');
    }

    /**
     * @param array<string,mixed>|null $previous
     * @param array<string,mixed> $current
     */
    private function changeType(array $current, ?array $previous): string
    {
        if ($previous === null || empty($previous['existed'])) {
            return !empty($current['existed']) ? 'new' : 'deleted';
        }

        if (empty($current['existed'])) {
            return 'deleted';
        }

        return 'modified';
    }

    /**
     * @param array<int,string> $selectedPaths
     */
    private function relativePathMatchesTargets(string $relative, array $selectedPaths): bool
    {
        $absolute = $this->absolutePath($relative);
        if ($absolute === '') {
            return false;
        }

        foreach ($selectedPaths as $target) {
            $target = $this->normalizePath($target);
            if ($target === '') {
                continue;
            }

            if ($absolute === $target || strpos($absolute, rtrim($target, '/') . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<int,string>
     */
    private function fullSitePaths(): array
    {
        $root = $this->rootPath();

        return $root !== '' ? [$root] : $this->defaultCriticalPaths();
    }

    private function enforceRetention(): void
    {
        $limit = $this->settings->retentionCount();
        $files = glob($this->restoreDirectory() . '/restore-*/manifest.json');
        if (!is_array($files) || count($files) <= $limit) {
            return;
        }

        usort($files, static function (string $left, string $right): int {
            return self::restorePointPathTimestamp($right) <=> self::restorePointPathTimestamp($left);
        });

        $fileByRestoreId = [];
        foreach ($files as $file) {
            $restoreId = basename(dirname($file));
            if ($restoreId !== '') {
                $fileByRestoreId[$restoreId] = $file;
            }
        }

        $retained = [];
        foreach (array_slice($files, 0, $limit) as $file) {
            $this->markRestorePointAndAncestorsRetained($file, $fileByRestoreId, $retained);
        }

        foreach ($files as $file) {
            $restoreId = basename(dirname($file));
            if ($restoreId !== '' && isset($retained[$restoreId])) {
                continue;
            }

            $this->removePath(dirname($file));
        }
    }

    /**
     * @param array<string,string> $fileByRestoreId
     * @param array<string,bool>   $retained
     */
    private function markRestorePointAndAncestorsRetained(string $file, array $fileByRestoreId, array &$retained): void
    {
        for ($depth = 0; $depth < 50; $depth++) {
            $restoreId = basename(dirname($file));
            if ($restoreId === '' || isset($retained[$restoreId])) {
                return;
            }

            $retained[$restoreId] = true;
            $manifest = $this->readJson($file);
            $baseRestoreId = is_array($manifest) ? (string) ($manifest['base_restore_id'] ?? '') : '';
            if ($baseRestoreId === '' || !isset($fileByRestoreId[$baseRestoreId])) {
                return;
            }

            $file = $fileByRestoreId[$baseRestoreId];
        }
    }

    /**
     * @param array<string,mixed> $hookExtra
     * @return array<int,string>
     */
    private function hookExtraItems(array $hookExtra, string $key): array
    {
        if (!isset($hookExtra[$key])) {
            return [];
        }

        $value = $hookExtra[$key];
        if (is_array($value)) {
            return array_values(array_filter(array_map('strval', $value)));
        }

        return is_scalar($value) ? [(string) $value] : [];
    }

    /**
     * @return array<string,mixed>|null
     */
    private function manifestById(string $restoreId, string $source = 'auto'): ?array
    {
        if (!preg_match('/^restore-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $restoreId)) {
            return null;
        }

        $source = $this->sanitizeRestoreSource($source);
        $manifest = $this->readJson($this->restoreDirectory() . '/' . $restoreId . '/manifest.json');
        $valid = !empty($manifest) && $this->manifestHmacValid($manifest);
        if (!$valid && $source === 'auto') {
            if (!BackupStorageManager::fromWordPress()->ensureRestorePointLocal($restoreId)) {
                return null;
            }

            $manifest = $this->readJson($this->restoreDirectory() . '/' . $restoreId . '/manifest.json');
            $valid = !empty($manifest) && $this->manifestHmacValid($manifest);
        }
        if (!$valid) {
            return null;
        }

        return $manifest;
    }

    private function sanitizeRestoreSource(string $source): string
    {
        $source = strtolower(trim($source));

        return in_array($source, ['auto', 'local', 'google'], true) ? $source : 'auto';
    }

    private function restorePointCleanupCutoff(string $beforeDate): ?int
    {
        $beforeDate = trim($beforeDate);
        if (!preg_match('/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/', $beforeDate, $matches)) {
            return null;
        }

        $year = (int) $matches[1];
        $month = (int) $matches[2];
        $day = (int) $matches[3];
        if (!checkdate($month, $day, $year)) {
            return null;
        }

        $cutoff = gmmktime(0, 0, 0, $month, $day, $year);
        if ($cutoff === false) {
            return null;
        }

        $today = gmmktime(0, 0, 0, (int) gmdate('m'), (int) gmdate('d'), (int) gmdate('Y'));
        if ($today === false || $cutoff > $today) {
            return null;
        }

        return $cutoff;
    }

    /**
     * @return array{restore_id:string,created_at:string}|null
     */
    private function restorePointInfoFromDirectory(string $directory): ?array
    {
        $restoreId = basename($directory);
        if (!preg_match('/^restore-([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2})-[a-f0-9]{32}$/', $restoreId, $matches)) {
            return null;
        }

        $year = (int) $matches[1];
        $month = (int) $matches[2];
        $day = (int) $matches[3];
        $hour = (int) $matches[4];
        $minute = (int) $matches[5];
        $second = (int) $matches[6];
        if (!checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) {
            return null;
        }

        return [
            'restore_id' => $restoreId,
            'created_at' => gmdate('c', gmmktime($hour, $minute, $second, $month, $day, $year)),
        ];
    }

    private static function restorePointPathTimestamp(string $manifestPath): int
    {
        $restoreId = basename(dirname($manifestPath));
        if (!preg_match('/^restore-([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2})-[a-f0-9]{32}$/', $restoreId, $matches)) {
            return 0;
        }

        $timestamp = gmmktime((int) $matches[4], (int) $matches[5], (int) $matches[6], (int) $matches[2], (int) $matches[3], (int) $matches[1]);

        return is_int($timestamp) ? $timestamp : 0;
    }

    private function hasActiveProgressiveRestorePointJob(string $directory, string $root): bool
    {
        $realDirectory = realpath($directory);
        if (!is_string($realDirectory) || $realDirectory === '' || !$this->pathInsideRootByString($realDirectory, $root)) {
            return false;
        }

        $jobPath = rtrim(str_replace('\\', '/', $realDirectory), '/') . '/job.json';
        if (!is_file($jobPath) && !is_link($jobPath)) {
            return false;
        }

        if (is_link($jobPath)) {
            return true;
        }

        $raw = is_readable($jobPath) ? file_get_contents($jobPath) : false;
        $job = is_string($raw) ? \ShieldX\Support\Json::decodeArray($raw) : null;
        if (is_array($job) && (string) ($job['status'] ?? '') === 'running') {
            return true;
        }

        $modified = filemtime($jobPath);

        return is_int($modified) && $modified >= (time() - 7200);
    }

    /**
     * @return array{ok:bool,deleted_files:int,deleted_bytes:int,failed_files:int}
     */
    private function deleteDirectoryInsideRoot(string $path, string $root): array
    {
        $root = rtrim(str_replace('\\', '/', $root), '/');
        $real = realpath($path);
        if (!is_string($real) || $real === '') {
            return [
                'ok' => false,
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'failed_files' => 1,
            ];
        }

        $real = rtrim(str_replace('\\', '/', $real), '/');
        if ($real === $root || strpos($real, $root . '/') !== 0 || !is_dir($real)) {
            return [
                'ok' => false,
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'failed_files' => 1,
            ];
        }

        $deletedFiles = 0;
        $deletedBytes = 0;
        $failedFiles = 0;

        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::CHILD_FIRST
            );
        } catch (\Throwable $error) {
            return [
                'ok' => false,
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'failed_files' => 1,
            ];
        }

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

            $itemPath = $item->getPathname();
            if (!$this->pathInsideRootByString($itemPath, $real)) {
                $failedFiles++;
                continue;
            }

            if ($item->isDir() && !$item->isLink()) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes private restore-point cleanup directories selected by an administrator.
                if (!rmdir($itemPath)) {
                    $failedFiles++;
                }
                continue;
            }

            $size = $item->isFile() ? max(0, (int) $item->getSize()) : 0;
            if ($this->deletePrivateFile($itemPath)) {
                $deletedFiles++;
                $deletedBytes += $size;
            } else {
                $failedFiles++;
            }
        }

        if ($failedFiles === 0) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the private restore-point root directory after all child files are removed.
            if (!rmdir($real)) {
                $failedFiles++;
            }
        }

        return [
            'ok' => $failedFiles === 0,
            'deleted_files' => $deletedFiles,
            'deleted_bytes' => $deletedBytes,
            'failed_files' => $failedFiles,
        ];
    }

    private function pathInsideRootByString(string $path, string $root): bool
    {
        $path = str_replace('\\', '/', $path);
        $root = rtrim(str_replace('\\', '/', $root), '/');

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

    private function deletePrivateFile(string $path): bool
    {
        if (!is_file($path) && !is_link($path)) {
            return false;
        }

        if (function_exists('wp_delete_file') && !is_link($path)) {
            return (bool) wp_delete_file($path);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes administrator-selected private restore-point files when WordPress helpers are unavailable in tests or for symlinks.
        return unlink($path);
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function writeManifest(string $restoreId, array $manifest): void
    {
        $directory = $this->restoreDirectory() . '/' . $restoreId;
        $this->ensureDirectory($directory);
        $manifest['manifest_hmac'] = $this->manifestHmac($manifest);
        $json = wp_json_encode($manifest, JSON_PRETTY_PRINT);
        if (is_string($json)) {
            $this->writeJsonFile($directory . '/manifest.json', $json . "\n");
        }
    }

    /**
     * @return array<string,mixed>
     */
    private function readRestoreJob(string $restoreId): array
    {
        $path = $this->restoreJobPath($restoreId);
        if ($path !== '' && is_file($path) && is_readable($path)) {
            $raw = file_get_contents($path);
            $decoded = is_string($raw) ? \ShieldX\Support\Json::decodeArrayRaw($raw, self::MAX_RESTORE_JSON_BYTES) : null;
            if (is_array($decoded)) {
                return $decoded;
            }
        }

        $durable = $this->durableJobs->read('restore_point', $restoreId);

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

    /**
     * @param array<string,mixed> $job
     */
    private function writeRestoreJob(string $restoreId, array $job): bool
    {
        $path = $this->restoreJobPath($restoreId);
        if ($path === '') {
            return false;
        }

        $json = wp_json_encode($job);
        if (is_string($json) && $this->writeJsonFile($path, $json . "\n")) {
            // Large progressive manifests must stay in private file storage.
            // Duplicating them into wp_options makes persistent object caches
            // allocate the entire job repeatedly on every batch request.
            $this->durableJobs->delete('restore_point', $restoreId);

            return true;
        }

        return $this->durableJobs->save('restore_point', $restoreId, $job, 86400);
    }

    private function removeRestoreJob(string $restoreId): void
    {
        $this->durableJobs->delete('restore_point', $restoreId);
        $path = $this->restoreJobPath($restoreId);
        if ($path !== '' && is_file($path)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes private progressive restore-point job state after completion.
            unlink($path);
        }
    }

    private function restoreJobPath(string $restoreId): string
    {
        if (!preg_match('/^restore-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $restoreId)) {
            return '';
        }

        return $this->restoreDirectory() . '/' . $restoreId . '/job.json';
    }

    private function writeJsonFile(string $path, string $json): bool
    {
        $directory = dirname($path);
        $this->ensureDirectory($directory);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Private restore-point metadata uses same-directory temp files for atomic writes.
        $temporary = tempnam($directory, 'shieldx-restore-');
        if (!is_string($temporary) || $temporary === '') {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Private restore-point metadata under SHIELD-X storage.
        if (file_put_contents($temporary, $json, LOCK_EX) === false) {
            $this->removeTemporaryFile($temporary);
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Defense-in-depth for private backup metadata.
        chmod($temporary, 0600);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replacement avoids partial restore-point metadata.
        if (!rename($temporary, $path)) {
            $this->removeTemporaryFile($temporary);
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Keep final restore-point metadata private where modes are honored.
        chmod($path, 0600);

        return true;
    }

    private function removeTemporaryFile(string $path): void
    {
        if (is_file($path)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Remove failed private restore-point temp file.
            unlink($path);
        }
    }

    /**
     * @return array<string,mixed>
     */
    private function readJson(string $path): array
    {
        if (!is_file($path) || !is_readable($path)) {
            return [];
        }

        $raw = file_get_contents($path);
        $decoded = is_string($raw) ? \ShieldX\Support\Json::decodeArrayRaw($raw, self::MAX_RESTORE_JSON_BYTES) : null;

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

    /**
     * @return array{hash:string,contents:string}
     */
    private function readSnapshotPreview(string $path): array
    {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streams a private restore-point snapshot for bounded preview without loading large files.
        $handle = fopen($path, 'rb');
        if (!is_resource($handle)) {
            return [
                'hash' => '',
                'contents' => '',
            ];
        }

        $contents = '';

        try {
            while (!feof($handle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Streams a private restore-point snapshot in small chunks for bounded preview.
                $chunk = fread($handle, 8192);
                if (!is_string($chunk) || $chunk === '') {
                    break;
                }

                $remaining = self::PREVIEW_MAX_BYTES - strlen($contents);
                if ($remaining > 0) {
                    $contents .= substr($chunk, 0, $remaining);
                } else {
                    break;
                }
            }
        } finally {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the private restore-point snapshot preview stream opened above.
            fclose($handle);
        }

        return [
            'hash' => '',
            'contents' => $contents,
        ];
    }

    private function newRestoreId(): string
    {
        return 'restore-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(16));
    }

    private function sanitizeReason(string $reason): string
    {
        $reason = trim($reason);
        if ($reason === '') {
            return 'manual';
        }

        $reason = preg_replace('/[^A-Za-z0-9 .:_-]+/', '-', $reason);

        return is_string($reason) ? substr($reason, 0, 120) : 'manual';
    }

    /**
     * @param array<int,mixed> $warnings
     * @return array<int,string>
     */
    private function sanitizeDatabaseWarningDetails(array $warnings): array
    {
        $clean = [];
        foreach ($warnings as $warning) {
            $warning = trim(preg_replace('/[\r\n\t]+/', ' ', (string) $warning) ?: '');
            if ($warning === '') {
                continue;
            }

            $clean[] = substr($warning, 0, 240);
            if (count($clean) >= 20) {
                break;
            }
        }

        return array_values(array_unique($clean));
    }

    private function normalizePath(string $path): string
    {
        $path = str_replace('\\', '/', trim($path));
        if ($path === '') {
            return '';
        }

        if (!$this->isAbsolute($path)) {
            $path = $this->rootPath() . '/' . ltrim($path, '/');
        }

        $real = realpath($path);
        if ($real !== false) {
            $path = $real;
        }

        $path = str_replace('\\', '/', $path);
        $root = $this->rootPath();

        return self::pathEqualsOrInside($path, $root) ? $path : '';
    }

    private function absolutePath(string $relative): string
    {
        $relative = trim(str_replace('\\', '/', $relative), '/');
        if ($relative === '' || strpos($relative, '..') !== false) {
            return '';
        }

        return $this->rootPath() . '/' . $relative;
    }

    private function safeBackupPath(string $backupPath, string $filesRoot): string
    {
        if ($backupPath === '' || $filesRoot === '') {
            return '';
        }

        $filesRootReal = realpath($filesRoot);
        $candidate = $this->isAbsolutePath($backupPath) ? $backupPath : rtrim($filesRoot, '/\\') . '/' . ltrim($backupPath, '/\\');
        $backupReal = realpath($candidate);
        if (!is_string($filesRootReal) || !is_string($backupReal) || !is_file($backupReal)) {
            return '';
        }

        $filesRootReal = rtrim(str_replace('\\', '/', $filesRootReal), '/');
        $backupReal = str_replace('\\', '/', $backupReal);

        return ($backupReal === $filesRootReal || strpos($backupReal, $filesRootReal . '/') === 0) ? $backupReal : '';
    }

    private function snapshotRelativePath(string $relative): string
    {
        $relative = trim(str_replace('\\', '/', $relative), '/');
        if ($relative === '' || strpos($relative, "\0") !== false || strpos($relative, '../') !== false || strpos($relative, '/..') !== false) {
            return '';
        }

        return $relative . self::SNAPSHOT_SUFFIX;
    }

    private function safeSnapshotSourcePath(string $path): string
    {
        $path = str_replace('\\', '/', $path);
        $root = realpath($this->rootPath());
        $real = realpath($path);
        if (
            !is_string($root)
            || !is_string($real)
            || $root === ''
            || $real === ''
            || is_link($path)
            || !$this->pathHasNoSymlinks($path)
            || !is_file($real)
            || !self::pathEqualsOrInside($real, $root)
            || $this->isInsideStorage($real)
        ) {
            return '';
        }

        return $path;
    }

    private function safeSnapshotDestination(string $path, string $filesDirectory): bool
    {
        $filesRoot = realpath($filesDirectory);
        $parent = realpath(dirname($path));

        return is_string($filesRoot)
            && is_string($parent)
            && $filesRoot !== ''
            && $parent !== ''
            && !is_link($path)
            && self::pathEqualsOrInside($parent, $filesRoot);
    }

    private function pathHasNoSymlinks(string $path): bool
    {
        $root = rtrim(str_replace('\\', '/', $this->rootPath()), '/');
        $path = str_replace('\\', '/', $path);
        if (!self::pathEqualsOrInside($path, $root)) {
            return false;
        }

        $relative = ltrim(substr($path, strlen($root)), '/');
        $candidate = $root;
        foreach (explode('/', $relative) as $segment) {
            if ($segment === '') {
                continue;
            }

            $candidate .= '/' . $segment;
            if (is_link($candidate)) {
                return false;
            }
        }

        return true;
    }

    private function copySnapshotFile(string $source, string $destination): bool
    {
        $result = $this->filesystem->copy($source, $destination, 0600);
        if (!empty($result['ok'])) {
            return true;
        }

        // Private restore-point storage remains usable for cron and recovery on
        // hosts where WordPress cannot initialize its filesystem API.
        return (string) ($result['code'] ?? '') === 'filesystem_unavailable' && copy($source, $destination);
    }

    private function isAbsolutePath(string $path): bool
    {
        return preg_match('#^(?:[A-Za-z]:[\\\\/]|[\\\\/]{2}|/)#', $path) === 1;
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function manifestHmacValid(array $manifest): bool
    {
        $hmac = (string) ($manifest['manifest_hmac'] ?? '');

        return $hmac !== '' && hash_equals($hmac, $this->manifestHmac($manifest));
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function manifestHmac(array $manifest): string
    {
        $context = hash_init('sha256', HASH_HMAC, $this->manifestHmacKey());
        if (!$this->hashCanonicalJson($context, $manifest, true)) {
            return hash_hmac('sha256', '', $this->manifestHmacKey());
        }

        return hash_final($context);
    }

    /**
     * Stream the same canonical JSON produced by the legacy recursive-copy
     * implementation into the HMAC context. Large restore manifests otherwise
     * require the decoded manifest, a second canonicalized copy, and the full
     * encoded JSON payload to coexist in memory.
     *
     * @param resource|\HashContext $context
     * @param mixed $value
     */
    private function hashCanonicalJson($context, $value, bool $excludeManifestHmac = false): bool
    {
        if (!is_array($value)) {
            $encoded = wp_json_encode($value, JSON_UNESCAPED_SLASHES);

            return is_string($encoded) && hash_update($context, $encoded);
        }

        $keys = array_keys($value);
        if ($excludeManifestHmac) {
            $keys = array_values(array_filter($keys, static function ($key): bool {
                return $key !== 'manifest_hmac';
            }));
        }
        $isList = empty($keys) || $keys === range(0, count($keys) - 1);
        if ($isList) {
            if (!hash_update($context, '[')) {
                return false;
            }

            foreach ($keys as $index => $key) {
                if ($index > 0 && !hash_update($context, ',')) {
                    return false;
                }
                if (!$this->hashCanonicalJson($context, $value[$key])) {
                    return false;
                }
            }

            return hash_update($context, ']');
        }

        sort($keys);
        if (!hash_update($context, '{')) {
            return false;
        }
        foreach ($keys as $index => $key) {
            $encodedKey = wp_json_encode((string) $key, JSON_UNESCAPED_SLASHES);
            if (!is_string($encodedKey)
                || ($index > 0 && !hash_update($context, ','))
                || !hash_update($context, $encodedKey . ':')
                || !$this->hashCanonicalJson($context, $value[$key])
            ) {
                return false;
            }
        }

        return hash_update($context, '}');
    }

    private function manifestHmacKey(): string
    {
        if (function_exists('wp_salt')) {
            return wp_salt('secure_auth');
        }

        return 'shield-x-restore-point|' . $this->rootPath() . '|' . $this->storage->baseDir();
    }

    private function relativePath(string $path): string
    {
        $path = str_replace('\\', '/', $path);
        $root = rtrim($this->rootPath(), '/') . '/';
        if (strpos($path, $root) !== 0) {
            return '';
        }

        return ltrim(substr($path, strlen($root)), '/');
    }

    private function fileModifiedAt(string $path): string
    {
        $modified = is_file($path) ? filemtime($path) : false;

        return is_int($modified) && $modified > 0 ? gmdate('Y-m-d H:i:s', $modified) : '';
    }

    private function isInsideStorage(string $path): bool
    {
        $path = str_replace('\\', '/', $path);
        $storage = str_replace('\\', '/', $this->storage->baseDir());

        return self::pathEqualsOrInside($path, $storage);
    }

    private static function pathEqualsOrInside(string $path, string $root): bool
    {
        $path = rtrim(str_replace('\\', '/', $path), '/');
        $root = rtrim(str_replace('\\', '/', $root), '/');

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

    private function isAbsolute(string $path): bool
    {
        return preg_match('/^[A-Za-z]:\//', $path) === 1 || strpos($path, '/') === 0;
    }

    private function rootPath(): string
    {
        $root = $this->rootDir !== '' ? $this->rootDir : WordPressPaths::root();
        $root = $root !== '' ? $root : getcwd();
        $root = str_replace('\\', '/', (string) $root);

        return rtrim($root, '/');
    }

    private function contentPath(): string
    {
        $storage = $this->normalizeDirectory($this->storage->baseDir());
        $uploads = $storage !== '' ? $this->normalizeDirectory(dirname($storage)) : '';
        if ($uploads !== '' && basename($storage) === '.shield-x' && basename($uploads) === 'uploads') {
            $content = $this->normalizeDirectory(dirname($uploads));
            $root = $this->rootPath();
            if ($root === '' || self::pathEqualsOrInside($content, $root)) {
                return $content;
            }
        }

        $content = $this->definedDirectory('SHIELDX_CONTENT_DIR');
        if ($content !== '') {
            return $content;
        }

        return WordPressPaths::contentDir();
    }

    private function definedDirectory(string $constant): string
    {
        if (!defined($constant)) {
            return '';
        }

        $value = constant($constant);

        return is_string($value) ? $this->normalizeDirectory($value) : '';
    }

    private function normalizeDirectory(string $path): string
    {
        $path = trim(str_replace('\\', '/', $path));
        if ($path === '') {
            return '';
        }

        $real = realpath($path);
        if (is_string($real) && $real !== '') {
            $path = str_replace('\\', '/', $real);
        }

        return rtrim($path, '/');
    }

    private function ensureDirectory(string $path): void
    {
        if (is_dir($path)) {
            return;
        }

        if (function_exists('wp_mkdir_p')) {
            wp_mkdir_p($path);
            return;
        }

        mkdir($path, 0755, true);
    }

    private function copyWithTemporaryWrite(string $source, string $target): bool
    {
        $parent = dirname($target);

        if (!is_file($source) || !is_readable($source)) {
            return false;
        }

        if (!$this->restoreParentPathAllowed($parent) || !is_writable($parent)) {
            return false;
        }

        if (is_link($target)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Replace a live restore-target symlink itself; never follow it outside the WordPress root.
            if (!unlink($target)) {
                return false;
            }
        } elseif (file_exists($target) && !is_file($target)) {
            return false;
        } elseif (is_file($target) && !is_writable($target)) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Restore writes use same-directory temp files so rename publishes a complete file.
        $temporary = tempnam($parent, 'shieldx-restore-');
        if (!is_string($temporary) || $temporary === '') {
            return false;
        }

        if (!copy($source, $temporary)) {
            $this->removeTemporaryRestoreFile($temporary);
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic same-directory publish keeps restored PHP files from being half-written.
        if (!rename($temporary, $target)) {
            $this->removeTemporaryRestoreFile($temporary);
            return false;
        }

        return true;
    }

    private function deleteWithTemporaryWrite(string $target): bool
    {
        $parent = dirname($target);

        if (!$this->restoreParentPathAllowed($parent)) {
            return false;
        }

        if (is_link($target)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Delete the restore-target symlink itself; never follow it.
            return unlink($target);
        }

        if (!is_file($target) || !is_writable($target)) {
            return false;
        }

        if (!is_writable($parent)) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Applies a signed restore-point deletion to a verified in-root target.
        return unlink($target);
    }

    private function restoreParentPathAllowed(string $parent): bool
    {
        if (!is_dir($parent)) {
            return false;
        }

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

        $root = rtrim(str_replace('\\', '/', $root), '/');
        $parentReal = rtrim(str_replace('\\', '/', $parentReal), '/');

        return self::pathEqualsOrInside($parentReal, $root) && $this->pathHasNoSymlinks($parentReal);
    }

    private function removeTemporaryRestoreFile(string $path): void
    {
        if (is_file($path)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes failed same-directory restore temp file.
            unlink($path);
        }
    }

    private function isProtectedRestorePath(string $relative): bool
    {
        $relative = trim(str_replace('\\', '/', $relative), '/');
        if ($relative === '') {
            return false;
        }

        if ($this->isProtectedRecoveryRuntimePath($relative)) {
            return true;
        }

        $protectedRoots = ['wp-content/plugins/xerotact-shield-x-security'];
        if (defined('SHIELDX_PLUGIN_DIR')) {
            $pluginRelative = $this->relativePath((string) SHIELDX_PLUGIN_DIR);
            if ($pluginRelative !== '') {
                $protectedRoots[] = $pluginRelative;
            }
        }

        foreach (array_unique($protectedRoots) as $root) {
            $root = trim(str_replace('\\', '/', $root), '/');
            if ($root !== '' && ($relative === $root || strpos($relative, $root . '/') === 0)) {
                return true;
            }
        }

        return false;
    }

    private function isProtectedRecoveryRuntimePath(string $relative): bool
    {
        $relative = trim(str_replace('\\', '/', $relative), '/');

        return in_array($relative, [
            'wp-content/shieldx-recovery-gateway.php',
            'wp-content/mu-plugins/shieldx-restore-health.php',
        ], true);
    }

    private function removePath(string $path): void
    {
        if (!file_exists($path)) {
            return;
        }

        if (is_file($path) || is_link($path)) {
            unlink($path);
            return;
        }

        $items = scandir($path);
        if (!is_array($items)) {
            return;
        }

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

            $this->removePath($path . DIRECTORY_SEPARATOR . $item);
        }

        rmdir($path);
    }
}
