<?php

declare(strict_types=1);

namespace ShieldX\Maintenance;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Database backup/export must inspect dynamic WordPress table names and create plugin-owned backup folders.

use ShieldX\Storage\FileStorage;
use ShieldX\Storage\DurableJobStore;

final class DatabaseBackupManager
{
    private const MAX_BACKUP_JSON_BYTES = 268435456;
    private const PROGRESSIVE_PAGE_MIN_ROWS = 250;
    private const PROGRESSIVE_PAGE_MAX_ROWS = 1000;
    private const PROGRESSIVE_PAGE_TARGET_BYTES = 524288;
    private const PROGRESSIVE_PAGE_MAX_BYTES = 1048576;
    private const PROGRESSIVE_PAGE_FAST_SECONDS = 0.40;
    private const PROGRESSIVE_PAGE_SLOW_SECONDS = 1.50;
    private const PROGRESSIVE_WORK_RESERVE_SECONDS = 0.35;
    private const RESTORE_INSERT_BATCH_ROWS = 1000;
    private const RESTORE_INSERT_BATCH_BYTES = 2097152;

    /** @var object|null */
    private $wpdb;

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

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

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

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

    public function __construct($wpdb, FileStorage $storage, ?DurableJobStore $durableJobs = null)
    {
        $this->wpdb = $wpdb;
        $this->storage = $storage;
        $this->durableJobs = $durableJobs ?: new DurableJobStore();
    }

    public static function fromWordPress(): self
    {
        global $wpdb;

        return new self($wpdb, FileStorage::fromWordPress(), new DurableJobStore());
    }

    /**
     * @param array<int,string> $tables
     * @return array<string,mixed>
     */
    public function create(array $tables = [], int $maxRowsPerTable = 5000, bool $syncRemote = true, int $maxSeconds = 0, bool $completeTableExport = false): array
    {
        if (!$this->hasDatabase()) {
            return [
                'ok' => false,
                'message' => 'Database is not available.',
            ];
        }

        $this->storage->ensure();
        $this->ensureDirectory($this->backupDirectory());

        $available = $this->discoverTables();
        $selected = $this->selectedTables($tables, $available);
        if (empty($selected)) {
            return [
                'ok' => false,
                'message' => 'No matching WordPress tables were selected.',
                'available_tables' => $available,
            ];
        }

        $started = microtime(true);
        $backupId = $this->newBackupId();
        $createdAt = gmdate('c');
        $maxRows = max(1, min(50000, $maxRowsPerTable));
        $maxSeconds = $maxSeconds > 0 ? max(2, min(20, $maxSeconds)) : 0;
        $path = $this->backupPath($backupId);
        $temporaryDirectory = $this->temporaryBackupDirectory($backupId);
        if ($path === '' || $temporaryDirectory === '') {
            return [
                'ok' => false,
                'message' => 'Could not prepare database backup storage.',
            ];
        }

        $this->ensureDirectory($temporaryDirectory);
        $jsonPath = $temporaryDirectory . '/' . $backupId . '.json';
        $streamed = $this->writeDatabaseBackupStream($jsonPath, $backupId, $createdAt, $selected, $maxRows, $started, $maxSeconds, $completeTableExport);

        if (empty($streamed['ok'])) {
            $this->removeDirectory($temporaryDirectory);

            return [
                'ok' => false,
                'message' => (string) ($streamed['message'] ?? 'Could not write database backup file.'),
            ];
        }

        $zipped = $this->createDatabaseBackupZip($jsonPath, $path);
        $this->removeDirectory($temporaryDirectory);
        if (empty($zipped['ok'])) {
            $this->removeTemporaryFile($path);

            return [
                'ok' => false,
                'message' => (string) ($zipped['message'] ?? 'Could not compress database backup file.'),
            ];
        }

        $result = [
            'ok' => true,
            'message' => 'Database backup created.',
            'backup_id' => $backupId,
            'path' => $path,
            'created_at' => $createdAt,
            'tables' => (int) ($streamed['table_count'] ?? 0),
            'rows_exported' => (int) ($streamed['rows_exported'] ?? 0),
            'truncated_tables' => (int) ($streamed['truncated_tables'] ?? 0),
            'warning_count' => (int) ($streamed['truncated_tables'] ?? 0),
            'warning_details' => $this->sanitizeWarningDetails((array) ($streamed['warning_details'] ?? [])),
            'duration_ms' => (int) ($streamed['duration_ms'] ?? 0),
            'size' => is_file($path) ? (int) filesize($path) : 0,
            'row_export_mode' => $completeTableExport ? 'complete' : 'capped',
        ];
        $this->writeBackupMetadata($path, $result);

        return $syncRemote ? BackupStorageManager::fromWordPress()->handleDatabaseBackupCreated($result) : $result;
    }

