<?php

namespace classes;

require_once WD . '/classes/backup_schema_bootstrap.php';
require_once WD . '/modules/backups/backups_c.php';

use backups\backups_c;
use Exception;
use interfaces\minio_backups_i;
use RuntimeException;
use Throwable;
use traits\minio_t;

class backup_store implements minio_backups_i
{
    use minio_t;

    public const BACKUP_SCHEMA_VERSION = 2;
    public const backup_s3_prefix = 'backups/';
    public const metadata_s3_prefix = 'metadata/';
    public const local_backup_path = '/tmp/backups/';
    private const BACKUP_BUCKET = 'backups';
    private const APP_DATA_BUCKETS = ['pdfs', 'uploads', 'attachments', 'invoices', 'truckwashdev'];

    public backups_c $config;
    private string $lock_owner;

    /**
     * @throws Exception
     */
    public function __construct()
    {
        backup_schema_bootstrap::ensureTables();
        $this->config = new backups_c();
        $this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
        self::setBucket(self::BACKUP_BUCKET);
        self::validateBucket();
    }

    public function validateBucket(): void
    {
        if ($this->shouldUseLocalBackupStorage()) {
            $this->localTestBucketDirectory(self::BACKUP_BUCKET);
            return;
        }

        if (!self::getS3Client()->doesBucketExist(self::getBucket())) {
            throw new Exception('The bucket does not exist in the S3 service! Missing: ' . self::getBucket());
        }
    }