    /**
     * @param array<int,string> $tables
     * @return array<string,mixed>
     */
    public function startProgressiveBackup(array $tables = [], int $maxRowsPerTable = 5000, bool $syncRemote = true, bool $completeTableExport = false): array
    {
        if (!$this->hasDatabase()) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => 'Database is not available.',
            ];
        }

        $this->storage->ensure();
        $this->ensureDirectory($this->backupDirectory());
        $this->ensureDirectory($this->backupJobDirectory());

        $available = $this->discoverTables();
        $selected = $this->selectedTables($tables, $available);
        if (empty($selected)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => 'No matching WordPress tables were selected.',
                'available_tables' => $available,
            ];
        }

        $backupId = $this->newBackupId();
        $maxRows = $completeTableExport ? 0 : max(1, min(50000, $maxRowsPerTable));
        $progressiveDataDirectory = $this->progressiveBackupDataDirectory($backupId);
        if ($progressiveDataDirectory === '') {
            return [
                'ok' => false,
                'status' => 'failed',
                'backup_id' => $backupId,
                'message' => 'Could not prepare progressive database backup storage.',
            ];
        }
        $this->ensureDirectory($progressiveDataDirectory);
        if (!is_dir($progressiveDataDirectory)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'backup_id' => $backupId,
                'message' => 'Could not prepare progressive database backup storage.',
            ];
        }
        $job = [
            'backup_id' => $backupId,
            'status' => 'running',
            'created_at' => gmdate('c'),
            'updated_at' => gmdate('c'),
            'started_at' => microtime(true),
            'tables_selected' => array_values($selected),
            'table_index' => 0,
            'row_offset' => 0,
            'row_cursor' => '',
            'rows_exported' => 0,
            'batches' => 0,
            'pages_processed' => 0,
            'adaptive_page_size' => self::PROGRESSIVE_PAGE_MIN_ROWS,
            'last_page_rows' => 0,
            'last_page_bytes' => 0,
            'last_page_elapsed_ms' => 0,
            'elapsed_ms' => 0,
            'rows_per_second' => 0.0,
            'estimated_remaining_seconds' => 0,
            'max_rows_per_table' => $maxRows,
            'complete_table_export' => $completeTableExport,
            'sync_remote' => $syncRemote,
            'payload' => [
                'backup_id' => $backupId,
                'created_at' => gmdate('c'),
                'site_url' => function_exists('site_url') ? site_url() : '',
                'prefix' => (string) $this->wpdb->prefix,
                'format' => 'shieldx-db-json-v1',
                'row_export_mode' => $completeTableExport ? 'complete' : 'capped',
                'max_rows_per_table' => $maxRows,
                'tables' => [],
            ],
        ];

        if (!$this->writeBackupJob($backupId, $job)) {
            $this->removeDirectory($progressiveDataDirectory);

            return [
                'ok' => false,
                'status' => 'failed',
                'backup_id' => $backupId,
                'message' => 'Could not write database backup job state.',
            ];
        }

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

    /**
     * @return array<string,mixed>
     */
    public function runProgressiveBackupBatch(string $backupId, int $rowBatch = 500, int $maxSeconds = 8): array
    {
        $job = $this->readBackupJob($backupId);
        if (!$job) {
            return [
                'ok' => false,
                'status' => 'failed',
                'backup_id' => $backupId,
                'message' => 'Database backup job was not found.',
            ];
        }

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

        $started = microtime(true);
        $rowBatch = max(25, min(self::PROGRESSIVE_PAGE_MAX_ROWS, $rowBatch));
        // Browser and admin requests leave time for signed checkpoint writes and
        // JSON responses. The caller may ask for less work, never more than the
        // portable eight-second work target.
        $maxSeconds = max(2, min(8, $maxSeconds));
        $tables = isset($job['tables_selected']) && is_array($job['tables_selected'])
            ? array_values(array_map('strval', $job['tables_selected']))
            : [];
        $tableIndex = max(0, (int) ($job['table_index'] ?? 0));
        $rowOffset = max(0, (int) ($job['row_offset'] ?? 0));
        $rowCursor = isset($job['row_cursor']) && is_scalar($job['row_cursor']) ? (string) $job['row_cursor'] : '';
        $completeTableExport = !empty($job['complete_table_export']);
        $maxRowsPerTable = $completeTableExport ? 0 : max(1, min(50000, (int) ($job['max_rows_per_table'] ?? 5000)));
        $payload = isset($job['payload']) && is_array($job['payload']) ? $job['payload'] : [];

        try {
            $this->migrateProgressivePayloadRows($backupId, $payload);
            $queriesProcessed = 0;
            while ($tableIndex < count($tables)) {
                if ((microtime(true) - $started) >= ($maxSeconds - self::PROGRESSIVE_WORK_RESERVE_SECONDS)) {
                    break;
                }

                $table = $tables[$tableIndex];
                if (!$this->validTableName($table)) {
                    $tableIndex++;
                    $rowOffset = 0;
                    $rowCursor = '';
                    continue;
                }

                if (!isset($payload['tables'][$tableIndex]) || !is_array($payload['tables'][$tableIndex])) {
                    $payload['tables'][$tableIndex] = [
                        'name' => $table,
                        'create_sql' => $this->createSql($table),
                        'rows_exported' => 0,
                        'truncated' => false,
                    ];
                }

                $pageSize = $this->progressivePageSize($job, $rowBatch);
                $remainingForTable = $completeTableExport ? $pageSize : max(0, $maxRowsPerTable - $rowOffset);
                $limit = min($pageSize, $remainingForTable);
                $pageOffset = $rowOffset;
                $cursorColumn = $this->tableNumericPrimaryKeyColumn($table);
                $pageStarted = microtime(true);
                $rows = $limit > 0
                    ? $this->tableRowsPage($table, $limit, $rowOffset, $cursorColumn, $rowCursor)
                    : [];
                $pageElapsed = max(0.0, microtime(true) - $pageStarted);
                $count = count($rows);
                $queriesProcessed++;
                $pageBytes = $this->progressiveRowsJsonBytes($rows);

                if ($count > 0) {
                    if (!$this->writeProgressiveBackupPage($backupId, $tableIndex, $pageOffset, $rows)) {
                        throw new \RuntimeException('Could not persist database backup row page.');
                    }
                    $payload['tables'][$tableIndex]['rows_exported'] = (int) $payload['tables'][$tableIndex]['rows_exported'] + $count;
                    $job['rows_exported'] = (int) ($job['rows_exported'] ?? 0) + $count;
                    $rowOffset += $count;
                    if ($cursorColumn !== '') {
                        $cursor = $this->progressiveCursorValue($rows, $cursorColumn);
                        if ($cursor === '') {
                            throw new \RuntimeException('A numeric primary-key cursor could not be checkpointed.');
                        }
                        $rowCursor = $cursor;
                    }
                }

                if ($count < $limit || (!$completeTableExport && $rowOffset >= $maxRowsPerTable) || $limit === 0) {
                    $payload['tables'][$tableIndex]['truncated'] = !$completeTableExport && $rowOffset >= $maxRowsPerTable;
                    $tableIndex++;
                    $rowOffset = 0;
                    $rowCursor = '';
                }

                $job['payload'] = $payload;
                $job['table_index'] = $tableIndex;
                $job['row_offset'] = $rowOffset;
                $job['row_cursor'] = $rowCursor;
                $job['pages_processed'] = (int) ($job['pages_processed'] ?? 0) + 1;
                $job['last_page_rows'] = $count;
                $job['last_page_bytes'] = $pageBytes;
                $job['last_page_elapsed_ms'] = (int) round($pageElapsed * 1000);
                $job['adaptive_page_size'] = $this->nextProgressivePageSize(
                    $pageSize,
                    $count,
                    $pageBytes,
                    $pageElapsed,
                    $this->memoryHeadroomBytes()
                );
                $job['elapsed_ms'] = $this->progressiveElapsedMilliseconds($job);
                $job['rows_per_second'] = $this->progressiveRowsPerSecond($job);
                $job['estimated_remaining_seconds'] = $this->progressiveEstimatedRemainingSeconds($job, count($tables), $tableIndex);
                $job['updated_at'] = gmdate('c');

                // Persist every completed page and its cursor before doing more
                // work. If the request is interrupted, the next request resumes
                // from this page boundary without duplicate or skipped rows.
                if (!$this->writeBackupJob((string) ($job['backup_id'] ?? $backupId), $job)) {
                    throw new \RuntimeException('Could not persist database backup progress.');
                }

                if ($tableIndex >= count($tables)
                    || $queriesProcessed >= 100
                    || (microtime(true) - $started) >= ($maxSeconds - self::PROGRESSIVE_WORK_RESERVE_SECONDS)) {
                    break;
                }
            }

            $job['payload'] = $payload;
            $job['table_index'] = $tableIndex;
            $job['row_offset'] = $rowOffset;
            $job['row_cursor'] = $rowCursor;
            $job['batches'] = (int) ($job['batches'] ?? 0) + 1;
            $job['elapsed_ms'] = $this->progressiveElapsedMilliseconds($job);
            $job['rows_per_second'] = $this->progressiveRowsPerSecond($job);
            $job['estimated_remaining_seconds'] = $this->progressiveEstimatedRemainingSeconds($job, count($tables), $tableIndex);
            $job['updated_at'] = gmdate('c');

            if ($tableIndex >= count($tables)) {
                return $this->completeProgressiveBackup($job);
            }

            if (!$this->writeBackupJob((string) ($job['backup_id'] ?? $backupId), $job)) {
                $job['status'] = 'failed';
                $job['message'] = 'Could not persist database backup progress.';

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

            return $this->publicProgressiveBackupState($job);
        } catch (\Throwable $error) {
            $job['status'] = 'failed';
            $job['message'] = 'Database backup failed during batch processing.';
            $job['updated_at'] = gmdate('c');
            if (!$this->writeBackupJob((string) ($job['backup_id'] ?? $backupId), $job)) {
                $job['message'] = 'Database backup failed during batch processing and could not persist failure state.';
            }

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

    /**
     * @return array<int,array<string,mixed>>
     */
    public function recent(int $limit = 8): array
    {
        $files = $this->localBackupFiles();
        if (!is_array($files)) {
            return BackupStorageManager::fromWordPress()->mergeDatabaseBackups([], $limit);
        }

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

            $backupId = (string) ($backup['backup_id'] ?? '');
            if ($backupId === '') {
                continue;
            }

            $backup['path'] = $file;
            if (!isset($entries[$backupId]) || substr($file, -4) === '.zip') {
                $entries[$backupId] = $backup;
            }
        }

        $entries = array_values($entries);
        usort($entries, static function (array $left, array $right): int {
            return strcmp((string) ($right['backup_id'] ?? ''), (string) ($left['backup_id'] ?? ''));
        });

        $items = [];
        $visibleLimit = max(1, min(50, $limit));
        foreach ($entries as $entry) {
            if (count($items) >= $visibleLimit) {
                break;
            }

            $file = (string) ($entry['path'] ?? '');
            $stat = $this->backupFileStat($file);
            if (!is_array($stat)) {
                continue;
            }

            $backupId = (string) ($entry['backup_id'] ?? '');
            $metadata = $this->readBackupMetadata($file);
            $metadataComplete = false;
            $tableCount = null;
            $rowsExported = null;
            $warningCount = 0;
            $warningDetails = [];
            if (is_array($metadata) && $this->backupMetadataIsComplete($metadata, $backupId)) {
                $metadataComplete = true;
                $tableCount = max(0, (int) ($metadata['table_count'] ?? 0));
                $rowsExported = max(0, (int) ($metadata['rows_exported'] ?? 0));
                $warningCount = max(0, (int) ($metadata['warning_count'] ?? 0));
                $warningDetails = $this->sanitizeWarningDetails((array) ($metadata['warning_details'] ?? []));
            }

            $items[] = [
                'backup_id' => $backupId,
                'created_at' => (string) ($entry['created_at'] ?? ''),
                'table_count' => $tableCount,
                'rows_exported' => $rowsExported,
                'metadata_complete' => $metadataComplete,
                'path' => $file,
                'size' => max(0, (int) ($stat['size'] ?? 0)),
                'warning_count' => $warningCount,
                'warning_details' => $warningDetails,
            ];
        }

        $merged = BackupStorageManager::fromWordPress()->mergeDatabaseBackups($items, 50);

        return $this->sortRecentDatabaseBackups($merged, $limit);
    }

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

        $excludeBackupId = trim($excludeBackupId);

        $directory = $this->backupDirectory();
        if (!is_dir($directory)) {
            return [
                'ok' => true,
                'deleted_backups' => 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' => 'Database backup storage could not be inspected.',
            ];
        }

        $files = $this->localBackupFiles();
        $metadataFiles = glob($directory . '/db-*.meta.json');
        if (!is_array($files) || !is_array($metadataFiles)) {
            return [
                'ok' => false,
                'message' => 'Database backup storage could not be listed.',
            ];
        }

        $deletedBackups = 0;
        $deletedFiles = 0;
        $deletedBytes = 0;
        $failedFiles = 0;
        $processedMetadata = [];

        $processedBackups = [];
        foreach ($files as $file) {
            $backup = $this->backupInfoFromFilename($file);
            if (!is_array($backup)) {
                continue;
            }

            $backupId = (string) ($backup['backup_id'] ?? '');
            if (isset($processedBackups[$backupId])) {
                continue;
            }
            if ($excludeBackupId !== '' && $backupId === $excludeBackupId) {
                continue;
            }

            $created = strtotime((string) ($backup['created_at'] ?? ''));
            if (!is_int($created) || $created >= $cutoff) {
                continue;
            }

            $stat = $this->backupFileStat($file);
            if (!is_array($stat)) {
                continue;
            }

            $size = max(0, (int) ($stat['size'] ?? 0));
            if (!$this->deleteFileInsideRoot($file, $root)) {
                $failedFiles++;
                continue;
            }

            $deletedBackups++;
            $deletedFiles++;
            $deletedBytes += $size;
            $processedBackups[$backupId] = true;

            $metadataPath = $this->backupMetadataPath($file);
            if ($metadataPath !== '' && is_file($metadataPath)) {
                $processedMetadata[$metadataPath] = true;
                $metadataStat = $this->backupFileStat($metadataPath);
                if ($this->deleteFileInsideRoot($metadataPath, $root)) {
                    $deletedFiles++;
                    $deletedBytes += is_array($metadataStat) ? max(0, (int) ($metadataStat['size'] ?? 0)) : 0;
                } else {
                    $failedFiles++;
                }
            }
        }

        foreach ($metadataFiles as $metadataPath) {
            if (isset($processedMetadata[$metadataPath])) {
                continue;
            }

            $backupPath = $this->backupPathFromMetadataPath($metadataPath);
            $backup = $backupPath !== '' ? $this->backupInfoFromFilename($backupPath) : null;
            $backupId = is_array($backup) ? (string) ($backup['backup_id'] ?? '') : '';
            if ($excludeBackupId !== '' && $backupId === $excludeBackupId) {
                continue;
            }

            $backupExists = $backupPath !== '' && is_file($backupPath);
            $created = is_array($backup) ? strtotime((string) ($backup['created_at'] ?? '')) : false;
            if ($backupExists && (!is_int($created) || $created >= $cutoff)) {
                continue;
            }

            $metadataStat = $this->backupFileStat($metadataPath);
            if (!is_array($metadataStat)) {
                continue;
            }

            if ($this->deleteFileInsideRoot($metadataPath, $root)) {
                $deletedFiles++;
                $deletedBytes += max(0, (int) ($metadataStat['size'] ?? 0));
            } else {
                $failedFiles++;
            }
        }

        return [
            'ok' => $failedFiles === 0,
            'deleted_backups' => $deletedBackups,
            'deleted_files' => $deletedFiles,
            'deleted_bytes' => $deletedBytes,
            'failed_files' => $failedFiles,
            'before_date' => $beforeDate,
            'message' => $failedFiles > 0 ? 'Some database backup files could not be deleted.' : 'Database backup cleanup completed.',
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    public function backupById(string $backupId, bool $allowRemoteDownload = true): ?array
    {
        $path = $this->existingBackupPath($backupId);
        if ($path === '') {
            if (!$allowRemoteDownload || !BackupStorageManager::fromWordPress()->ensureDatabaseBackupLocal($backupId)) {
                return null;
            }
            $path = $this->existingBackupPath($backupId);
            if ($path === '') {
                return null;
            }
        }

        $payload = $this->readDatabaseBackupPayload($path, $backupId);
        $contents = is_array($payload) ? (string) ($payload['raw'] ?? '') : '';
        $decoded = isset($payload['decoded']) && is_array($payload['decoded']) ? $payload['decoded'] : null;

        if (!is_array($decoded)) {
            return null;
        }

        if (!$this->verifyPayloadSignature($decoded, $contents)) {
            return null;
        }

        $decoded['_path'] = $path;

        return $decoded;
    }

    /**
     * Return the bounded sidecar metadata needed to start an isolated restore.
     *
     * This deliberately does not decompress or decode the database archive.
     * Large complete backups can approach the configured archive ceiling and
     * loading them during a normal WordPress admin request can exhaust memory
     * before the standalone Recovery controller is created.
     *
     * @return array<string,mixed>|null
     */
    public function restorePackageMetadata(string $backupId, bool $allowRemoteDownload = true, bool $allowLegacyCompleteSidecar = false): ?array
    {
        $path = $this->existingBackupPath($backupId);
        if ($path === '') {
            if (!$allowRemoteDownload || !BackupStorageManager::fromWordPress()->ensureDatabaseBackupLocal($backupId)) {
                return null;
            }
            $path = $this->existingBackupPath($backupId);
            if ($path === '') {
                return null;
            }
        }

        $metadata = $this->readBackupMetadata($path);
        if (!is_array($metadata) || !$this->backupMetadataIsComplete($metadata, strtolower($backupId))) {
            return null;
        }

        $rowExportMode = strtolower((string) ($metadata['row_export_mode'] ?? ''));
        if ($rowExportMode === '' && $allowLegacyCompleteSidecar && (int) ($metadata['warning_count'] ?? 0) === 0) {
            // RestorePointManager only enables this compatibility path after a
            // signed restore manifest identifies the backup created by its
            // complete-table export flow. Ordinary database backups must carry
            // an explicit mode in their current sidecar metadata.
            $rowExportMode = 'complete';
        }
        if ($rowExportMode !== 'complete' || (int) ($metadata['table_count'] ?? 0) < 1) {
            return null;
        }

        $hash = hash_file('sha256', $path);
        if (!is_string($hash) || preg_match('/^[a-f0-9]{64}$/', strtolower($hash)) !== 1) {
            return null;
        }

        return [
            'backup_id' => strtolower($backupId),
            'backup_path' => $path,
            'backup_sha256' => strtolower($hash),
            'backup_size' => max(0, (int) filesize($path)),
            'row_export_mode' => $rowExportMode,
            'table_count' => max(0, (int) ($metadata['table_count'] ?? 0)),
            'rows_exported' => max(0, (int) ($metadata['rows_exported'] ?? 0)),
            'warning_count' => max(0, (int) ($metadata['warning_count'] ?? 0)),
        ];
    }

    /**
     * @param array<int,string>|null $selectedTables Null restores every table in the backup.
     * @return array<string,mixed>
     */
    public function restore(string $backupId, ?array $selectedTables = null, bool $allowTruncated = false, bool $allowRemoteDownload = true): array
    {
        if (!$this->hasDatabase()) {
            return [
                'ok' => false,
                'message' => 'Database is not available.',
            ];
        }

        $backup = $this->backupById($backupId, $allowRemoteDownload);
        if (!$backup) {
            return [
                'ok' => false,
                'message' => 'Database backup was not found.',
            ];
        }

        $tables = isset($backup['tables']) && is_array($backup['tables']) ? $backup['tables'] : [];
        if (empty($tables)) {
            return [
                'ok' => false,
                'message' => 'Database backup does not contain tables.',
            ];
        }

        $selectedMap = null;
        if (is_array($selectedTables)) {
            $cleanTables = [];
            foreach ($selectedTables as $tableName) {
                $tableName = trim((string) $tableName);
                if ($this->validTableName($tableName)) {
                    $cleanTables[$tableName] = true;
                }
            }

            $selectedMap = $cleanTables;
        }

        $tablesToRestore = [];
        foreach ($tables as $table) {
            if (!is_array($table)) {
                continue;
            }

            $name = (string) ($table['name'] ?? '');
            if (!$this->validTableName($name) || strpos($name, (string) $this->wpdb->prefix) !== 0) {
                continue;
            }

            if (is_array($selectedMap) && !isset($selectedMap[$name])) {
                continue;
            }

            $tablesToRestore[] = $table;
        }

        if (empty($tablesToRestore)) {
            return [
                'ok' => false,
                'message' => 'No matching WordPress tables were restored.',
                'backup_id' => $backupId,
            ];
        }

        $truncatedTables = $this->truncatedTableNames($tablesToRestore);
        if (!$allowTruncated && !empty($truncatedTables)) {
            return [
                'ok' => false,
                'message' => 'Database backup restore was not started because the backup is truncated. Confirm truncated restore explicitly before proceeding.',
                'backup_id' => $backupId,
                'truncated_tables' => $truncatedTables,
            ];
        }

        try {
            $this->createMissingRestoreTables($tablesToRestore);
        } catch (\Throwable $error) {
            return [
                'ok' => false,
                'message' => 'Database backup restore failed before data changes because a table schema could not be prepared.',
                'backup_id' => $backupId,
            ];
        }

        $transactionStarted = $this->beginTransaction();
        if (!$transactionStarted) {
            return [
                'ok' => false,
                'message' => 'Database backup restore was not started because a database transaction could not be opened.',
                'backup_id' => $backupId,
            ];
        }

        $restored = 0;
        $rowsRestored = 0;
        $foreignKeyChecksDisabled = false;

        try {
            $foreignKeyChecksDisabled = $this->setForeignKeyChecks(false);
            foreach ($tablesToRestore as $table) {
                $name = (string) ($table['name'] ?? '');

                $deleteSql = $this->prepareIdentifierSql('DELETE FROM %i', $name);
                if ($deleteSql === '' || $this->wpdb->query($deleteSql) === false) {
                    throw new \RuntimeException('Could not clear table before restore.');
                }

                $rows = isset($table['rows']) && is_array($table['rows']) ? $table['rows'] : [];
                $rowsRestored += $this->restoreRows($name, $rows);

                $restored++;
            }

            if ($foreignKeyChecksDisabled) {
                $this->setForeignKeyChecks(true);
                $foreignKeyChecksDisabled = false;
            }

            $this->commitTransaction($transactionStarted);
        } catch (\Throwable $error) {
            if ($foreignKeyChecksDisabled) {
                $this->setForeignKeyChecks(true);
            }
            $this->rollbackTransaction($transactionStarted);

            return [
                'ok' => false,
                'message' => 'Database backup restore failed. Data changes were rolled back when supported; storage engines without transactional rollback may require manual review.',
                'backup_id' => $backupId,
            ];
        }

        return [
            'ok' => true,
            'message' => 'Database backup restored.',
            'backup_id' => $backupId,
            'tables' => $restored,
            'rows_restored' => $rowsRestored,
            'tables_skipped' => is_array($selectedMap) ? max(0, count($tables) - $restored) : 0,
        ];
    }

    private function beginTransaction(): bool
    {
        if (!method_exists($this->wpdb, 'query')) {
            return false;
        }

        return $this->wpdb->query('START TRANSACTION') !== false;
    }

    private function commitTransaction(bool $started): void
    {
        if ($started && method_exists($this->wpdb, 'query')) {
            $this->wpdb->query('COMMIT');
        }
    }

    private function rollbackTransaction(bool $started): void
    {
        if ($started && method_exists($this->wpdb, 'query')) {
            $this->wpdb->query('ROLLBACK');
        }
    }

    /**
     * @param array<int,array<string,mixed>> $tables
     */
    private function createMissingRestoreTables(array $tables): void
    {
        foreach ($tables as $table) {
            $name = (string) ($table['name'] ?? '');
            if (!$this->validTableName($name)) {
                continue;
            }

            $createSql = (string) ($table['create_sql'] ?? '');
            if ($createSql === '') {
                continue;
            }

            $createSql = $this->safeCreateTableStatement($createSql, $name);
            if ($createSql === '') {
                throw new \RuntimeException('Backup create statement failed validation.');
            }

            if ($this->wpdb->query($this->createTableIfMissingStatement($createSql)) === false) {
                throw new \RuntimeException('Could not prepare table schema before restore.');
            }
        }
    }

    private function createTableIfMissingStatement(string $sql): string
    {
        return preg_replace('/^\s*CREATE\s+TABLE\s+/i', 'CREATE TABLE IF NOT EXISTS ', $sql, 1) ?? $sql;
    }

    private function setForeignKeyChecks(bool $enabled): bool
    {
        if (!method_exists($this->wpdb, 'query')) {
            return false;
        }

        return $this->wpdb->query($enabled ? 'SET FOREIGN_KEY_CHECKS=1' : 'SET FOREIGN_KEY_CHECKS=0') !== false;
    }

    /**
     * @param array<int,array<string,mixed>> $tables
     * @return array<int,string>
     */
    private function truncatedTableNames(array $tables): array
    {
        $truncated = [];
        foreach ($tables as $table) {
            if (is_array($table) && !empty($table['truncated'])) {
                $name = (string) ($table['name'] ?? '');
                if ($this->validTableName($name)) {
                    $truncated[] = $name;
                }
            }
        }

        return array_values(array_unique($truncated));
    }

    /**
     * @param array<int,mixed> $tables
     * @return array<int,string>
     */
    private function databaseBackupWarningsFromTables(array $tables, int $maxRows): array
    {
        $warnings = [];
        foreach ($tables as $table) {
            if (!is_array($table) || empty($table['truncated'])) {
                continue;
            }

            $name = (string) ($table['name'] ?? '');
            if (!$this->validTableName($name)) {
                continue;
            }

            $rows = max(0, (int) ($table['rows_exported'] ?? 0));
            $limit = $maxRows > 0 ? $maxRows : $rows;
            $warnings[] = sprintf('Table %s reached the per-table export cap of %d rows and was truncated.', $name, $limit);
        }

        return $this->sanitizeWarningDetails($warnings);
    }

    /**
     * @param array<int,mixed> $warnings
     * @return array<int,string>
     */
    private function sanitizeWarningDetails(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));
    }

    public function backupDirectory(): string
    {
        return $this->storage->baseDir() . '/db-backups';
    }

    public function backupJobDirectory(): string
    {
        return $this->backupDirectory() . '/jobs';
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function completeProgressiveBackup(array $job): array
    {
        $payload = isset($job['payload']) && is_array($job['payload']) ? $job['payload'] : [];
        $backupId = (string) ($payload['backup_id'] ?? $job['backup_id'] ?? '');
        $path = $this->backupPath($backupId);
        $temporaryDirectory = $this->temporaryBackupDirectory($backupId);
        if ($path === '' || $temporaryDirectory === '') {
            $job['status'] = 'failed';
            $job['message'] = 'Could not prepare database backup storage.';
            $this->writeBackupJob($backupId, $job);

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

        $this->ensureDirectory($temporaryDirectory);
        $jsonPath = $temporaryDirectory . '/' . $backupId . '.json';
        $streamed = $this->writeProgressiveBackupStream($jsonPath, $job);
        if (empty($streamed['ok'])) {
            $this->removeDirectory($temporaryDirectory);
            $job['status'] = 'failed';
            $job['message'] = (string) ($streamed['message'] ?? 'Could not write database backup file.');
            $this->writeBackupJob($backupId, $job);

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

        $zipped = $this->createDatabaseBackupZip($jsonPath, $path);
        $this->removeDirectory($temporaryDirectory);
        if (empty($zipped['ok'])) {
            $this->removeTemporaryFile($path);
            $job['status'] = 'failed';
            $job['message'] = (string) ($zipped['message'] ?? 'Could not compress database backup file.');
            $this->writeBackupJob($backupId, $job);

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

        $job['status'] = 'completed';
        $job['message'] = 'Database backup created.';
        $job['path'] = $path;
        $job['size'] = is_file($path) ? (int) filesize($path) : 0;
        $job['warning_count'] = (int) ($streamed['warning_count'] ?? 0);
        $job['warning_details'] = $this->sanitizeWarningDetails((array) ($streamed['warning_details'] ?? []));
        $job['updated_at'] = gmdate('c');
        $this->removeBackupJob($backupId);

        $state = $this->publicProgressiveBackupState($job);
        $this->writeBackupMetadata($path, $state);

        return !empty($job['sync_remote']) ? BackupStorageManager::fromWordPress()->handleDatabaseBackupCreated($state) : $state;
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function writeProgressiveBackupStream(string $path, array $job): array
    {
        $payload = isset($job['payload']) && is_array($job['payload']) ? $job['payload'] : [];
        $tables = isset($payload['tables']) && is_array($payload['tables']) ? $payload['tables'] : [];
        $backupId = (string) ($payload['backup_id'] ?? $job['backup_id'] ?? '');
        $warningDetails = $this->databaseBackupWarningsFromTables($tables, (int) ($job['max_rows_per_table'] ?? 0));
        $durationMs = (int) round((microtime(true) - (float) ($job['started_at'] ?? microtime(true))) * 1000);
        $directory = dirname($path);
        $this->ensureDirectory($directory);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Same-directory temp files make the private streamed backup write atomic.
        $temporary = tempnam($directory, 'shieldx-db-');
        if (!is_string($temporary) || $temporary === '') {
            return [
                'ok' => false,
                'message' => 'Could not prepare database backup temp file.',
            ];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming page files avoids loading a full database backup into memory.
        $handle = fopen($temporary, 'wb');
        if (!$handle) {
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => 'Could not open database backup temp file.',
            ];
        }

        $hash = hash_init('sha256', HASH_HMAC, $this->signatureKey());
        $payloadBytes = 0;

        try {
            $writePayload = function (string $chunk) use ($handle, $hash, &$payloadBytes): void {
                $payloadBytes += strlen($chunk);
                if ($payloadBytes > self::MAX_BACKUP_JSON_BYTES - 1024) {
                    throw new \RuntimeException('Database backup exceeded the maximum supported uncompressed size.');
                }
                $this->writeStreamChunk($handle, $chunk);
                hash_update($hash, $chunk);
            };
            $json = static function ($value): string {
                $encoded = wp_json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
                if (!is_string($encoded)) {
                    throw new \RuntimeException('Could not encode database backup chunk.');
                }

                return $encoded;
            };

            $writePayload('{');
            $writePayload('"backup_id":' . $json($backupId));
            $writePayload(',"created_at":' . $json((string) ($payload['created_at'] ?? $job['created_at'] ?? '')));
            $writePayload(',"site_url":' . $json((string) ($payload['site_url'] ?? '')));
            $writePayload(',"prefix":' . $json((string) ($payload['prefix'] ?? '')));
            $writePayload(',"format":"shieldx-db-json-v1"');
            $writePayload(',"row_export_mode":' . $json((string) ($payload['row_export_mode'] ?? 'complete')));
            $writePayload(',"max_rows_per_table":' . max(0, (int) ($payload['max_rows_per_table'] ?? 0)));
            $writePayload(',"tables":[');

            $firstTable = true;
            foreach ($tables as $tableIndex => $table) {
                if (!is_array($table)) {
                    continue;
                }
                if (!$firstTable) {
                    $writePayload(',');
                }
                $firstTable = false;
                $writePayload('{');
                $writePayload('"name":' . $json((string) ($table['name'] ?? '')));
                $writePayload(',"create_sql":' . $json((string) ($table['create_sql'] ?? '')));
                $writePayload(',"rows":[');

                $firstRow = true;
                foreach ($this->progressiveBackupPagePaths($backupId, (int) $tableIndex) as $pagePath) {
                    $raw = file_get_contents($pagePath);
                    $rows = is_string($raw) ? \ShieldX\Support\Json::decodeArrayRaw($raw, self::MAX_BACKUP_JSON_BYTES) : null;
                    if (!is_array($rows)) {
                        throw new \RuntimeException('A progressive database backup row page could not be read.');
                    }
                    foreach ($rows as $row) {
                        if (!is_array($row)) {
                            continue;
                        }
                        if (!$firstRow) {
                            $writePayload(',');
                        }
                        $firstRow = false;
                        $writePayload($json($row));
                    }
                }

                $writePayload(']');
                $writePayload(',"rows_exported":' . max(0, (int) ($table['rows_exported'] ?? 0)));
                $writePayload(',"truncated":' . (!empty($table['truncated']) ? 'true' : 'false'));
                $writePayload('}');
            }

            $truncatedTables = count(array_filter($tables, static function ($table): bool {
                return is_array($table) && !empty($table['truncated']);
            }));
            $writePayload(']');
            $writePayload(',"table_count":' . count($tables));
            $writePayload(',"rows_exported":' . max(0, (int) ($job['rows_exported'] ?? 0)));
            $writePayload(',"truncated_tables":' . $truncatedTables);
            $writePayload(',"warning_count":' . count($warningDetails));
            $writePayload(',"warning_details":' . $json($warningDetails));
            $writePayload(',"duration_ms":' . $durationMs);
            hash_update($hash, '}');

            $signature = hash_final($hash);
            $this->writeStreamChunk($handle, ',"signature":{"alg":"hmac-sha256-stream-v1","value":' . $json($signature) . '}}' . "\n");

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Defense-in-depth for the private database backup file.
            chmod($temporary, 0600);
        } catch (\Throwable $error) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Native stream is required for chunked database backup JSON.
            fclose($handle);
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => $error->getMessage(),
            ];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Native stream is required for chunked database backup JSON.
        fclose($handle);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic same-directory replacement prevents partial backup reads.
        if (!rename($temporary, $path)) {
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => 'Could not move database backup file into place.',
            ];
        }

        return [
            'ok' => true,
            'warning_count' => count($warningDetails),
            'warning_details' => $warningDetails,
            'duration_ms' => $durationMs,
        ];
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function progressivePageSize(array $job, int $requested): int
    {
        $stored = (int) ($job['adaptive_page_size'] ?? 0);
        if ($stored < 25) {
            // Older jobs do not carry an adaptive value. Keep explicitly small
            // caller values for tests and CLI, while browser jobs start at 250.
            $stored = min(self::PROGRESSIVE_PAGE_MIN_ROWS, max(25, $requested));
        }

        return max(25, min(self::PROGRESSIVE_PAGE_MAX_ROWS, $stored));
    }

    private function nextProgressivePageSize(int $current, int $rows, int $bytes, float $elapsedSeconds, int $memoryHeadroom): int
    {
        $current = max(25, min(self::PROGRESSIVE_PAGE_MAX_ROWS, $current));
        if ($rows < 1) {
            return $current;
        }

        if ($bytes >= self::PROGRESSIVE_PAGE_MAX_BYTES
            || $elapsedSeconds >= self::PROGRESSIVE_PAGE_SLOW_SECONDS
            || ($memoryHeadroom > 0 && $memoryHeadroom < (16 * 1024 * 1024))) {
            return max(25, (int) floor($current / 2));
        }

        if ($rows >= $current
            && $bytes <= self::PROGRESSIVE_PAGE_TARGET_BYTES
            && $elapsedSeconds <= self::PROGRESSIVE_PAGE_FAST_SECONDS
            && ($memoryHeadroom === 0 || $memoryHeadroom >= (32 * 1024 * 1024))) {
            return min(self::PROGRESSIVE_PAGE_MAX_ROWS, $current + self::PROGRESSIVE_PAGE_MIN_ROWS);
        }

        return $current;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function progressiveRowsJsonBytes(array $rows): int
    {
        $json = wp_json_encode(array_values($rows), JSON_UNESCAPED_SLASHES);

        return is_string($json) ? strlen($json) : 0;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function progressiveCursorValue(array $rows, string $column): string
    {
        $last = end($rows);
        if (!is_array($last) || !array_key_exists($column, $last)) {
            return '';
        }

        $value = (string) $last[$column];

        return preg_match('/^-?\d+$/', $value) === 1 ? $value : '';
    }

    private function progressiveElapsedMilliseconds(array $job): int
    {
        $startedAt = isset($job['started_at']) && is_numeric($job['started_at']) ? (float) $job['started_at'] : 0.0;

        return $startedAt > 0.0 ? max(0, (int) round((microtime(true) - $startedAt) * 1000)) : 0;
    }

    private function progressiveRowsPerSecond(array $job): float
    {
        $elapsedMs = max(0, (int) ($job['elapsed_ms'] ?? 0));
        if ($elapsedMs < 1) {
            return 0.0;
        }

        return round(max(0, (int) ($job['rows_exported'] ?? 0)) / ($elapsedMs / 1000), 2);
    }

    private function progressiveEstimatedRemainingSeconds(array $job, int $tableCount, int $tableIndex): int
    {
        $completed = max(0, min($tableCount, $tableIndex));
        if ($completed < 1 || $tableCount <= $completed) {
            return 0;
        }

        $elapsedMs = max(0, (int) ($job['elapsed_ms'] ?? 0));
        if ($elapsedMs < 1) {
            return 0;
        }

        return max(1, (int) ceil(($elapsedMs / 1000 / $completed) * ($tableCount - $completed)));
    }

    private function memoryHeadroomBytes(): int
    {
        $limit = $this->phpMemoryLimitBytes();
        if ($limit < 1) {
            return 0;
        }

        return max(0, $limit - memory_get_usage(true));
    }

    private function phpMemoryLimitBytes(): int
    {
        $value = trim((string) ini_get('memory_limit'));
        if ($value === '' || $value === '-1') {
            return 0;
        }

        if (preg_match('/^(\d+)\s*([KMG])?$/i', $value, $matches) !== 1) {
            return 0;
        }

        $bytes = (int) $matches[1];
        $unit = strtoupper((string) ($matches[2] ?? ''));
        if ($unit === 'G') {
            $bytes *= 1024;
        }
        if ($unit === 'G' || $unit === 'M') {
            $bytes *= 1024;
        }
        if ($unit === 'G' || $unit === 'M' || $unit === 'K') {
            $bytes *= 1024;
        }

        return $bytes;
    }

    private function publicProgressiveBackupState(array $job): array
    {
        $tables = isset($job['tables_selected']) && is_array($job['tables_selected']) ? $job['tables_selected'] : [];
        $total = max(1, count($tables));
        $tableIndex = max(0, (int) ($job['table_index'] ?? 0));
        $offset = max(0, (int) ($job['row_offset'] ?? 0));
        $completeTableExport = !empty($job['complete_table_export']);
        $maxRowsPerTable = max(1, (int) ($job['max_rows_per_table'] ?? 5000));
        $status = (string) ($job['status'] ?? 'running');
        $percent = $status === 'completed'
            ? 100
            : min(99, (int) floor((($tableIndex + ($completeTableExport ? 0 : min(1, $offset / $maxRowsPerTable))) / $total) * 100));
        $currentTable = isset($tables[min($tableIndex, max(0, count($tables) - 1))]) ? (string) $tables[min($tableIndex, max(0, count($tables) - 1))] : '';
        $elapsedMilliseconds = max(0, (int) ($job['elapsed_ms'] ?? 0));
        $elapsedSeconds = (int) floor($elapsedMilliseconds / 1000);
        $rowsPerSecond = max(0.0, (float) ($job['rows_per_second'] ?? 0.0));
        $adaptivePageSize = max(25, min(self::PROGRESSIVE_PAGE_MAX_ROWS, (int) ($job['adaptive_page_size'] ?? self::PROGRESSIVE_PAGE_MIN_ROWS)));
        $remainingSeconds = max(0, (int) ($job['estimated_remaining_seconds'] ?? 0));
        $tableProgress = sprintf(
            /* translators: 1: current table number, 2: total tables, 3: exported rows. */
            __('Table %1$d / %2$d, %3$d rows', 'xerotact-shield-x-security'),
            max(0, min($tableIndex + 1, count($tables))),
            count($tables),
            (int) ($job['rows_exported'] ?? 0)
        );
        $throughput = $rowsPerSecond > 0
            ? sprintf(
                /* translators: 1: exported rows per second, 2: current adaptive page size. */
                __('%1$.0f rows/s, page %2$d', 'xerotact-shield-x-security'),
                $rowsPerSecond,
                $adaptivePageSize
            )
            : sprintf(
                /* translators: %d: current adaptive page size. */
                __('Page %d, measuring speed', 'xerotact-shield-x-security'),
                $adaptivePageSize
            );
        $scope = $currentTable !== ''
            /* translators: %s: database table currently being exported. */
            ? sprintf(__('Database backup: exporting %s.', 'xerotact-shield-x-security'), $currentTable)
            : __('Database backup is finalizing.', 'xerotact-shield-x-security');
        if ($status === 'running') {
            $scope .= ' ' . ($remainingSeconds > 0
                /* translators: %s: estimated remaining duration. */
                ? sprintf(__('Estimated %s remaining.', 'xerotact-shield-x-security'), $this->progressiveDurationLabel($remainingSeconds))
                : __('Estimating remaining time.', 'xerotact-shield-x-security'));
        }

        return [
            'ok' => $status !== 'failed',
            'type' => 'database_backup',
            'status' => $status,
            'backup_id' => (string) ($job['backup_id'] ?? ''),
            'message' => (string) ($job['message'] ?? ''),
            'path' => (string) ($job['path'] ?? ''),
            'size' => (int) ($job['size'] ?? 0),
            'warning_count' => max(0, (int) ($job['warning_count'] ?? 0)),
            'warning_details' => $this->sanitizeWarningDetails((array) ($job['warning_details'] ?? [])),
            'tables' => max(0, min($tableIndex, count($tables))),
            'table_count' => count($tables),
            'rows_exported' => (int) ($job['rows_exported'] ?? 0),
            'row_export_mode' => $completeTableExport ? 'complete' : 'capped',
            'percent' => max(0, min(100, $percent)),
            'elapsed_ms' => $elapsedMilliseconds,
            'rows_per_second' => $rowsPerSecond,
            'adaptive_page_size' => $adaptivePageSize,
            'last_page_bytes' => max(0, (int) ($job['last_page_bytes'] ?? 0)),
            'last_page_elapsed_ms' => max(0, (int) ($job['last_page_elapsed_ms'] ?? 0)),
            'estimated_remaining_seconds' => $remainingSeconds,
            'percent_label' => __('Elapsed', 'xerotact-shield-x-security'),
            'percent_value_label' => $this->progressiveDurationLabel($elapsedSeconds),
            'files_processed' => max(0, min($tableIndex, count($tables))),
            'files_total' => count($tables),
            'files_metric_label' => __('Table progress', 'xerotact-shield-x-security'),
            'files_label' => $tableProgress,
            'batches' => max(0, (int) ($job['batches'] ?? 0)),
            'batches_metric_label' => __('Throughput', 'xerotact-shield-x-security'),
            'batches_label' => $throughput,
            'scope_label' => $scope,
        ];
    }

    private function progressiveDurationLabel(int $seconds): string
    {
        $seconds = max(0, $seconds);
        $minutes = (int) floor($seconds / 60);
        $remainder = $seconds % 60;
        if ($minutes > 0) {
            /* translators: 1: elapsed minutes, 2: elapsed seconds. */
            return sprintf(__('%1$dm %2$ds', 'xerotact-shield-x-security'), $minutes, $remainder);
        }

        /* translators: %d: elapsed seconds. */
        return sprintf(__('%ds', 'xerotact-shield-x-security'), $remainder);
    }

    /**
     * Moves row data created by versions before bounded page storage out of the
     * durable job document so an in-progress backup can resume after updating.
     *
     * @param array<string,mixed> $payload
     */
    private function migrateProgressivePayloadRows(string $backupId, array &$payload): void
    {
        if (!isset($payload['tables']) || !is_array($payload['tables'])) {
            return;
        }

        foreach ($payload['tables'] as $tableIndex => &$table) {
            if (!is_array($table)) {
                continue;
            }
            $rows = isset($table['rows']) && is_array($table['rows']) ? array_values(array_filter($table['rows'], 'is_array')) : [];
            if (!empty($rows) && !$this->writeProgressiveBackupPage($backupId, (int) $tableIndex, 0, $rows)) {
                throw new \RuntimeException('Could not migrate database backup progress.');
            }
            unset($table['rows']);
        }
        unset($table);
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function writeProgressiveBackupPage(string $backupId, int $tableIndex, int $rowOffset, array $rows): bool
    {
        $path = $this->progressiveBackupPagePath($backupId, $tableIndex, $rowOffset);
        if ($path === '') {
            return false;
        }

        $json = wp_json_encode(array_values($rows), JSON_UNESCAPED_SLASHES);

        return is_string($json) && $this->writeBackupJson($path, $json . "\n");
    }

    /**
     * @return array<int,string>
     */
    private function progressiveBackupPagePaths(string $backupId, int $tableIndex): array
    {
        $directory = $this->progressiveBackupDataDirectory($backupId);
        if ($directory === '' || $tableIndex < 0) {
            return [];
        }

        $paths = glob($directory . '/table-' . sprintf('%06d', $tableIndex) . '-page-*.json');
        if (!is_array($paths)) {
            return [];
        }

        sort($paths, SORT_STRING);

        return array_values($paths);
    }

    private function progressiveBackupPagePath(string $backupId, int $tableIndex, int $rowOffset): string
    {
        $directory = $this->progressiveBackupDataDirectory($backupId);
        if ($directory === '' || $tableIndex < 0 || $rowOffset < 0) {
            return '';
        }

        $this->ensureDirectory($directory);
        if (!is_dir($directory)) {
            return '';
        }

        return $directory . '/table-' . sprintf('%06d', $tableIndex) . '-page-' . sprintf('%012d', $rowOffset) . '.json';
    }

    private function progressiveBackupDataDirectory(string $backupId): string
    {
        $backupId = trim(strtolower($backupId));
        if (!preg_match('/^db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $backupId)) {
            return '';
        }

        return $this->backupJobDirectory() . '/' . $backupId . '-data';
    }

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

        $durable = $this->durableJobs->read('database_backup', $backupId);

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

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

        $json = wp_json_encode($job, JSON_UNESCAPED_SLASHES);
        if (is_string($json) && $this->writeBackupJson($path, $json . "\n")) {
            $this->durableJobs->delete('database_backup', $backupId);

            return true;
        }

        return $this->durableJobs->save('database_backup', $backupId, $job, 86400);
    }

    private function removeBackupJob(string $backupId): void
    {
        $this->durableJobs->delete('database_backup', $backupId);
        $progressiveDataDirectory = $this->progressiveBackupDataDirectory($backupId);
        if ($progressiveDataDirectory !== '') {
            $this->removeDirectory($progressiveDataDirectory);
        }
        $path = $this->backupJobPath($backupId);
        if ($path !== '' && is_file($path)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes private progressive DB-backup job state after successful completion.
            unlink($path);
        }
    }

    private function backupJobPath(string $backupId): string
    {
        $backupId = trim(strtolower($backupId));
        if (!preg_match('/^db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $backupId)) {
            return '';
        }

        return $this->backupJobDirectory() . '/' . $backupId . '.json';
    }

    /**
     * @param array<int,string> $tables
     * @return array<string,mixed>
     */
    private function writeDatabaseBackupStream(string $path, string $backupId, string $createdAt, array $tables, int $maxRows, float $started, int $maxSeconds, bool $completeTableExport): array
    {
        $directory = dirname($path);
        $this->ensureDirectory($directory);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Database backup JSON is private SHIELD-X storage; same-directory temp files make the write atomic.
        $temporary = tempnam($directory, 'shieldx-db-');
        if (!is_string($temporary) || $temporary === '') {
            return [
                'ok' => false,
                'message' => 'Could not prepare database backup temp file.',
            ];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming private backup JSON avoids building large database payloads in memory.
        $handle = fopen($temporary, 'wb');
        if (!$handle) {
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => 'Could not open database backup temp file.',
            ];
        }

        $hash = hash_init('sha256', HASH_HMAC, $this->signatureKey());
        $tableCount = 0;
        $rowsExported = 0;
        $truncatedTables = 0;
        $warningDetails = [];
        $payloadBytes = 0;

        try {
            $writePayload = function (string $chunk) use ($handle, $hash, &$payloadBytes): void {
                $payloadBytes += strlen($chunk);
                if ($payloadBytes > self::MAX_BACKUP_JSON_BYTES - 1024) {
                    throw new \RuntimeException('Database backup exceeded the maximum supported uncompressed size.');
                }
                $this->writeStreamChunk($handle, $chunk);
                hash_update($hash, $chunk);
            };
            $json = function ($value): string {
                $encoded = wp_json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
                if (!is_string($encoded)) {
                    throw new \RuntimeException('Could not encode database backup chunk.');
                }

                return $encoded;
            };
            $ensureWithinTime = static function () use ($maxSeconds, $started): void {
                if ($maxSeconds > 0 && (microtime(true) - $started) >= $maxSeconds) {
                    throw new \RuntimeException('Database backup timed out before it could be completed.');
                }
            };

            $writePayload('{');
            $writePayload('"backup_id":' . $json($backupId));
            $writePayload(',"created_at":' . $json($createdAt));
            $writePayload(',"site_url":' . $json(function_exists('site_url') ? site_url() : ''));
            $writePayload(',"prefix":' . $json((string) $this->wpdb->prefix));
            $writePayload(',"format":"shieldx-db-json-v1"');
            $writePayload(',"row_export_mode":' . $json($completeTableExport ? 'complete' : 'capped'));
            $writePayload(',"max_rows_per_table":' . ($completeTableExport ? 0 : $maxRows));
            $writePayload(',"tables":[');

            $firstTable = true;
            foreach ($tables as $table) {
                $ensureWithinTime();
                if (!$this->validTableName($table)) {
                    continue;
                }

                if (!$firstTable) {
                    $writePayload(',');
                }
                $firstTable = false;
                $tableCount++;

                $writePayload('{');
                $writePayload('"name":' . $json($table));
                $writePayload(',"create_sql":' . $json($this->createSql($table)));
                $writePayload(',"rows":[');

                $firstRow = true;
                $tableRows = 0;
                $rowOffset = 0;
                while ($completeTableExport || $rowOffset < $maxRows) {
                    $ensureWithinTime();
                    $limit = $completeTableExport ? 1000 : min(1000, $maxRows - $rowOffset);
                    $rows = $this->tableRowsPage($table, $limit, $rowOffset);
                    $ensureWithinTime();
                    $count = count($rows);
                    if ($count === 0) {
                        break;
                    }

                    foreach ($rows as $row) {
                        if (!$firstRow) {
                            $writePayload(',');
                        }
                        $firstRow = false;
                        $writePayload($json($row));
                    }

                    $tableRows += $count;
                    $rowOffset += $count;

                    if ($count < $limit) {
                        break;
                    }
                }

                $truncated = !$completeTableExport && $tableRows >= $maxRows;
                if ($truncated) {
                    $truncatedTables++;
                    $warningDetails[] = sprintf('Table %s reached the per-table export cap of %d rows and was truncated.', $table, $maxRows);
                }
                $rowsExported += $tableRows;

                $writePayload(']');
                $writePayload(',"rows_exported":' . $tableRows);
                $writePayload(',"truncated":' . ($truncated ? 'true' : 'false'));
                $writePayload('}');
            }

            $ensureWithinTime();
            $durationMs = (int) round((microtime(true) - $started) * 1000);
            $writePayload(']');
            $writePayload(',"table_count":' . $tableCount);
            $writePayload(',"rows_exported":' . $rowsExported);
            $writePayload(',"truncated_tables":' . $truncatedTables);
            $writePayload(',"warning_count":' . count($warningDetails));
            $writePayload(',"warning_details":' . $json($warningDetails));
            $writePayload(',"duration_ms":' . $durationMs);
            hash_update($hash, '}');

            $signature = hash_final($hash);
            $this->writeStreamChunk($handle, ',"signature":{"alg":"hmac-sha256-stream-v1","value":' . $json($signature) . '}}' . "\n");

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Defense-in-depth for private backup JSON alongside deny rules.
            chmod($temporary, 0600);
        } catch (\Throwable $exception) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Native stream is required for chunked database backup JSON without loading all rows into memory.
            fclose($handle);
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => $exception->getMessage(),
            ];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Native stream is required for chunked database backup JSON without loading all rows into memory.
        fclose($handle);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic same-directory replacement prevents readers from seeing partial backup JSON.
        if (!rename($temporary, $path)) {
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => 'Could not move database backup file into place.',
            ];
        }

        return [
            'ok' => true,
            'table_count' => $tableCount,
            'rows_exported' => $rowsExported,
            'truncated_tables' => $truncatedTables,
            'warning_details' => $warningDetails,
            'duration_ms' => $durationMs,
        ];
    }

    /**
     * @param resource $handle
     */
    private function writeStreamChunk($handle, string $chunk): void
    {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Streaming private backup JSON avoids large memory spikes.
        if (fwrite($handle, $chunk) === false) {
            throw new \RuntimeException('Could not write database backup chunk.');
        }
    }

    private function writeBackupJson(string $path, string $json): bool
    {
        if (strlen($json) > self::MAX_BACKUP_JSON_BYTES) {
            return false;
        }

        $directory = dirname($path);
        $this->ensureDirectory($directory);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Database backup JSON is private SHIELD-X storage; same-directory temp files make the write atomic.
        $temporary = tempnam($directory, 'shieldx-db-');
        if (!is_string($temporary) || $temporary === '') {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- This writes a private backup file under SHIELD-X managed storage, not a host-managed WordPress file.
        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 JSON alongside deny rules.
        chmod($temporary, 0600);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic same-directory replacement prevents readers from seeing partial backup JSON.
        if (!rename($temporary, $path)) {
            $this->removeTemporaryFile($temporary);
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Keep final database backup JSON private where the filesystem honors modes.
        chmod($path, 0600);

        return true;
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function writeBackupMetadata(string $backupPath, array $metadata): void
    {
        $path = $this->backupMetadataPath($backupPath);
        if ($path === '') {
            return;
        }

        $payload = [
            'backup_id' => (string) ($metadata['backup_id'] ?? $this->backupIdFromPath($backupPath)),
            'created_at' => (string) ($metadata['created_at'] ?? $metadata['updated_at'] ?? ''),
            'table_count' => (int) ($metadata['table_count'] ?? $metadata['tables'] ?? 0),
            'rows_exported' => (int) ($metadata['rows_exported'] ?? 0),
            'row_export_mode' => strtolower((string) ($metadata['row_export_mode'] ?? '')),
            'warning_count' => max(0, (int) ($metadata['warning_count'] ?? 0)),
            'warning_details' => $this->sanitizeWarningDetails((array) ($metadata['warning_details'] ?? [])),
            'path' => $backupPath,
            'size' => is_file($backupPath) ? (int) filesize($backupPath) : (int) ($metadata['size'] ?? 0),
            'archive_format' => substr($backupPath, -4) === '.zip' ? 'zip' : 'json',
            'metadata_complete' => true,
        ];
        $json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);
        if (is_string($json)) {
            $this->writeBackupJson($path, $json . "\n");
        }
    }

    /**
     * @return array<string,mixed>|null
     */
    private function readBackupMetadata(string $backupPath): ?array
    {
        $path = $this->backupMetadataPath($backupPath);
        if ($path === '' || !is_readable($path)) {
            return null;
        }

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

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

    private function backupMetadataPath(string $backupPath): string
    {
        $backupId = $this->backupIdFromPath($backupPath);

        return $backupId !== '' ? dirname($backupPath) . '/' . $backupId . '.meta.json' : '';
    }

    private function backupPathFromMetadataPath(string $metadataPath): string
    {
        $filename = basename($metadataPath);
        if (!preg_match('/^(db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32})\.meta\.json$/', $filename, $matches)) {
            return '';
        }

        $existing = $this->existingBackupPath((string) $matches[1]);

        return $existing !== '' ? $existing : $this->backupPath((string) $matches[1]);
    }

    public function canDeleteBeforeDate(string $beforeDate): bool
    {
        return $this->backupCleanupCutoff($beforeDate) !== null;
    }

    private function backupCleanupCutoff(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;
    }

    private function deleteFileInsideRoot(string $path, string $root): bool
    {
        $root = rtrim(str_replace('\\', '/', $root), '/');
        $real = realpath($path);
        if (!is_string($real) || $real === '') {
            return false;
        }

        $real = str_replace('\\', '/', $real);
        if ($real === $root || strpos($real, $root . '/') !== 0 || !is_file($real)) {
            return false;
        }

        if (function_exists('wp_delete_file')) {
            return (bool) wp_delete_file($real);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes admin-selected private database backup artifacts when WordPress helpers are unavailable in tests.
        return unlink($real);
    }

    /**
     * @return array{backup_id:string,created_at:string}|null
     */
    private function backupInfoFromFilename(string $file): ?array
    {
        $filename = basename($file);
        if (!preg_match('/^(db-([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2})-[a-f0-9]{32})\.zip$/', $filename, $matches)) {
            return null;
        }

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

        return [
            'backup_id' => (string) $matches[1],
            'created_at' => sprintf('%04d-%02d-%02dT%02d:%02d:%02d+00:00', $year, $month, $day, $hour, $minute, $second),
        ];
    }

    /**
     * @return array<string,int>|null
     */
    private function backupFileStat(string $path): ?array
    {
        // A backup can be deleted between glob() and the listing pass.
        $stat = @stat($path);
        if (!is_array($stat) || !isset($stat['mode']) || (((int) $stat['mode'] & 0170000) !== 0100000)) {
            return null;
        }

        return $stat;
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function backupMetadataIsComplete(array $metadata, string $backupId): bool
    {
        return strtolower((string) ($metadata['backup_id'] ?? '')) === $backupId
            && isset($metadata['table_count'], $metadata['rows_exported'])
            && is_numeric($metadata['table_count'])
            && is_numeric($metadata['rows_exported']);
    }

    /**
     * @param array<int,array<string,mixed>> $backups
     * @return array<int,array<string,mixed>>
     */
    private function sortRecentDatabaseBackups(array $backups, int $limit): array
    {
        usort($backups, static function (array $left, array $right): int {
            $leftId = (string) ($left['backup_id'] ?? '');
            $rightId = (string) ($right['backup_id'] ?? '');
            $leftIsTimestamped = preg_match('/^db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $leftId) === 1;
            $rightIsTimestamped = preg_match('/^db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $rightId) === 1;
            if ($leftIsTimestamped && $rightIsTimestamped) {
                return strcmp($rightId, $leftId);
            }

            return strtotime((string) ($right['created_at'] ?? '')) <=> strtotime((string) ($left['created_at'] ?? ''));
        });

        return array_slice($backups, 0, max(1, min(50, $limit)));
    }

    private function removeTemporaryFile(string $path): void
    {
        if (is_file($path)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Remove failed private backup temp file from SHIELD-X managed storage.
            unlink($path);
        }
    }

    /**
     * @return array<int,string>
     */
    private function discoverTables(): array
    {
        if (!$this->hasDatabase() || !method_exists($this->wpdb, 'get_results') || !method_exists($this->wpdb, 'prepare') || !method_exists($this->wpdb, 'esc_like')) {
            return [];
        }

        $prefix = (string) $this->wpdb->prefix;
        $like = $this->wpdb->esc_like($prefix) . '%';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerates local WordPress tables for a user-requested private backup.
        $rows = $this->wpdb->get_results($this->wpdb->prepare('SHOW FULL TABLES LIKE %s', $like), defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');
        if (!is_array($rows)) {
            return [];
        }

        $tables = [];
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $tableName = '';
            $tableType = 'BASE TABLE';
            foreach ($row as $key => $value) {
                $key = (string) $key;
                if (stripos($key, 'table_type') !== false) {
                    $tableType = strtoupper((string) $value);
                } elseif ($tableName === '') {
                    $tableName = (string) $value;
                }
            }

            if ($tableType === 'BASE TABLE' && $this->validTableName($tableName)) {
                $tables[] = $tableName;
            }
        }

        return array_values(array_unique($tables));
    }

    /**
     * @param array<int,string> $requested
     * @param array<int,string> $available
     * @return array<int,string>
     */
    private function selectedTables(array $requested, array $available): array
    {
        $availableMap = array_fill_keys($available, true);
        if (empty($requested)) {
            return $available;
        }

        $selected = [];
        foreach ($requested as $table) {
            $table = trim((string) $table);
            if (isset($availableMap[$table])) {
                $selected[] = $table;
            }
        }

        return array_values(array_unique($selected));
    }

    private function createSql(string $table): string
    {
        if (!$this->validTableName($table)) {
            return '';
        }

        $row = $this->wpdb->get_row('SHOW CREATE TABLE `' . str_replace('`', '``', $table) . '`', defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');
        if (!is_array($row)) {
            return '';
        }

        foreach ($row as $value) {
            if (is_string($value) && stripos($value, 'CREATE TABLE') !== false) {
                return $value;
            }
        }

        return '';
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function tableRowsPage(string $table, int $limit, int $offset, string $cursorColumn = '', string $cursorValue = ''): array
    {
        if (!$this->validTableName($table)) {
            return [];
        }

        $limit = max(1, min(1000, $limit));
        $offset = max(0, $offset);
        $query = $cursorColumn !== '' && $cursorValue !== ''
            ? $this->prepareTableRowsKeysetQuery($table, $cursorColumn, $cursorValue, $limit)
            : $this->prepareTableRowsQuery($table, $limit, $offset);
        if ($query === '') {
            return [];
        }

        $rows = $this->wpdb->get_results($query, defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');

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

    private function prepareTableRowsKeysetQuery(string $table, string $column, string $cursor, int $limit): string
    {
        if (!$this->validTableName($table) || !$this->validTableName($column)
            || preg_match('/^-?\d+$/', $cursor) !== 1
            || !method_exists($this->wpdb, 'prepare')) {
            return '';
        }

        $identifierSql = $this->prepareIdentifierSql('SELECT * FROM %i', $table);
        if ($identifierSql === '') {
            return '';
        }

        $prepared = $this->wpdb->prepare(
            $identifierSql . ' WHERE ' . $this->quoteIdentifier($column) . ' > %s ORDER BY ' . $this->quoteIdentifier($column) . ' LIMIT %d',
            $cursor,
            $limit
        );

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

    private function prepareTableRowsQuery(string $table, int $limit, ?int $offset = null): string
    {
        $identifierSql = $this->prepareIdentifierSql('SELECT * FROM %i', $table);
        if ($identifierSql === '' || !method_exists($this->wpdb, 'prepare')) {
            return '';
        }

        $orderBy = $this->tableOrderBySql($table);
        if ($offset === null) {
            $prepared = $this->wpdb->prepare($identifierSql . $orderBy . ' LIMIT %d', $limit);
        } else {
            $prepared = $this->wpdb->prepare($identifierSql . $orderBy . ' LIMIT %d, %d', $offset, $limit);
        }

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

    private function tableOrderBySql(string $table): string
    {
        $columns = $this->tablePrimaryKeyColumns($table);
        if (empty($columns)) {
            return '';
        }

        $quoted = [];
        foreach ($columns as $column) {
            if (!$this->validTableName($column)) {
                return '';
            }
            $quoted[] = $this->quoteIdentifier($column);
        }

        return ' ORDER BY ' . implode(', ', $quoted);
    }

    /**
     * @return array<int,string>
     */
    private function tablePrimaryKeyColumns(string $table): array
    {
        if (!$this->validTableName($table)) {
            return [];
        }

        if (array_key_exists($table, $this->primaryKeyColumns)) {
            return $this->primaryKeyColumns[$table];
        }

        if (!method_exists($this->wpdb, 'get_results')) {
            $this->primaryKeyColumns[$table] = [];

            return [];
        }

        $query = 'SHOW KEYS FROM ' . $this->quoteIdentifier($table) . " WHERE Key_name = 'PRIMARY'";
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads table metadata to make paged backup exports deterministic.
        $rows = $this->wpdb->get_results($query, defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');
        if (!is_array($rows)) {
            $this->primaryKeyColumns[$table] = [];

            return [];
        }

        usort($rows, static function (array $left, array $right): int {
            return (int) ($left['Seq_in_index'] ?? $left['seq_in_index'] ?? 0) <=> (int) ($right['Seq_in_index'] ?? $right['seq_in_index'] ?? 0);
        });

        $columns = [];
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $column = (string) ($row['Column_name'] ?? $row['column_name'] ?? '');
            if ($this->validTableName($column)) {
                $columns[] = $column;
            }
        }

        $this->primaryKeyColumns[$table] = array_values(array_unique($columns));

        return $this->primaryKeyColumns[$table];
    }

    private function tableNumericPrimaryKeyColumn(string $table): string
    {
        if (!$this->validTableName($table)) {
            return '';
        }

        if (array_key_exists($table, $this->numericPrimaryKeyColumns)) {
            return $this->numericPrimaryKeyColumns[$table];
        }

        $primaryKey = $this->tablePrimaryKeyColumns($table);
        if (count($primaryKey) !== 1 || !method_exists($this->wpdb, 'get_results') || !method_exists($this->wpdb, 'prepare')) {
            $this->numericPrimaryKeyColumns[$table] = '';

            return '';
        }

        $column = (string) $primaryKey[0];
        $prepared = $this->wpdb->prepare(
            'SHOW COLUMNS FROM ' . $this->quoteIdentifier($table) . ' WHERE Field = %s',
            $column
        );
        if (!is_string($prepared) || $prepared === '') {
            $this->numericPrimaryKeyColumns[$table] = '';

            return '';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads table metadata once to choose a deterministic resumable cursor.
        $rows = $this->wpdb->get_results($prepared, defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');
        $type = is_array($rows) && isset($rows[0]) && is_array($rows[0])
            ? (string) ($rows[0]['Type'] ?? $rows[0]['type'] ?? '')
            : '';
        $this->numericPrimaryKeyColumns[$table] = preg_match('/^(?:tinyint|smallint|mediumint|int|bigint)(?:\(\d+\))?(?:\s+unsigned)?$/i', trim($type)) === 1
            ? $column
            : '';

        return $this->numericPrimaryKeyColumns[$table];
    }

    private function validTableName(string $table): bool
    {
        return preg_match('/^[A-Za-z0-9_]+$/', $table) === 1;
    }

    private function isCreateSqlForTable(string $sql, string $table): bool
    {
        if (!$this->validTableName($table)) {
            return false;
        }

        $pattern = '/^\s*CREATE\s+TABLE\s+`?' . preg_quote($table, '/') . '`?\s*\(/i';

        return preg_match($pattern, $sql) === 1;
    }

    private function safeCreateTableStatement(string $sql, string $table): string
    {
        $statements = $this->splitSqlStatements($sql);
        if (count($statements) !== 1) {
            return '';
        }

        $statement = trim($statements[0]);
        if ($statement === '' || !$this->isCreateSqlForTable($statement, $table)) {
            return '';
        }

        return $statement;
    }

    /**
     * @return array<int,string>
     */
    private function splitSqlStatements(string $sql): array
    {
        $statements = [];
        $buffer = '';
        $quote = '';
        $length = strlen($sql);
        $lineComment = false;
        $blockComment = false;

        for ($index = 0; $index < $length; $index++) {
            $char = $sql[$index];
            $next = $index + 1 < $length ? $sql[$index + 1] : '';

            if ($lineComment) {
                $buffer .= $char;
                if ($char === "\n") {
                    $lineComment = false;
                }
                continue;
            }

            if ($blockComment) {
                $buffer .= $char;
                if ($char === '*' && $next === '/') {
                    $buffer .= $next;
                    $index++;
                    $blockComment = false;
                }
                continue;
            }

            if ($quote !== '') {
                $buffer .= $char;
                if ($char === '\\' && $quote !== '`' && $next !== '') {
                    $buffer .= $next;
                    $index++;
                    continue;
                }

                if ($char === $quote) {
                    if ($quote !== '`' && $next === $quote) {
                        $buffer .= $next;
                        $index++;
                        continue;
                    }
                    $quote = '';
                }
                continue;
            }

            if (($char === '-' && $next === '-') || $char === '#') {
                $lineComment = true;
                $buffer .= $char;
                if ($char === '-' && $next === '-') {
                    $buffer .= $next;
                    $index++;
                }
                continue;
            }

            if ($char === '/' && $next === '*') {
                $blockComment = true;
                $buffer .= $char . $next;
                $index++;
                continue;
            }

            if ($char === '\'' || $char === '"' || $char === '`') {
                $quote = $char;
                $buffer .= $char;
                continue;
            }

            if ($char === ';') {
                $statement = trim($buffer);
                if ($statement !== '') {
                    $statements[] = $statement;
                }
                $buffer = '';
                continue;
            }

            $buffer .= $char;
        }

        if ($quote !== '' || $blockComment) {
            return [];
        }

        $statement = trim($buffer);
        if ($statement !== '') {
            $statements[] = $statement;
        }

        return $statements;
    }

    private function prepareIdentifierSql(string $sql, string $identifier): string
    {
        if (!$this->validTableName($identifier)) {
            return '';
        }

        if ($this->supportsIdentifierPlaceholders()) {
            $prepared = $this->wpdb->prepare($sql, $identifier);
            if (is_string($prepared) && $prepared !== '') {
                return $prepared;
            }
        }

        return str_replace('%i', '`' . str_replace('`', '``', $identifier) . '`', $sql);
    }

    /**
     * @param array<int,mixed> $rows
     */
    private function restoreRows(string $table, array $rows): int
    {
        $restored = 0;
        $batch = [];
        $batchColumns = [];
        $batchBytes = 0;

        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $insert = $this->cleanRestoreRow($row);
            if (empty($insert)) {
                continue;
            }

            $columns = array_keys($insert);
            if (!empty($batch) && $columns !== $batchColumns) {
                $restored += $this->insertRestoreRows($table, $batchColumns, $batch);
                $batch = [];
                $batchColumns = [];
                $batchBytes = 0;
            }

            if (empty($batchColumns)) {
                $batchColumns = $columns;
            }

            $rowBytes = $this->estimatedRestoreRowSqlBytes($insert);
            if (!empty($batch) && ($batchBytes + $rowBytes) > self::RESTORE_INSERT_BATCH_BYTES) {
                $restored += $this->insertRestoreRows($table, $batchColumns, $batch);
                $batch = [];
                $batchColumns = $columns;
                $batchBytes = 0;
            }

            $batch[] = $insert;
            $batchBytes += $rowBytes;
            if (count($batch) >= self::RESTORE_INSERT_BATCH_ROWS) {
                $restored += $this->insertRestoreRows($table, $batchColumns, $batch);
                $batch = [];
                $batchColumns = [];
                $batchBytes = 0;
            }
        }

        if (!empty($batch)) {
            $restored += $this->insertRestoreRows($table, $batchColumns, $batch);
        }

        return $restored;
    }

    /**
     * @param array<string,mixed> $row
     */
    private function estimatedRestoreRowSqlBytes(array $row): int
    {
        $bytes = 32;
        foreach ($row as $value) {
            if ($value === null) {
                $bytes += 4;
            } elseif (is_int($value) || is_float($value)) {
                $bytes += strlen((string) $value);
            } else {
                $bytes += strlen((string) $value) * 2 + 3;
            }
        }

        return $bytes;
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function cleanRestoreRow(array $row): array
    {
        $insert = [];
        foreach ($row as $key => $value) {
            $column = (string) $key;
            if (!$this->validTableName($column)) {
                continue;
            }

            if (is_bool($value)) {
                $insert[$column] = $value ? 1 : 0;
            } elseif (is_scalar($value) || $value === null) {
                $insert[$column] = $value;
            }
        }

        return $insert;
    }

    /**
     * @param array<int,string>              $columns
     * @param array<int,array<string,mixed>> $rows
     */
    private function insertRestoreRows(string $table, array $columns, array $rows): int
    {
        if (empty($columns) || empty($rows) || !$this->validTableName($table) || !method_exists($this->wpdb, 'prepare')) {
            return 0;
        }

        $columnSql = [];
        foreach ($columns as $column) {
            if (!$this->validTableName($column)) {
                return 0;
            }

            $columnSql[] = $this->quoteIdentifier($column);
        }

        $args = [];
        $valueRows = [];
        foreach ($rows as $row) {
            $placeholders = [];
            foreach ($columns as $column) {
                $value = $row[$column] ?? null;
                if ($value === null) {
                    $placeholders[] = 'NULL';
                } elseif (is_int($value)) {
                    $placeholders[] = '%d';
                    $args[] = $value;
                } elseif (is_float($value)) {
                    $placeholders[] = '%f';
                    $args[] = $value;
                } else {
                    $placeholders[] = '%s';
                    $args[] = (string) $value;
                }
            }
            $valueRows[] = '(' . implode(',', $placeholders) . ')';
        }

        $sql = 'INSERT INTO ' . $this->quoteIdentifier($table) . ' (' . implode(',', $columnSql) . ') VALUES ' . implode(',', $valueRows);
        $prepared = $this->wpdb->prepare($sql, ...$args);
        if (!is_string($prepared) || $prepared === '') {
            throw new \RuntimeException('Could not prepare backup rows for restore.');
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared immediately above; identifiers are validated and quoted by SHIELD-X.
        if ($this->wpdb->query($prepared) === false) {
            throw new \RuntimeException('Could not insert backup rows.');
        }

        return count($rows);
    }

    private function quoteIdentifier(string $identifier): string
    {
        return '`' . str_replace('`', '``', $identifier) . '`';
    }

    private function supportsIdentifierPlaceholders(): bool
    {
        return is_object($this->wpdb)
            && method_exists($this->wpdb, 'has_cap')
            && (bool) $this->wpdb->has_cap('identifier_placeholders');
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function signPayload(array $payload): string
    {
        unset($payload['signature'], $payload['_path']);
        $this->sortRecursive($payload);
        $json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);

        return is_string($json) ? hash_hmac('sha256', $json, $this->signatureKey()) : '';
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function verifyPayloadSignature(array $payload, string $raw = ''): bool
    {
        $signature = isset($payload['signature']) && is_array($payload['signature'])
            ? $payload['signature']
            : [];
        $value = (string) ($signature['value'] ?? '');
        $alg = (string) ($signature['alg'] ?? 'hmac-sha256');

        if ($value === '') {
            return false;
        }

        if ($alg === 'hmac-sha256-stream-v1') {
            $rawSignature = $raw !== '' ? $this->signRawStreamPayload($raw) : '';
            if ($rawSignature !== '' && hash_equals($rawSignature, $value)) {
                return true;
            }

            return hash_equals($this->signPayloadInDocumentOrder($payload), $value);
        }

        return hash_equals($this->signPayload($payload), $value);
    }

    private function signRawStreamPayload(string $raw): string
    {
        $trimmed = rtrim($raw);
        if (preg_match('/,"signature"\s*:\s*\{\s*"alg"\s*:\s*"hmac-sha256-stream-v1"\s*,\s*"value"\s*:\s*"[a-f0-9]{64}"\s*\}\s*\}\z/s', $trimmed, $match) !== 1) {
            return '';
        }

        $unsigned = substr($trimmed, 0, -strlen($match[0])) . '}';

        return hash_hmac('sha256', $unsigned, $this->signatureKey());
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function signPayloadInDocumentOrder(array $payload): string
    {
        unset($payload['signature'], $payload['_path']);
        $json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);

        return is_string($json) ? hash_hmac('sha256', $json, $this->signatureKey()) : '';
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function sortRecursive(array &$payload): void
    {
        ksort($payload);
        foreach ($payload as &$value) {
            if (is_array($value)) {
                $this->sortRecursive($value);
            }
        }
        unset($value);
    }

    private function signatureKey(): string
    {
        $salt = function_exists('wp_salt') ? (string) wp_salt('auth') : '';

        return hash('sha256', $salt . '|shieldx-db-backup|' . $this->storage->baseDir(), true);
    }

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

    /**
     * @return array<int,string>
     */
    private function localBackupFiles(): array
    {
        $files = glob($this->backupDirectory() . '/db-*.zip');

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

    private function backupPath(string $backupId): string
    {
        $backupId = trim(strtolower($backupId));
        if (!preg_match('/^db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $backupId)) {
            return '';
        }

        return $this->backupDirectory() . '/' . $backupId . '.zip';
    }

    private function existingBackupPath(string $backupId): string
    {
        $zipPath = $this->backupPath($backupId);
        if ($zipPath !== '' && is_file($zipPath) && is_readable($zipPath)) {
            return $zipPath;
        }

        return '';
    }

    private function backupIdFromPath(string $path): string
    {
        $filename = basename($path);
        if (!preg_match('/^(db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32})\.zip$/', $filename, $matches)) {
            return '';
        }

        return (string) $matches[1];
    }

    private function temporaryBackupDirectory(string $backupId, string $purpose = 'pack'): string
    {
        $backupId = trim(strtolower($backupId));
        if (!preg_match('/^db-[0-9]{8}-[0-9]{6}-[a-f0-9]{32}$/', $backupId)) {
            return '';
        }

        $purpose = preg_replace('/[^a-z0-9-]+/', '-', strtolower($purpose)) ?: 'tmp';
        try {
            $token = bin2hex(random_bytes(8));
        } catch (\Throwable $error) {
            return '';
        }

        return $this->backupDirectory() . '/.' . $purpose . '-' . $backupId . '-' . $token;
    }

    /**
     * @return array<string,mixed>
     */
    private function createDatabaseBackupZip(string $jsonPath, string $zipPath): array
    {
        if (!$this->loadWordPressZipApi()) {
            return [
                'ok' => false,
                'message' => 'WordPress ZIP support is not available.',
            ];
        }

        if (!is_file($jsonPath) || !is_readable($jsonPath)) {
            return [
                'ok' => false,
                'message' => 'Database backup JSON was not prepared for compression.',
            ];
        }

        $this->ensureDirectory(dirname($zipPath));
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Same-directory temp archives avoid partial ZIP reads in private SHIELD-X storage.
        $temporary = tempnam(dirname($zipPath), 'shieldx-db-zip-');
        if (!is_string($temporary) || $temporary === '') {
            return [
                'ok' => false,
                'message' => 'Could not create a temporary database backup archive.',
            ];
        }
        $this->removeTemporaryFile($temporary);

        $archive = new \PclZip($temporary);
        $created = $archive->create($jsonPath, PCLZIP_OPT_REMOVE_PATH, dirname($jsonPath));
        if ($created === 0 || $created === false) {
            $message = method_exists($archive, 'errorInfo') ? (string) $archive->errorInfo(true) : '';
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => $message !== '' ? 'WordPress could not create the database backup ZIP: ' . $message : 'WordPress could not create the database backup ZIP.',
            ];
        }

        if (!is_file($temporary)) {
            return [
                'ok' => false,
                'message' => 'WordPress did not create the database backup ZIP.',
            ];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Keep private database backup archive unreadable to other users where modes are honored.
        chmod($temporary, 0600);
        $this->removeTemporaryFile($zipPath);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic same-directory replacement prevents readers from seeing partial backup archives.
        if (!rename($temporary, $zipPath)) {
            $this->removeTemporaryFile($temporary);

            return [
                'ok' => false,
                'message' => 'Could not finalize the database backup ZIP.',
            ];
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Keep final database backup archive private where modes are honored.
        chmod($zipPath, 0600);

        return ['ok' => true];
    }

    private function loadWordPressZipApi(): bool
    {
        if (!class_exists('\PclZip', false) && defined('ABSPATH')) {
            $path = rtrim(str_replace('\\', '/', (string) ABSPATH), '/') . '/wp-admin/includes/class-pclzip.php';
            if (is_readable($path)) {
                require_once $path;
            }
        }

        return class_exists('\PclZip', false) && defined('PCLZIP_OPT_REMOVE_PATH');
    }

    /**
     * @return array{raw:string,decoded:array<string,mixed>}|null
     */
    private function readDatabaseBackupPayload(string $path, string $backupId): ?array
    {
        return substr($path, -4) === '.zip'
            ? $this->readDatabaseBackupZipPayload($path, $backupId)
            : null;
    }

    /**
     * @return array{raw:string,decoded:array<string,mixed>}|null
     */
    private function readDatabaseBackupZipPayload(string $path, string $backupId): ?array
    {
        if (!$this->loadWordPressUnzipApi() || !$this->ensureWordPressFilesystem()) {
            return null;
        }

        $temporaryDirectory = $this->temporaryBackupDirectory($backupId, 'unzip');
        if ($temporaryDirectory === '') {
            return null;
        }

        $this->ensureDirectory($temporaryDirectory);
        $result = unzip_file($path, $temporaryDirectory);
        if ((function_exists('is_wp_error') && is_wp_error($result)) || $result !== true) {
            $this->removeDirectory($temporaryDirectory);

            return null;
        }

        $jsonPath = $temporaryDirectory . '/' . $backupId . '.json';
        $root = realpath($temporaryDirectory);
        $real = realpath($jsonPath);
        if (!is_string($root) || !is_string($real) || !$this->pathInside($real, $root) || !is_readable($real)) {
            $this->removeDirectory($temporaryDirectory);

            return null;
        }

        $contents = file_get_contents($real);
        $decoded = is_string($contents) ? \ShieldX\Support\Json::decodeArrayRaw($contents, self::MAX_BACKUP_JSON_BYTES) : null;
        $this->removeDirectory($temporaryDirectory);
        if (!is_string($contents) || !is_array($decoded)) {
            return null;
        }

        return [
            'raw' => $contents,
            'decoded' => $decoded,
        ];
    }

    private function loadWordPressUnzipApi(): bool
    {
        if (!function_exists('unzip_file') && defined('ABSPATH')) {
            $path = rtrim(str_replace('\\', '/', (string) ABSPATH), '/') . '/wp-admin/includes/file.php';
            if (is_readable($path)) {
                require_once $path;
            }
        }

        return function_exists('unzip_file');
    }

    private function ensureWordPressFilesystem(): bool
    {
        if (!function_exists('WP_Filesystem')) {
            return true;
        }

        global $wp_filesystem;
        if (is_object($wp_filesystem)) {
            return true;
        }

        return (bool) WP_Filesystem(false, false, true);
    }

    private function removeDirectory(string $path): void
    {
        $root = realpath($this->backupDirectory());
        $real = realpath($path);
        if (!is_string($root) || !is_string($real) || !$this->pathInside($real, $root) || !is_dir($real)) {
            return;
        }

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

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

            $child = $real . DIRECTORY_SEPARATOR . $item;
            if (is_dir($child) && !is_link($child)) {
                $this->removeDirectory($child);
            } elseif (is_file($child) || is_link($child)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes temporary private backup files created by SHIELD-X.
                unlink($child);
            }
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes temporary private backup directories created by SHIELD-X.
        rmdir($real);
    }

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

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

    private function hasDatabase(): bool
    {
        return is_object($this->wpdb) && isset($this->wpdb->prefix);
    }

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