    public function backup_exists(string $backup_uuid): bool
    {
        $record = $this->backupRecord($backup_uuid);
        if ($record !== null) {
            return !in_array((string)$record['status'], ['failed', 'pruned'], true);
        }

        return $this->objectExists(self::BACKUP_BUCKET, self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
    }

    public function getBackupDownloadUrl(string $backup_uuid): string
    {
        $record = $this->backupRecord($backup_uuid);
        if ($record !== null && (string)($record['manifest_key'] ?? '') !== '') {
            return self::getPresignedUrl((string)$record['manifest_key']);
        }

        return self::getPresignedUrl(self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
    }

    public function download(string $backup_uuid): string
    {
        $path = self::local_backup_path . 'backup_' . $backup_uuid . '.zip';
        $this->ensureLocalDirectory(dirname($path));
        $body = $this->getObjectBody(self::BACKUP_BUCKET, self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
        file_put_contents($path, $body);
        return $path;
    }

    /**
     * Legacy synchronous entrypoint. New HTTP routes should use enqueueCreateBackup().
     *
     * @throws Exception
     */
    public function createBackup($backup_name = null, $backup_description = null): string
    {
        $queued = $this->enqueueCreateBackup($backup_name, $backup_description, null, 'cron');
        $job = $this->runJobById((int)$queued['job_id']);
        if (!in_array((string)$job['status'], ['succeeded', 'created_unverified'], true)) {
            throw new RuntimeException((string)($job['error_message'] ?? 'Backup failed.'));
        }

        return (string)$queued['backup_uuid'];
    }

    public function enqueueCreateBackup($backup_name = null, $backup_description = null, ?int $actor_user_id = null, string $source = 'manual'): array
    {
        if (!$this->configBool('enabled', true)) {
            throw new RuntimeException('Backup system is disabled in backup configuration.');
        }

        $backup_uuid = self::uuid();
        $now = date('Y-m-d H:i:s');
        $backup_name = trim((string)($backup_name ?: 'Backup ' . $now));
        $backup_description = trim((string)($backup_description ?: 'Backup created on ' . $now));
        $storage_prefix = self::backup_s3_prefix . $backup_uuid . '/';

        $this->query(
            "INSERT INTO backup_records
                (backup_uuid, name, description, source, status, schema_version, storage_bucket, storage_prefix, requested_by_user_id)
            VALUES ("
            . $this->sql($backup_uuid) . ', '
            . $this->sql($backup_name) . ', '
            . $this->nullableSql($backup_description) . ', '
            . $this->sql($source) . ", 'queued', "
            . self::BACKUP_SCHEMA_VERSION . ', '
            . $this->sql(self::BACKUP_BUCKET) . ', '
            . $this->sql($storage_prefix) . ', '
            . ($actor_user_id === null ? 'NULL' : (string)$actor_user_id)
            . ")"
        );

        $job_id = $this->createJob('create', $backup_uuid, [
            'name' => $backup_name,
            'description' => $backup_description,
            'source' => $source,
        ], $actor_user_id);

        return [
            'job_id' => $job_id,
            'backup_uuid' => $backup_uuid,
            'status' => 'queued',
        ];
    }

    public function enqueueVerifyBackup(string $backup_uuid, ?int $actor_user_id = null): array
    {
        $this->requireBackupRecord($backup_uuid);
        $job_id = $this->createJob('verify', $backup_uuid, [], $actor_user_id);

        return [
            'job_id' => $job_id,
            'backup_uuid' => $backup_uuid,
            'status' => 'queued',
        ];
    }

    public function previewRestore(string $backup_uuid, ?int $actor_user_id = null): array
    {
        $record = $this->requireBackupRecord($backup_uuid);
        if ((string)$record['status'] !== 'available') {
            throw new RuntimeException('Only verified available backups can be restored.');
        }

        $manifest = $this->loadManifest($record);
        $checks = $this->restorePreflightChecks($record, $manifest);
        $expires_at = date('c', time() + 900);
        $confirmation_phrase = 'RESTORE ' . $backup_uuid . ' TO PRODUCTION';
        $preview = [
            'backup_uuid' => $backup_uuid,
            'backup_name' => $record['name'],
            'target_environment' => 'production',
            'expires_at' => $expires_at,
            'confirmation_phrase' => $confirmation_phrase,
            'checks' => $checks,
            'destructive_impact' => [
                'database' => 'The current production database will be replaced from the backup dump after a pre-restore backup is created.',
                'object_data' => 'App-owned object buckets will be reconciled to the backup manifest. Extra objects are moved to restore quarantine.',
            ],
        ];

        $job_id = $this->createJob('restore_preview', $backup_uuid, ['expires_at' => $expires_at], $actor_user_id, 'succeeded', 100, 'Restore preview ready.', $preview);

        return $preview + ['preview_id' => $job_id];
    }

    public function enqueueRestore(string $backup_uuid, int $preview_job_id, string $confirmation_phrase, string $reason, ?int $actor_user_id = null, array $request_context = []): array
    {
        if (!$this->configBool('restore_enabled', false)) {
            throw new RuntimeException('Direct production restore is disabled in backup configuration.');
        }

        $record = $this->requireBackupRecord($backup_uuid);
        if ((string)$record['status'] !== 'available') {
            throw new RuntimeException('Only verified available backups can be restored.');
        }

        $preview = $this->getJob($preview_job_id);
        if ($preview === null || (string)$preview['job_type'] !== 'restore_preview' || (string)$preview['backup_uuid'] !== $backup_uuid) {
            throw new RuntimeException('Restore preview is missing or does not match this backup.');
        }

        $result = $this->decodeJson($preview['result_json'] ?? null);
        if (strtotime((string)($result['expires_at'] ?? '')) < time()) {
            throw new RuntimeException('Restore preview has expired.');
        }

        $expected = 'RESTORE ' . $backup_uuid . ' TO PRODUCTION';
        if (!hash_equals($expected, trim($confirmation_phrase))) {
            throw new RuntimeException('Restore confirmation phrase did not match.');
        }

        $reason = trim($reason);
        if ($reason === '') {
            throw new RuntimeException('Restore reason is required.');
        }

        $job_id = $this->createJob('restore', $backup_uuid, [
            'preview_job_id' => $preview_job_id,
            'reason' => $reason,
            'request_context' => $request_context,
        ], $actor_user_id);

        $this->query(
            "INSERT INTO backup_restore_audit
                (restore_job_id, preview_job_id, backup_uuid, actor_user_id, target_environment, confirmation_fingerprint, reason, ip_address, user_agent, status)
            VALUES ("
            . (string)$job_id . ', '
            . (string)$preview_job_id . ', '
            . $this->sql($backup_uuid) . ', '
            . ($actor_user_id === null ? 'NULL' : (string)$actor_user_id) . ", 'production', "
            . $this->sql(hash('sha256', $confirmation_phrase)) . ', '
            . $this->nullableSql($reason) . ', '
            . $this->nullableSql((string)($request_context['ip_address'] ?? '')) . ', '
            . $this->nullableSql(substr((string)($request_context['user_agent'] ?? ''), 0, 255)) . ", 'queued')"
        );

        return [
            'job_id' => $job_id,
            'backup_uuid' => $backup_uuid,
            'status' => 'queued',
        ];
    }

    public function enqueueRetentionPrune(?int $actor_user_id = null): array
    {
        $job_id = $this->createJob('prune', null, [], $actor_user_id);

        return [
            'job_id' => $job_id,
            'status' => 'queued',
        ];
    }

    public function processPendingJobs(int $limit = 5): array
    {
        $limit = max(1, min(25, $limit));
        $jobs = $this->fetchAll(
            "SELECT * FROM backup_jobs WHERE status = 'queued' ORDER BY id ASC LIMIT $limit"
        );
        $processed = [];

        foreach ($jobs as $job) {
            $processed[] = $this->runJobById((int)$job['id']);
        }

        return [
            'processed' => $processed,
            'count' => count($processed),
        ];
    }

    public function runJobById(int $job_id): array
    {
        $job = $this->getJob($job_id);
        if ($job === null) {
            throw new RuntimeException('Backup job not found.');
        }

        if ((string)$job['status'] !== 'queued') {
            return $job + ['result' => $this->decodeJson($job['result_json'] ?? null)];
        }

        $this->query(
            "UPDATE backup_jobs
            SET status = 'running',
                started_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
                locked_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
                lock_owner = " . $this->sql($this->lock_owner) . "
            WHERE id = " . (string)$job_id . " AND status = 'queued'"
        );
        if ($this->affectedRows() !== 1) {
            return $this->getJob($job_id) ?? $job;
        }

        $status = 'succeeded';
        $message = 'Backup job completed.';
        $result = [];
        $error = null;

        try {
            $type = (string)$job['job_type'];
            $payload = $this->decodeJson($job['payload_json'] ?? null);
            if ($type === 'create') {
                $result = $this->performCreateBackup((string)$job['backup_uuid'], $payload);
                $status = (string)($result['status'] ?? 'succeeded');
                $message = (string)($result['message'] ?? 'Backup created.');
            } elseif ($type === 'verify') {
                $result = $this->performVerification((string)$job['backup_uuid'], true);
                $status = (string)($result['job_status'] ?? 'succeeded');
                $message = (string)($result['message'] ?? 'Backup verification completed.');
            } elseif ($type === 'restore') {
                $result = $this->performRestore((string)$job['backup_uuid'], $payload, (int)($job['actor_user_id'] ?? 0) ?: null, $job_id);
                $message = 'Restore completed.';
            } elseif ($type === 'prune') {
                $result = $this->pruneRetention();
                $message = 'Backup retention pruned.';
            } else {
                throw new RuntimeException('Unsupported backup job type: ' . $type);
            }
        } catch (Throwable $throwable) {
            $status = 'failed';
            $error = $throwable->getMessage();
            $message = $error;
        }

        $this->completeJob($job_id, $status, $message, $result, $error);
        return $this->getJob($job_id) + ['result' => $result];
    }

    public function getJob(int $job_id): ?array
    {
        $job = $this->fetchOne("SELECT * FROM backup_jobs WHERE id = " . (string)$job_id);
        if ($job === null) {
            return null;
        }
        $job['payload'] = $this->decodeJson($job['payload_json'] ?? null);
        $job['result'] = $this->decodeJson($job['result_json'] ?? null);
        return $job;
    }

    public function listJobs(?string $backup_uuid = null, int $limit = 50): array
    {
        $limit = max(1, min(200, $limit));
        $where = $backup_uuid !== null && $backup_uuid !== ''
            ? "WHERE backup_uuid = " . $this->sql($backup_uuid)
            : '';

        return [
            'jobs' => $this->fetchAll("SELECT * FROM backup_jobs $where ORDER BY id DESC LIMIT $limit"),
        ];
    }

    public function generateBackupFile(): string
    {
        $uuid = $this->createBackup();
        return self::local_backup_path . 'backup_' . $uuid . '.zip';
    }

    public function cleanupBackupFile(string $backup_uuid): void
    {
        $path = self::local_backup_path . 'backup_' . $backup_uuid;
        if (is_file($path . '.zip')) {
            @unlink($path . '.zip');
        }
        if (is_file($path . '.json')) {
            @unlink($path . '.json');
        }
        if (is_dir($path)) {
            $this->removeDirectory($path);
        }
    }

    public function backupDatabase(string $backup_uuid): string
    {
        $component = $this->createDatabaseComponent($backup_uuid);
        return (string)$component['storage_key'];
    }

    public function backupServerFiles(string $backup_uuid): string
    {
        throw new RuntimeException('Full server filesystem backups are intentionally not supported. Restore application source from deployment artifacts.');
    }

    public function backupEnvironmentVariables(string $backup_uuid): string
    {
        $key = self::backup_s3_prefix . $backup_uuid . '/runtime-manifest.json';
        $manifest = [
            'generated_at' => date('c'),
            'required_config_keys' => $this->sanitizedRuntimeConfigKeys(),
        ];
        $this->putObjectBody(self::BACKUP_BUCKET, $key, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        return $key;
    }

    public function createBackupMetadata(string $backup_uuid, string $backup_name, string $backup_description): void
    {
        $record = $this->backupRecord($backup_uuid);
        $metadata = [
            'backup_uuid' => $backup_uuid,
            'backup_name' => $backup_name,
            'backup_description' => $backup_description,
            'backup_date' => date('Y-m-d H:i:s'),
            'status' => $record['status'] ?? 'legacy',
            'hash' => $record['manifest_sha256'] ?? null,
        ];
        $this->putObjectBody(
            self::BACKUP_BUCKET,
            self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json',
            json_encode($metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
        );
    }

    public function listBackups(?int $limit = 50, int $offset = 0): array
    {
        $limit = max(1, min(200, (int)$limit));
        $offset = max(0, $offset);
        $total = (int)($this->fetchOne("SELECT COUNT(*) AS total FROM backup_records")['total'] ?? 0);
        $records = $this->fetchAll(
            "SELECT * FROM backup_records ORDER BY created_at DESC LIMIT $limit OFFSET $offset"
        );

        foreach ($records as &$record) {
            $record['components'] = $this->componentsForBackup((string)$record['backup_uuid']);
        }

        return [
            'items' => $records,
            'legacy_items' => $offset === 0 ? $this->legacyBackups() : [],
            'total' => $total,
            'limit' => $limit,
            'offset' => $offset,
            'has_more' => ($offset + count($records)) < $total,
        ];
    }

    public function getBackupMetadata(string $backup_uuid): array
    {
        $record = $this->backupRecord($backup_uuid);
        if ($record !== null) {
            return $record + ['components' => $this->componentsForBackup($backup_uuid)];
        }

        $metadata = $this->getObjectBody(self::BACKUP_BUCKET, self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json');
        return json_decode($metadata, true) ?: [];
    }

    public function restoreAudit(int $limit = 50): array
    {
        $limit = max(1, min(200, $limit));
        return [
            'items' => $this->fetchAll("SELECT * FROM backup_restore_audit ORDER BY id DESC LIMIT $limit"),
        ];
    }

    public function latestVerifiedBackup(): ?array
    {
        return $this->fetchOne(
            "SELECT * FROM backup_records WHERE status = 'available' AND verified_at IS NOT NULL ORDER BY verified_at DESC LIMIT 1"
        );
    }

    public function healthSummary(): array
    {
        $latest = $this->latestVerifiedBackup();
        $encryption = $this->encryptionStatus();
        $latestAgeSeconds = null;
        if ($latest !== null && strtotime((string)$latest['verified_at']) !== false) {
            $latestAgeSeconds = time() - strtotime((string)$latest['verified_at']);
        }

        $fresh = $latestAgeSeconds !== null && $latestAgeSeconds <= 4500;

        return [
            'latest_verified_backup' => $latest,
            'latest_verified_age_seconds' => $latestAgeSeconds,
            'fresh' => $fresh,
            'encryption' => $encryption,
            'verification_required' => $this->configBool('verification_required', true),
            'restore_enabled' => $this->configBool('restore_enabled', false),
        ];
    }

    private function performCreateBackup(string $backup_uuid, array $payload): array
    {
        $record = $this->requireBackupRecord($backup_uuid);
        $started_at = date('Y-m-d H:i:s');
        $this->updateJobProgressForBackup($backup_uuid, 5, 'Preparing backup.');
        $this->query(
            "UPDATE backup_records
            SET status = 'creating', started_at = " . $this->sql($started_at) . ", last_error = NULL
            WHERE backup_uuid = " . $this->sql($backup_uuid)
        );

        $components = [];
        $this->clearComponents($backup_uuid);
        $this->ensureLocalDirectory(self::local_backup_path . $backup_uuid);

        $components[] = $this->createDatabaseComponent($backup_uuid);
        $this->updateJobProgressForBackup($backup_uuid, 45, 'Database component created.');

        if ($this->configBool('app_data_enabled', true)) {
            foreach (self::APP_DATA_BUCKETS as $bucket) {
                $components[] = $this->createObjectBucketComponent($backup_uuid, $bucket);
            }
        }
        $this->updateJobProgressForBackup($backup_uuid, 80, 'Object components created.');

        $manifest = [
            'schema_version' => self::BACKUP_SCHEMA_VERSION,
            'backup_uuid' => $backup_uuid,
            'name' => $record['name'],
            'description' => $record['description'],
            'source' => $record['source'],
            'created_at' => date('c'),
            'app_identifier' => $this->appIdentifier(),
            'storage_bucket' => self::BACKUP_BUCKET,
            'storage_prefix' => self::backup_s3_prefix . $backup_uuid . '/',
            'encryption_key_id' => $this->currentEncryptionKey()['id'],
            'required_runtime_config_keys' => $this->sanitizedRuntimeConfigKeys(),
            'components' => $components,
        ];

        $manifest_json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($manifest_json === false) {
            throw new RuntimeException('Could not encode backup manifest.');
        }

        $manifest_key = self::backup_s3_prefix . $backup_uuid . '/manifest.json';
        $this->putObjectBody(self::BACKUP_BUCKET, $manifest_key, $manifest_json, 'application/json');
        $manifest_sha256 = hash('sha256', $manifest_json);

        $component_count = count(array_filter($components, static fn(array $component): bool => ($component['status'] ?? '') === 'stored'));
        $object_count = array_sum(array_map(static fn(array $component): int => (int)($component['object_count'] ?? 0), $components));
        $total_bytes = array_sum(array_map(static fn(array $component): int => (int)($component['byte_size'] ?? 0), $components));
        $completed_at = date('Y-m-d H:i:s');

        $this->query(
            "UPDATE backup_records
            SET status = 'created_unverified',
                completed_at = " . $this->sql($completed_at) . ",
                manifest_key = " . $this->sql($manifest_key) . ",
                manifest_sha256 = " . $this->sql($manifest_sha256) . ",
                encryption_key_id = " . $this->sql($manifest['encryption_key_id']) . ",
                component_count = " . (string)$component_count . ",
                object_count = " . (string)$object_count . ",
                total_bytes = " . (string)$total_bytes . "
            WHERE backup_uuid = " . $this->sql($backup_uuid)
        );

        $this->createBackupMetadata($backup_uuid, (string)$record['name'], (string)($record['description'] ?? ''));

        if ($this->configBool('verification_required', true)) {
            $verification = $this->performVerification($backup_uuid, true);
            return [
                'backup_uuid' => $backup_uuid,
                'status' => (string)($verification['job_status'] ?? 'created_unverified'),
                'message' => (string)($verification['message'] ?? 'Backup created but verification did not complete.'),
                'verification' => $verification,
            ];
        }

        $this->query(
            "UPDATE backup_records
            SET status = 'available', verified_at = " . $this->sql(date('Y-m-d H:i:s')) . "
            WHERE backup_uuid = " . $this->sql($backup_uuid)
        );

        return [
            'backup_uuid' => $backup_uuid,
            'status' => 'succeeded',
            'message' => 'Backup created and marked available without scratch verification.',
        ];
    }

    private function createDatabaseComponent(string $backup_uuid): array
    {
        global $db;

        if (!$db instanceof db) {
            throw new RuntimeException('Database connection is not available.');
        }

        $dir = self::local_backup_path . $backup_uuid;
        $this->ensureLocalDirectory($dir);
        $dump_path = $dir . '/database.sql';
        if (!$db->backupDatabase($dump_path)) {
            throw new RuntimeException('Failed to dump the database.');
        }

        $dump = file_get_contents($dump_path);
        if ($dump === false) {
            throw new RuntimeException('Could not read database dump.');
        }
        $compressed = gzencode($dump, 6);
        if ($compressed === false) {
            throw new RuntimeException('Could not compress database dump.');
        }

        $storage_key = self::backup_s3_prefix . $backup_uuid . '/components/database.sql.gz.enc';
        $aad = ['backup_uuid' => $backup_uuid, 'component' => 'database', 'storage_key' => $storage_key];
        $encrypted = $this->encryptString($compressed, $aad);
        $this->putObjectBody(self::BACKUP_BUCKET, $storage_key, $encrypted['ciphertext']);
        @unlink($dump_path);
        @unlink($dump_path . '.error.log');

        $component = [
            'component_type' => 'database',
            'logical_name' => $db->getDatabase(),
            'source_bucket' => null,
            'source_prefix' => null,
            'storage_key' => $storage_key,
            'object_count' => 1,
            'byte_size' => strlen($compressed),
            'content_sha256' => hash('sha256', $compressed),
            'encrypted_sha256' => hash('sha256', $encrypted['ciphertext']),
            'encryption' => $encrypted['metadata'],
            'aad' => $aad,
            'status' => 'stored',
        ];

        $this->insertComponent($backup_uuid, $component);
        return $component;
    }

    private function createObjectBucketComponent(string $backup_uuid, string $bucket): array
    {
        $objects = [];
        $status = 'stored';
        $error = null;

        try {
            if (!$this->bucketExists($bucket)) {
                $status = 'skipped';
                $error = 'Bucket is not available.';
            } else {
                foreach ($this->listObjectsInBucket($bucket) as $object) {
                    $source_key = (string)$object['Key'];
                    if ($source_key === '' || str_ends_with($source_key, '/')) {
                        continue;
                    }
                    $body = $this->getObjectBody($bucket, $source_key);
                    $storage_key = self::backup_s3_prefix . $backup_uuid . '/objects/' . $bucket . '/' . $this->encodeObjectKey($source_key) . '.enc';
                    $aad = ['backup_uuid' => $backup_uuid, 'component' => 'object', 'bucket' => $bucket, 'source_key' => $source_key, 'storage_key' => $storage_key];
                    $encrypted = $this->encryptString($body, $aad);
                    $this->putObjectBody(self::BACKUP_BUCKET, $storage_key, $encrypted['ciphertext']);
                    $objects[] = [
                        'source_key' => $source_key,
                        'size' => strlen($body),
                        'content_sha256' => hash('sha256', $body),
                        'storage_key' => $storage_key,
                        'encrypted_sha256' => hash('sha256', $encrypted['ciphertext']),
                        'encryption' => $encrypted['metadata'],
                        'aad' => $aad,
                    ];
                }
            }
        } catch (Throwable $throwable) {
            $status = 'failed';
            $error = $throwable->getMessage();
        }

        $manifest_key = self::backup_s3_prefix . $backup_uuid . '/components/' . $bucket . '.manifest.json';
        $bucket_manifest = json_encode([
            'bucket' => $bucket,
            'created_at' => date('c'),
            'objects' => $objects,
            'status' => $status,
            'error' => $error,
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $this->putObjectBody(self::BACKUP_BUCKET, $manifest_key, $bucket_manifest ?: '{}', 'application/json');

        $component = [
            'component_type' => 'object_bucket',
            'logical_name' => $bucket,
            'source_bucket' => $bucket,
            'source_prefix' => '',
            'storage_key' => null,
            'manifest_key' => $manifest_key,
            'object_count' => count($objects),
            'byte_size' => array_sum(array_map(static fn(array $object): int => (int)$object['size'], $objects)),
            'content_sha256' => $bucket_manifest !== false ? hash('sha256', $bucket_manifest) : null,
            'encrypted_sha256' => null,
            'encryption' => null,
            'objects' => $objects,
            'status' => $status,
            'error' => $error,
        ];

        $this->insertComponent($backup_uuid, $component);
        return $component;
    }

    private function performVerification(string $backup_uuid, bool $require_scratch_db): array
    {
        $record = $this->requireBackupRecord($backup_uuid);
        $manifest = $this->loadManifest($record);
        $failures = [];

        if (hash('sha256', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) !== (string)$record['manifest_sha256']) {
            $manifest_body = $this->getObjectBody(self::BACKUP_BUCKET, (string)$record['manifest_key']);
            if (hash('sha256', $manifest_body) !== (string)$record['manifest_sha256']) {
                $failures[] = 'Manifest hash mismatch.';
            }
        }

        foreach (($manifest['components'] ?? []) as $component) {
            if (($component['status'] ?? '') === 'skipped') {
                continue;
            }
            if (($component['status'] ?? '') !== 'stored') {
                $failures[] = 'Component failed: ' . (string)($component['logical_name'] ?? 'unknown');
                continue;
            }
            if (($component['component_type'] ?? '') === 'database') {
                $this->verifyDatabaseComponent($component, $require_scratch_db, $failures);
            }
            if (($component['component_type'] ?? '') === 'object_bucket') {
                $this->verifyObjectBucketComponent($component, $failures);
            }
        }

        if ($failures !== []) {
            $error = implode(' ', $failures);
            $this->query(
                "UPDATE backup_records
                SET status = 'created_unverified', last_error = " . $this->sql($error) . "
                WHERE backup_uuid = " . $this->sql($backup_uuid)
            );
            return [
                'backup_uuid' => $backup_uuid,
                'job_status' => 'created_unverified',
                'message' => $error,
                'failures' => $failures,
            ];
        }

        $this->query(
            "UPDATE backup_records
            SET status = 'available', verified_at = " . $this->sql(date('Y-m-d H:i:s')) . ", last_error = NULL
            WHERE backup_uuid = " . $this->sql($backup_uuid)
        );

        return [
            'backup_uuid' => $backup_uuid,
            'job_status' => 'succeeded',
            'message' => 'Backup manifest, encrypted components, and verification target were validated.',
        ];
    }

    private function verifyDatabaseComponent(array $component, bool $require_scratch_db, array &$failures): void
    {
        $storage_key = (string)($component['storage_key'] ?? '');
        if ($storage_key === '' || !$this->objectExists(self::BACKUP_BUCKET, $storage_key)) {
            $failures[] = 'Database component object is missing.';
            return;
        }

        $ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, $storage_key);
        if (hash('sha256', $ciphertext) !== (string)($component['encrypted_sha256'] ?? '')) {
            $failures[] = 'Database component encrypted hash mismatch.';
            return;
        }

        $compressed = $this->decryptString($ciphertext, $component['encryption'] ?? [], $component['aad'] ?? []);
        if (hash('sha256', $compressed) !== (string)($component['content_sha256'] ?? '')) {
            $failures[] = 'Database component plaintext hash mismatch.';
            return;
        }

        $sql = gzdecode($compressed);
        if ($sql === false || trim($sql) === '') {
            $failures[] = 'Database dump could not be decompressed.';
            return;
        }

        $verifyConfig = $this->verificationDatabaseConfig();
        if ($verifyConfig === null) {
            if ($require_scratch_db) {
                $failures[] = 'Backup verification database is not configured.';
            }
            return;
        }

        $this->importSqlIntoDatabase($sql, $verifyConfig, 'verify');
    }

    private function verifyObjectBucketComponent(array $component, array &$failures): void
    {
        foreach (($component['objects'] ?? []) as $object) {
            $storage_key = (string)($object['storage_key'] ?? '');
            if ($storage_key === '' || !$this->objectExists(self::BACKUP_BUCKET, $storage_key)) {
                $failures[] = 'Object backup is missing for ' . (string)($component['logical_name'] ?? 'bucket') . '/' . (string)($object['source_key'] ?? 'unknown');
                continue;
            }
            $ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, $storage_key);
            if (hash('sha256', $ciphertext) !== (string)($object['encrypted_sha256'] ?? '')) {
                $failures[] = 'Object backup encrypted hash mismatch for ' . (string)($object['source_key'] ?? 'unknown');
            }
        }
    }

    private function performRestore(string $backup_uuid, array $payload, ?int $actor_user_id, int $job_id): array
    {
        $record = $this->requireBackupRecord($backup_uuid);
        $manifest = $this->loadManifest($record);
        $owner = 'backup-restore:' . $job_id;
        $pre_restore_uuid = null;

        $this->query(
            "UPDATE backup_restore_audit
            SET status = 'running', started_at = " . $this->sql(date('Y-m-d H:i:s')) . "
            WHERE restore_job_id = " . (string)$job_id
        );

        application_write_freeze::freeze('Backup restore in progress.', $owner, 7200);
        try {
            $pre = $this->enqueueCreateBackup('Pre-restore backup ' . date('Y-m-d H:i:s'), 'Automatic backup before restoring ' . $backup_uuid, $actor_user_id, 'pre_restore');
            $pre_restore_uuid = (string)$pre['backup_uuid'];
            $preJob = $this->runJobById((int)$pre['job_id']);
            if ((string)($preJob['status'] ?? '') !== 'succeeded') {
                throw new RuntimeException('Pre-restore backup did not complete as a verified backup.');
            }

            foreach (($manifest['components'] ?? []) as $component) {
                if (($component['component_type'] ?? '') === 'database') {
                    $this->restoreDatabaseComponent($component);
                }
            }
            foreach (($manifest['components'] ?? []) as $component) {
                if (($component['component_type'] ?? '') === 'object_bucket') {
                    $this->restoreObjectBucketComponent($backup_uuid, $component);
                }
            }

            $this->query(
                "UPDATE backup_restore_audit
                SET status = 'succeeded',
                    pre_restore_backup_uuid = " . $this->sql($pre_restore_uuid) . ",
                    completed_at = " . $this->sql(date('Y-m-d H:i:s')) . "
                WHERE restore_job_id = " . (string)$job_id
            );
        } catch (Throwable $throwable) {
            $this->query(
                "UPDATE backup_restore_audit
                SET status = 'failed',
                    pre_restore_backup_uuid = " . $this->nullableSql($pre_restore_uuid) . ",
                    completed_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
                    error_message = " . $this->sql($throwable->getMessage()) . "
                WHERE restore_job_id = " . (string)$job_id
            );
            throw $throwable;
        } finally {
            application_write_freeze::unfreeze($owner);
        }

        return [
            'backup_uuid' => $backup_uuid,
            'pre_restore_backup_uuid' => $pre_restore_uuid,
            'status' => 'succeeded',
        ];
    }

    private function restoreDatabaseComponent(array $component): void
    {
        global $db;

        if (!$db instanceof db) {
            throw new RuntimeException('Production database connection is not available.');
        }

        $ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, (string)$component['storage_key']);
        $compressed = $this->decryptString($ciphertext, $component['encryption'] ?? [], $component['aad'] ?? []);
        $sql = gzdecode($compressed);
        if ($sql === false || trim($sql) === '') {
            throw new RuntimeException('Restore database dump could not be decompressed.');
        }

        $this->importSqlIntoDatabase($sql, [
            'host' => $db->getHost(),
            'port' => $db->getPort(),
            'user' => $db->getUsername(),
            'password' => $db->getPassword(),
            'database' => $db->getDatabase(),
        ], 'restore');
    }

    private function restoreObjectBucketComponent(string $backup_uuid, array $component): void
    {
        $bucket = (string)($component['source_bucket'] ?? '');
        if ($bucket === '') {
            return;
        }

        if (!$this->bucketExists($bucket)) {
            throw new RuntimeException('Restore target bucket is missing: ' . $bucket);
        }

        $sourceKeys = [];
        foreach (($component['objects'] ?? []) as $object) {
            $source_key = (string)($object['source_key'] ?? '');
            if ($source_key === '') {
                continue;
            }
            $sourceKeys[$source_key] = true;
            $ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, (string)$object['storage_key']);
            $body = $this->decryptString($ciphertext, $object['encryption'] ?? [], $object['aad'] ?? []);
            $this->putObjectBody($bucket, $source_key, $body);
        }

        foreach ($this->listObjectsInBucket($bucket) as $existing) {
            $key = (string)$existing['Key'];
            if ($key === '' || isset($sourceKeys[$key]) || str_starts_with($key, 'restore-quarantine/')) {
                continue;
            }

            $quarantineKey = 'restore-quarantine/' . $backup_uuid . '/' . $key;
            $body = $this->getObjectBody($bucket, $key);
            $this->putObjectBody($bucket, $quarantineKey, $body);
            $this->deleteObject($bucket, $key);
        }
    }

    public function pruneRetention(): array
    {
        $records = $this->fetchAll(
            "SELECT * FROM backup_records WHERE status IN ('available', 'created_unverified') ORDER BY completed_at DESC, created_at DESC"
        );
        $keep = $this->retentionKeepSet($records);
        $pruned = [];

        foreach ($records as $record) {
            $uuid = (string)$record['backup_uuid'];
            if (isset($keep[$uuid])) {
                continue;
            }

            $this->deletePrefix(self::BACKUP_BUCKET, (string)$record['storage_prefix']);
            $this->query(
                "UPDATE backup_records
                SET status = 'pruned', expires_at = " . $this->sql(date('Y-m-d H:i:s')) . "
                WHERE backup_uuid = " . $this->sql($uuid)
            );
            $pruned[] = $uuid;
        }

        return [
            'pruned' => $pruned,
            'kept' => array_keys($keep),
        ];
    }

    private function retentionKeepSet(array $records): array
    {
        $keep = [];
        $newestVerifiedKept = false;
        $recentCutoff = time() - ($this->configInt('retention_recent_hours', 48) * 3600);
        $dailyCutoff = time() - ($this->configInt('retention_daily_days', 30) * 86400);
        $weeklyCutoff = time() - ($this->configInt('retention_weekly_weeks', 8) * 7 * 86400);
        $monthlyCutoff = strtotime('-' . $this->configInt('retention_monthly_months', 3) . ' months');
        $daily = [];
        $weekly = [];
        $monthly = [];

        foreach ($records as $record) {
            $uuid = (string)$record['backup_uuid'];
            $created = strtotime((string)($record['completed_at'] ?: $record['created_at']));
            if ($created === false) {
                continue;
            }

            if (!$newestVerifiedKept && (string)$record['status'] === 'available') {
                $keep[$uuid] = true;
                $newestVerifiedKept = true;
                continue;
            }

            if ($created >= $recentCutoff) {
                $keep[$uuid] = true;
                continue;
            }

            if ($created >= $dailyCutoff) {
                $day = date('Y-m-d', $created);
                if (!isset($daily[$day])) {
                    $daily[$day] = true;
                    $keep[$uuid] = true;
                }
                continue;
            }

            if ($created >= $weeklyCutoff) {
                $week = date('o-W', $created);
                if (!isset($weekly[$week])) {
                    $weekly[$week] = true;
                    $keep[$uuid] = true;
                }
                continue;
            }

            if ($monthlyCutoff !== false && $created >= $monthlyCutoff) {
                $month = date('Y-m', $created);
                if (!isset($monthly[$month])) {
                    $monthly[$month] = true;
                    $keep[$uuid] = true;
                }
            }
        }

        return $keep;
    }

    private function restorePreflightChecks(array $record, array $manifest): array
    {
        $checks = [];
        $checks[] = [
            'key' => 'status',
            'status' => (string)$record['status'] === 'available' ? 'ok' : 'failed',
            'message' => 'Backup must be verified and available.',
        ];
        $checks[] = [
            'key' => 'encryption_key',
            'status' => $this->encryptionStatus()['available'] ? 'ok' : 'failed',
            'message' => 'Backup encryption key must be available.',
        ];

        foreach (($manifest['components'] ?? []) as $component) {
            if (($component['status'] ?? '') !== 'stored') {
                continue;
            }
            if (($component['storage_key'] ?? '') !== '') {
                $checks[] = [
                    'key' => 'component:' . (string)$component['logical_name'],
                    'status' => $this->objectExists(self::BACKUP_BUCKET, (string)$component['storage_key']) ? 'ok' : 'failed',
                    'message' => 'Component object exists: ' . (string)$component['logical_name'],
                ];
            }
            if (($component['manifest_key'] ?? '') !== '') {
                $checks[] = [
                    'key' => 'component_manifest:' . (string)$component['logical_name'],
                    'status' => $this->objectExists(self::BACKUP_BUCKET, (string)$component['manifest_key']) ? 'ok' : 'failed',
                    'message' => 'Component manifest exists: ' . (string)$component['logical_name'],
                ];
            }
        }

        return $checks;
    }

    private function createJob(string $type, ?string $backup_uuid, array $payload, ?int $actor_user_id, string $status = 'queued', int $progress = 0, ?string $message = null, array $result = []): int
    {
        $this->query(
            "INSERT INTO backup_jobs
                (job_type, backup_uuid, status, progress_percent, progress_message, payload_json, result_json, actor_user_id, started_at, completed_at)
            VALUES ("
            . $this->sql($type) . ', '
            . $this->nullableSql($backup_uuid) . ', '
            . $this->sql($status) . ', '
            . (string)$progress . ', '
            . $this->nullableSql($message) . ', '
            . $this->nullableSql(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ', '
            . $this->nullableSql($result === [] ? null : json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ', '
            . ($actor_user_id === null ? 'NULL' : (string)$actor_user_id) . ', '
            . ($status === 'queued' ? 'NULL, NULL' : $this->sql(date('Y-m-d H:i:s')) . ', ' . $this->sql(date('Y-m-d H:i:s')))
            . ")"
        );

        return $this->insertId();
    }

    private function completeJob(int $job_id, string $status, string $message, array $result, ?string $error): void
    {
        $this->query(
            "UPDATE backup_jobs
            SET status = " . $this->sql($status) . ",
                progress_percent = 100,
                progress_message = " . $this->sql(substr($message, 0, 255)) . ",
                result_json = " . $this->nullableSql(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ",
                error_message = " . $this->nullableSql($error) . ",
                completed_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
                lock_owner = NULL,
                locked_at = NULL
            WHERE id = " . (string)$job_id
        );
    }

    private function updateJobProgressForBackup(string $backup_uuid, int $percent, string $message): void
    {
        $this->query(
            "UPDATE backup_jobs
            SET progress_percent = " . max(0, min(99, $percent)) . ",
                progress_message = " . $this->sql(substr($message, 0, 255)) . "
            WHERE backup_uuid = " . $this->sql($backup_uuid) . " AND status = 'running'"
        );
    }

    private function insertComponent(string $backup_uuid, array $component): void
    {
        $this->query(
            "INSERT INTO backup_components
                (backup_uuid, component_type, logical_name, source_bucket, source_prefix, storage_key, manifest_key, object_count, byte_size, content_sha256, encrypted_sha256, encryption_key_id, status, error_message)
            VALUES ("
            . $this->sql($backup_uuid) . ', '
            . $this->sql((string)$component['component_type']) . ', '
            . $this->sql((string)$component['logical_name']) . ', '
            . $this->nullableSql($component['source_bucket'] ?? null) . ', '
            . $this->nullableSql($component['source_prefix'] ?? null) . ', '
            . $this->nullableSql($component['storage_key'] ?? null) . ', '
            . $this->nullableSql($component['manifest_key'] ?? null) . ', '
            . (string)(int)($component['object_count'] ?? 0) . ', '
            . (string)(int)($component['byte_size'] ?? 0) . ', '
            . $this->nullableSql($component['content_sha256'] ?? null) . ', '
            . $this->nullableSql($component['encrypted_sha256'] ?? null) . ', '
            . $this->nullableSql($component['encryption']['key_id'] ?? null) . ', '
            . $this->sql((string)($component['status'] ?? 'stored')) . ', '
            . $this->nullableSql($component['error'] ?? null)
            . ")"
        );
    }

    private function clearComponents(string $backup_uuid): void
    {
        $this->query("DELETE FROM backup_components WHERE backup_uuid = " . $this->sql($backup_uuid));
    }

    private function componentsForBackup(string $backup_uuid): array
    {
        return $this->fetchAll(
            "SELECT * FROM backup_components WHERE backup_uuid = " . $this->sql($backup_uuid) . " ORDER BY id ASC"
        );
    }

    private function requireBackupRecord(string $backup_uuid): array
    {
        $record = $this->backupRecord($backup_uuid);
        if ($record === null) {
            throw new RuntimeException('Backup not found.');
        }
        return $record;
    }

    private function backupRecord(string $backup_uuid): ?array
    {
        return $this->fetchOne(
            "SELECT * FROM backup_records WHERE backup_uuid = " . $this->sql($backup_uuid)
        );
    }

    private function loadManifest(array $record): array
    {
        $key = (string)($record['manifest_key'] ?? '');
        if ($key === '') {
            throw new RuntimeException('Backup manifest is missing.');
        }
        $body = $this->getObjectBody(self::BACKUP_BUCKET, $key);
        if ((string)($record['manifest_sha256'] ?? '') !== '' && hash('sha256', $body) !== (string)$record['manifest_sha256']) {
            throw new RuntimeException('Backup manifest hash mismatch.');
        }
        $manifest = json_decode($body, true);
        if (!is_array($manifest)) {
            throw new RuntimeException('Backup manifest is invalid JSON.');
        }
        return $manifest;
    }

    private function encryptString(string $plaintext, array $aad): array
    {
        $key = $this->currentEncryptionKey();
        $nonce = random_bytes(12);
        $tag = '';
        $aadJson = json_encode($aad, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
        $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key['key'], OPENSSL_RAW_DATA, $nonce, $tag, $aadJson);
        if ($ciphertext === false) {
            throw new RuntimeException('Could not encrypt backup component.');
        }

        return [
            'ciphertext' => $ciphertext,
            'metadata' => [
                'algorithm' => 'AES-256-GCM',
                'key_id' => $key['id'],
                'nonce' => base64_encode($nonce),
                'tag' => base64_encode($tag),
            ],
        ];
    }

    private function decryptString(string $ciphertext, array $metadata, array $aad): string
    {
        $key = $this->currentEncryptionKey();
        $nonce = base64_decode((string)($metadata['nonce'] ?? ''), true);
        $tag = base64_decode((string)($metadata['tag'] ?? ''), true);
        if ($nonce === false || $tag === false) {
            throw new RuntimeException('Backup encryption metadata is invalid.');
        }
        if (isset($metadata['key_id']) && (string)$metadata['key_id'] !== $key['id']) {
            throw new RuntimeException('Configured backup encryption key id does not match artifact key id.');
        }

        $aadJson = json_encode($aad, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
        $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key['key'], OPENSSL_RAW_DATA, $nonce, $tag, $aadJson);
        if ($plaintext === false) {
            throw new RuntimeException('Could not decrypt backup component.');
        }
        return $plaintext;
    }

    private function currentEncryptionKey(): array
    {
        $raw = $this->readEnv('BACKUP_ENCRYPTION_KEY_V1');
        if ($raw === '') {
            throw new RuntimeException('BACKUP_ENCRYPTION_KEY_V1 is not configured.');
        }

        $key = base64_decode($raw, true);
        if ($key === false || strlen($key) !== 32) {
            $hex = ctype_xdigit($raw) ? hex2bin($raw) : false;
            $key = is_string($hex) && strlen($hex) === 32 ? $hex : hash('sha256', $raw, true);
        }

        if (strlen($key) !== 32) {
            throw new RuntimeException('Backup encryption key must resolve to 32 bytes.');
        }

        $id = $this->readEnv('BACKUP_ENCRYPTION_KEY_ID');
        if ($id === '') {
            $id = 'env-v1-' . substr(hash('sha256', $key), 0, 12);
        }

        return ['id' => $id, 'key' => $key];
    }

    private function encryptionStatus(): array
    {
        try {
            $key = $this->currentEncryptionKey();
            return ['available' => true, 'key_id' => $key['id']];
        } catch (Throwable $throwable) {
            return ['available' => false, 'error' => $throwable->getMessage()];
        }
    }

    private function verificationDatabaseConfig(): ?array
    {
        $host = $this->readEnv('BACKUP_VERIFY_DB_HOST');
        $user = $this->readEnv('BACKUP_VERIFY_DB_USER');
        $database = $this->readEnv('BACKUP_VERIFY_DB_DATABASE');
        if ($host === '' || $user === '' || $database === '') {
            return null;
        }

        return [
            'host' => $host,
            'user' => $user,
            'password' => $this->readEnv('BACKUP_VERIFY_DB_PASSWORD'),
            'database' => $database,
            'port' => (int)($this->readEnv('BACKUP_VERIFY_DB_PORT') ?: '3306'),
        ];
    }

    private function importSqlIntoDatabase(string $sql, array $config, string $label): void
    {
        $dir = self::local_backup_path . 'restore-' . bin2hex(random_bytes(6));
        $this->ensureLocalDirectory($dir);
        $path = $dir . '/database.sql';
        file_put_contents($path, $sql);

        $host = escapeshellarg((string)$config['host']);
        $user = escapeshellarg((string)$config['user']);
        $database = escapeshellarg((string)$config['database']);
        $port = (int)($config['port'] ?? 3306);
        $command = "mysql -h $host -P $port -u $user $database";
        $environment = array_merge(getenv() ?: [], $_ENV);
        $environment['MYSQL_PWD'] = (string)($config['password'] ?? '');
        $descriptors = [
            0 => ['file', $path, 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];

        $process = proc_open($command, $descriptors, $pipes, null, $environment);
        if (!is_resource($process)) {
            $this->removeDirectory($dir);
            throw new RuntimeException('Could not start mysql import for backup ' . $label . '.');
        }

        $stdout = stream_get_contents($pipes[1]);
        fclose($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[2]);
        $return = proc_close($process);
        $this->removeDirectory($dir);

        if ($return !== 0) {
            throw new RuntimeException('Database import for backup ' . $label . ' failed: ' . trim((string)($stderr ?: $stdout)));
        }
    }

    private function putObjectBody(string $bucket, string $key, string $body, string $content_type = 'application/octet-stream'): void
    {
        if ($this->shouldUseLocalBackupStorage()) {
            $path = $this->localTestObjectPath($bucket, $key);
            $this->ensureLocalDirectory(dirname($path));
            file_put_contents($path, $body);
            return;
        }

        self::getS3Client()->putObject([
            'Bucket' => $bucket,
            'Key' => $key,
            'Body' => $body,
            'ContentType' => $content_type,
        ]);
    }

    private function getObjectBody(string $bucket, string $key): string
    {
        if ($this->shouldUseLocalBackupStorage()) {
            $path = $this->localTestObjectPath($bucket, $key);
            if (!is_file($path)) {
                throw new RuntimeException('Object does not exist: ' . $bucket . '/' . $key);
            }
            $body = file_get_contents($path);
            if ($body === false) {
                throw new RuntimeException('Could not read object: ' . $bucket . '/' . $key);
            }
            return $body;
        }

        $result = self::getS3Client()->getObject([
            'Bucket' => $bucket,
            'Key' => $key,
        ]);
        return (string)$result['Body'];
    }

    private function objectExists(string $bucket, string $key): bool
    {
        if ($this->shouldUseLocalBackupStorage()) {
            return is_file($this->localTestObjectPath($bucket, $key));
        }

        return self::getS3Client()->doesObjectExist($bucket, $key);
    }

    private function bucketExists(string $bucket): bool
    {
        if ($this->shouldUseLocalBackupStorage()) {
            return is_dir($this->localTestBucketDirectory($bucket));
        }

        return self::getS3Client()->doesBucketExist($bucket);
    }

    private function listObjectsInBucket(string $bucket, string $prefix = ''): array
    {
        if ($this->shouldUseLocalBackupStorage()) {
            $dir = $this->localTestBucketDirectory($bucket);
            if (!is_dir($dir)) {
                return [];
            }

            $objects = [];
            $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
            foreach ($iterator as $fileInfo) {
                if (!$fileInfo->isFile()) {
                    continue;
                }
                $relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen($dir) + 1));
                if ($prefix !== '' && !str_starts_with($relative, $prefix)) {
                    continue;
                }
                $objects[] = ['Key' => $relative, 'Size' => $fileInfo->getSize()];
            }
            return $objects;
        }

        $objects = [];
        $continuation = null;
        do {
            $params = ['Bucket' => $bucket, 'Prefix' => $prefix];
            if ($continuation !== null) {
                $params['ContinuationToken'] = $continuation;
            }
            $result = self::getS3Client()->listObjectsV2($params);
            foreach (($result['Contents'] ?? []) as $object) {
                $objects[] = $object;
            }
            $continuation = !empty($result['IsTruncated']) ? (string)($result['NextContinuationToken'] ?? '') : null;
        } while ($continuation !== null && $continuation !== '');

        return $objects;
    }

    private function deleteObject(string $bucket, string $key): void
    {
        if ($this->shouldUseLocalBackupStorage()) {
            $path = $this->localTestObjectPath($bucket, $key);
            if (is_file($path)) {
                @unlink($path);
            }
            return;
        }

        self::getS3Client()->deleteObject(['Bucket' => $bucket, 'Key' => $key]);
    }

    private function deletePrefix(string $bucket, string $prefix): void
    {
        foreach ($this->listObjectsInBucket($bucket, $prefix) as $object) {
            $this->deleteObject($bucket, (string)$object['Key']);
        }
    }

    private function shouldUseLocalBackupStorage(): bool
    {
        return getenv('RUN_API_TESTS') === '1'
            && (trim((string)$this->getEndpoint()) === ''
                || trim((string)$this->getAccessKey()) === ''
                || trim((string)$this->getSecretKey()) === '');
    }

    private function localTestBucketDirectory(string $bucket): string
    {
        $bucket = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $bucket) ?: 'default';
        $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
            . DIRECTORY_SEPARATOR
            . 'truckwash-test-object-store'
            . DIRECTORY_SEPARATOR
            . $bucket;
        if (!is_dir($directory)) {
            mkdir($directory, 0777, true);
        }
        return $directory;
    }

    private function localTestObjectPath(string $bucket, string $key): string
    {
        $normalizedKey = str_replace('\\', '/', $key);
        $normalizedKey = preg_replace('#(^|/)\\.\\.(?=/|$)#', '', $normalizedKey) ?? $normalizedKey;
        $normalizedKey = ltrim($normalizedKey, '/');
        if ($normalizedKey === '') {
            $normalizedKey = 'object';
        }
        return $this->localTestBucketDirectory($bucket) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalizedKey);
    }

    private function legacyBackups(): array
    {
        $legacy = [];
        try {
            foreach ($this->listObjectsInBucket(self::BACKUP_BUCKET, self::metadata_s3_prefix) as $object) {
                $key = (string)$object['Key'];
                if (!str_ends_with($key, '.json')) {
                    continue;
                }
                $uuid = str_replace([self::metadata_s3_prefix . 'backup_', '.json'], '', $key);
                if ($this->backupRecord($uuid) !== null) {
                    continue;
                }
                $metadata = json_decode($this->getObjectBody(self::BACKUP_BUCKET, $key), true);
                if (is_array($metadata)) {
                    $legacy[] = $metadata + ['backup_uuid' => $uuid, 'status' => 'legacy_unverified'];
                }
            }
        } catch (Throwable) {
            return [];
        }
        return $legacy;
    }

    private function appIdentifier(): array
    {
        return [
            'host' => gethostname(),
            'wd' => defined('WD') ? WD : null,
            'git_sha' => $this->readEnv('GIT_COMMIT_SHA') ?: $this->readEnv('SOURCE_COMMIT'),
        ];
    }

    private function sanitizedRuntimeConfigKeys(): array
    {
        $keys = array_keys($_ENV + $_SERVER);
        $required = array_values(array_filter($keys, static function (string $key): bool {
            return str_starts_with($key, 'CONFIG_DB_')
                || str_starts_with($key, 'MINIO_')
                || str_starts_with($key, 'BACKUP_');
        }));
        sort($required, SORT_STRING);
        return $required;
    }

    private function configBool(string $variable, bool $default): bool
    {
        $config = $this->configMap();
        if (!array_key_exists($variable, $config)) {
            return $default;
        }

        $value = $config[$variable];
        if (is_bool($value)) {
            return $value;
        }
        if (is_int($value) || is_float($value)) {
            return (int)$value !== 0;
        }
        if (is_string($value)) {
            $normalized = strtolower(trim($value));
            if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
                return true;
            }
            if (in_array($normalized, ['0', 'false', 'no', 'off', ''], true)) {
                return false;
            }
        }

        return $default;
    }

    private function configInt(string $variable, int $default): int
    {
        $config = $this->configMap();
        if (!array_key_exists($variable, $config) || !is_numeric($config[$variable])) {
            return $default;
        }
        return max(0, (int)$config[$variable]);
    }

    private function configMap(): array
    {
        $map = [];
        foreach ($this->config->getConfig() as $row) {
            $map[(string)$row['variable']] = $row['value'];
        }
        return $map;
    }

    private function encodeObjectKey(string $key): string
    {
        return rtrim(strtr(base64_encode($key), '+/', '-_'), '=');
    }

    private function readEnv(string $key): string
    {
        $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
        if ($value === false || $value === null) {
            return '';
        }
        return trim((string)$value);
    }

    private static function uuid(): string
    {
        return bin2hex(random_bytes(16));
    }

    private function ensureLocalDirectory(string $path): void
    {
        if (!is_dir($path) && !mkdir($path, 0770, true) && !is_dir($path)) {
            throw new RuntimeException('Could not create directory: ' . $path);
        }
    }

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

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($iterator as $fileInfo) {
            if ($fileInfo->isDir()) {
                @rmdir($fileInfo->getPathname());
            } else {
                @unlink($fileInfo->getPathname());
            }
        }
        @rmdir($path);
    }

    private function query(string $sql): void
    {
        $this->db()->query($sql);
    }

    private function fetchOne(string $sql): ?array
    {
        $result = $this->db()->query($sql);
        if (!is_object($result)) {
            return null;
        }
        $row = $result->fetch_assoc();
        return is_array($row) ? $row : null;
    }

    private function fetchAll(string $sql): array
    {
        $result = $this->db()->query($sql);
        if (!is_object($result)) {
            return [];
        }
        return $result->fetch_all(MYSQLI_ASSOC);
    }

    private function affectedRows(): int
    {
        return (int)$this->db()->conn()->affected_rows;
    }

    private function insertId(): int
    {
        return (int)$this->db()->insert_id();
    }

    private function db(): db
    {
        global $db;
        if (!$db instanceof db) {
            throw new RuntimeException('Database connection is not available.');
        }
        return $db;
    }

    private function sql(string|int|float|bool|null $value): string
    {
        return "'" . $this->db()->escape_string((string)$value) . "'";
    }

    private function nullableSql(mixed $value): string
    {
        if ($value === null || $value === '') {
            return 'NULL';
        }
        return $this->sql((string)$value);
    }

    private function decodeJson(mixed $value): array
    {
        if (!is_string($value) || trim($value) === '') {
            return [];
        }
        $decoded = json_decode($value, true);
        return is_array($decoded) ? $decoded : [];
    }
}
