<?php

declare(strict_types=1);

final class BackupService
{
    private const FORMAT_VERSION = 1;

    public static function create(string $reason = 'manual'): array
    {
        if (!class_exists(ZipArchive::class)) {
            throw new RuntimeException(t('service.backup.zip_missing'));
        }
        $root = dirname(__DIR__, 2);
        $backupDir = $root . '/storage/backups';
        if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) {
            throw new RuntimeException(t('service.backup.directory_failed'));
        }
        $stamp = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('Ymd-His');
        $suffix = $reason === 'pre-restore' ? '-avant-restauration' : '';
        $target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip';
        // Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas
        // y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au
        // répertoire temporaire système ; seule l'archive finale va dans backups/.
        $snapshot = tempnam(sys_get_temp_dir(), 'globinours-snapshot-');
        if ($snapshot === false) {
            throw new RuntimeException(t('service.backup.prepare_failed'));
        }
        @unlink($snapshot); // VACUUM INTO exige que la destination n'existe pas.

        $pdo = DB::pdo();
        $pdo->exec('PRAGMA wal_checkpoint(FULL)');
        $pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));

        $zip = new ZipArchive();
        if ($zip->open($target, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
            @unlink($snapshot);
            throw new RuntimeException(t('service.backup.create_failed'));
        }
        $files = [];
        try {
            self::addFile($zip, $snapshot, 'database/refuge.sqlite', $files);
            if (getenv('GLOBINOURS_BACKUP_DATA_ONLY') !== '1') {
                self::addTree($zip, $root . '/public/media', 'public/media', $files);
                self::addTree($zip, $root . '/data/association', 'data/association', $files);
                self::addTree($zip, $root . '/data/grants', 'data/grants', $files);
                self::addTree($zip, $root . '/data/medical-documents', 'data/medical-documents', $files);
                self::addTree($zip, $root . '/data/private-media', 'data/private-media', $files);
            }
            $manifest = [
                'application' => 'Globinours',
                'format_version' => self::FORMAT_VERSION,
                'created_at' => (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format(
                    DateTimeInterface::ATOM,
                ),
                'reason' => $reason,
                'php_version' => PHP_VERSION,
                'files' => $files,
            ];
            $zip->addFromString(
                'manifest.json',
                json_encode(
                    $manifest,
                    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
                ),
            );
        } finally {
            $zip->close();
            @unlink($snapshot);
        }
        @chmod($target, 0600);
        self::prune($backupDir);
        return self::describe($target);
    }

    public static function list(): array
    {
        $dir = dirname(__DIR__, 2) . '/storage/backups';
        $files = glob($dir . '/globinours-*.zip') ?: [];
        usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
        return array_map([self::class, 'describe'], $files);
    }

    public static function path(string $name): ?string
    {
        if (
            $name !== basename($name) ||
            !preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name)
        ) {
            return null;
        }
        $path = dirname(__DIR__, 2) . '/storage/backups/' . $name;
        return is_file($path) ? $path : null;
    }

    public static function delete(string $name): void
    {
        $path = self::path($name);
        if (!$path) {
            throw new RuntimeException(t('service.backup.not_found'));
        }
        if (!unlink($path)) {
            throw new RuntimeException(t('service.backup.delete_failed'));
        }
    }

    public static function verify(string $name): array
    {
        $path = self::path($name);
        if (!$path) {
            throw new RuntimeException(t('service.backup.not_found'));
        }
        $zip = new ZipArchive();
        if ($zip->open($path) !== true) {
            throw new RuntimeException(t('service.backup.unreadable'));
        }
        try {
            $raw = $zip->getFromName('manifest.json');
            if ($raw === false) {
                throw new RuntimeException(t('service.backup.manifest_missing'));
            }
            $manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
            if (
                ($manifest['application'] ?? '') !== 'Globinours' ||
                (int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
            ) {
                throw new RuntimeException(t('service.backup.incompatible'));
            }
            foreach ($manifest['files'] ?? [] as $file => $meta) {
                $stream = $zip->getStream((string) $file);
                if (!$stream) {
                    throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
                }
                $context = hash_init('sha256');
                hash_update_stream($context, $stream);
                fclose($stream);
                if (!hash_equals((string) ($meta['sha256'] ?? ''), hash_final($context))) {
                    throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
                }
            }
            $tmp = tempnam(sys_get_temp_dir(), 'globinours-verify-');
            if ($tmp === false) {
                throw new RuntimeException(t('service.backup.prepare_failed'));
            }
            $dbBytes = $zip->getFromName('database/refuge.sqlite');
            if ($dbBytes === false) {
                @unlink($tmp);
                throw new RuntimeException(t('service.backup.database_missing'));
            }
            file_put_contents($tmp, $dbBytes);
            self::validateDatabase($tmp);
            @unlink($tmp);
            return [
                'valid' => true,
                'name' => $name,
                'created_at' => (string) ($manifest['created_at'] ?? ''),
                'files' => count($manifest['files'] ?? []),
            ];
        } finally {
            $zip->close();
        }
    }

    public static function runScheduled(bool $force = false): array
    {
        if (AppSettings::get('backup_schedule_enabled') !== '1' && !$force) {
            return ['created' => false, 'reason' => 'disabled'];
        }
        $frequency = AppSettings::get('backup_schedule_frequency');
        $last = AppSettings::get('backup_last_run_at');
        $minimum = $frequency === 'weekly' ? 6 * 86400 : 20 * 3600;
        if (!$force && $last !== '' && time() - (strtotime($last) ?: 0) < $minimum) {
            return ['created' => false, 'reason' => 'not_due'];
        }
        $backup = self::create('scheduled');
        $verification = self::verify($backup['name']);
        AppSettings::save(
            ['backup_last_run_at' => date('Y-m-d H:i:s'), 'backup_last_verified_at' => date('Y-m-d H:i:s')],
            null,
        );
        return ['created' => true, 'backup' => $backup, 'verification' => $verification];
    }

    public static function restore(string $uploadedPath): array
    {
        if (!is_file($uploadedPath)) {
            throw new RuntimeException(t('service.backup.restore_missing'));
        }
        $root = dirname(__DIR__, 2);
        $stage = $root . '/storage/restore-stage-' . bin2hex(random_bytes(6));
        if (!mkdir($stage, 0700, true)) {
            throw new RuntimeException(t('service.backup.prepare_failed'));
        }
        $zip = new ZipArchive();
        if ($zip->open($uploadedPath) !== true) {
            self::removeTree($stage);
            throw new RuntimeException(t('service.backup.unreadable'));
        }
        try {
            if ($zip->numFiles > 10000) {
                throw new RuntimeException(t('service.backup.too_many_files'));
            }
            $total = 0;
            $seen = [];
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $stat = $zip->statIndex($i);
                $name = (string) ($stat['name'] ?? '');
                if (isset($seen[$name])) {
                    throw new RuntimeException(t('service.backup.unsafe_path'));
                }
                $seen[$name] = true;
                $total += (int) ($stat['size'] ?? 0);
                if ($total > 2_000_000_000) {
                    throw new RuntimeException(t('service.backup.too_large'));
                }
                if (
                    $name === '' ||
                    str_contains($name, "\0") ||
                    str_starts_with($name, '/') ||
                    preg_match('#(^|/)\.\.(/|$)#', $name)
                ) {
                    throw new RuntimeException(t('service.backup.unsafe_path'));
                }
            }
            $manifestRaw = $zip->getFromName('manifest.json');
            if ($manifestRaw === false) {
                throw new RuntimeException(t('service.backup.manifest_missing'));
            }
            $manifest = json_decode($manifestRaw, true, 512, JSON_THROW_ON_ERROR);
            if (
                ($manifest['application'] ?? '') !== 'Globinours' ||
                (int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
            ) {
                throw new RuntimeException(t('service.backup.incompatible'));
            }
            $manifestFiles = array_keys(is_array($manifest['files'] ?? null) ? $manifest['files'] : []);
            $allowed = array_fill_keys(array_merge(['manifest.json'], $manifestFiles), true);
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $stat = $zip->statIndex($i);
                $name = (string) ($stat['name'] ?? '');
                if (str_ends_with($name, '/')) {
                    continue;
                }
                if (!isset($allowed[$name])) {
                    throw new RuntimeException(t('service.backup.manifest_path'));
                }
                if (method_exists($zip, 'getExternalAttributesIndex')) {
                    $opsys = 0;
                    $attr = 0;
                    if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && (($attr >> 16) & 0170000) === 0120000) {
                        throw new RuntimeException(t('service.backup.unsafe_path'));
                    }
                }
            }
            $written = 0;
            foreach ($manifestFiles as $name) {
                $source = $zip->getStream($name);
                if (!$source) {
                    throw new RuntimeException(t('service.backup.extract_failed'));
                }
                $target = $stage . '/' . $name;
                $dir = dirname($target);
                if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
                    fclose($source);
                    throw new RuntimeException(t('service.backup.extract_failed'));
                }
                $dest = fopen($target, 'xb');
                if (!$dest) {
                    fclose($source);
                    throw new RuntimeException(t('service.backup.extract_failed'));
                }
                while (!feof($source)) {
                    $chunk = fread($source, 1048576);
                    if ($chunk === false) {
                        fclose($source);
                        fclose($dest);
                        throw new RuntimeException(t('service.backup.extract_failed'));
                    }
                    $written += strlen($chunk);
                    if ($written > 2_000_000_000) {
                        fclose($source);
                        fclose($dest);
                        throw new RuntimeException(t('service.backup.too_large'));
                    }
                    fwrite($dest, $chunk);
                }
                fclose($source);
                fclose($dest);
                @chmod($target, 0600);
            }
        } finally {
            $zip->close();
        }

        try {
            foreach ($manifest['files'] ?? [] as $name => $metadata) {
                if (
                    !is_string($name) ||
                    !preg_match(
                        '#^(database/refuge\.sqlite|public/media/.+|data/grants/.+|data/medical-documents/.+|data/private-media/.+|data/documents/.+|data/association/logo\.(?:png|jpg|webp)|data/(?:Logo_Globinours_64\.png|Logo_Globinours_maxi\.png|favicon\.ico))$#',
                        $name,
                    )
                ) {
                    throw new RuntimeException(t('service.backup.manifest_path'));
                }
                $restored = $stage . '/' . $name;
                $hash = (string) ($metadata['sha256'] ?? '');
                if (!is_file($restored) || $hash === '' || !hash_equals($hash, hash_file('sha256', $restored))) {
                    throw new RuntimeException(t('service.backup.file_altered', ['name' => $name]));
                }
            }
            $database = $stage . '/database/refuge.sqlite';
            if (!is_file($database)) {
                throw new RuntimeException(t('service.backup.database_missing'));
            }
            $expected = $manifest['files']['database/refuge.sqlite']['sha256'] ?? '';
            if ($expected === '' || !hash_equals($expected, hash_file('sha256', $database))) {
                throw new RuntimeException(t('service.backup.database_hash'));
            }
            self::validateDatabase($database);
            $emergency = self::create('pre-restore');
            self::replaceInstallation($stage, $root);
            return ['emergency' => $emergency, 'created_at' => (string) ($manifest['created_at'] ?? '')];
        } finally {
            self::removeTree($stage);
        }
    }

    private static function validateDatabase(string $path): void
    {
        $db = new SQLite3($path, SQLITE3_OPEN_READONLY);
        try {
            if ($db->querySingle('PRAGMA integrity_check') !== 'ok') {
                throw new RuntimeException(t('service.backup.database_corrupt'));
            }
            foreach (['animals', 'users', 'animal_history'] as $table) {
                $safe = SQLite3::escapeString($table);
                if (!(int) $db->querySingle("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$safe'")) {
                    throw new RuntimeException(t('service.backup.table_missing', ['table' => $table]));
                }
            }
        } finally {
            $db->close();
        }
    }

    private static function replaceInstallation(string $stage, string $root): void
    {
        $rollback = $root . '/storage/restore-rollback-' . bin2hex(random_bytes(5));
        mkdir($rollback, 0700, true);
        $targets = ['public/media', 'data/association', 'data/grants', 'data/medical-documents', 'data/private-media'];
        $moved = [];
        try {
            foreach ($targets as $relative) {
                $live = $root . '/' . $relative;
                $saved = $rollback . '/' . str_replace('/', '__', $relative);
                $incoming = $stage . '/' . $relative;
                if (is_dir($live) && !rename($live, $saved)) {
                    throw new RuntimeException(t('service.backup.secure_failed', ['path' => $relative]));
                }
                if (is_dir($saved)) {
                    $moved[$relative] = $saved;
                }
                if (is_dir($incoming) && !rename($incoming, $live)) {
                    throw new RuntimeException(t('service.backup.restore_path_failed', ['path' => $relative]));
                }
                if (!is_dir($live)) {
                    mkdir($live, 0775, true);
                }
            }
            $pdo = DB::pdo();
            $pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');
            unset($pdo);
            DB::close();
            foreach (['-wal', '-shm'] as $suffix) {
                @unlink($root . '/data/refuge.sqlite' . $suffix);
            }
            if (!rename($root . '/data/refuge.sqlite', $rollback . '/refuge.sqlite')) {
                throw new RuntimeException(t('service.backup.secure_database_failed'));
            }
            if (!copy($stage . '/database/refuge.sqlite', $root . '/data/refuge.sqlite')) {
                throw new RuntimeException(t('service.backup.restore_database_failed'));
            }
            @chmod($root . '/data/refuge.sqlite', 0600);
            self::removeTree($rollback);
        } catch (Throwable $e) {
            DB::close();
            if (is_file($rollback . '/refuge.sqlite')) {
                @unlink($root . '/data/refuge.sqlite');
                @rename($rollback . '/refuge.sqlite', $root . '/data/refuge.sqlite');
            }
            foreach ($targets as $relative) {
                $live = $root . '/' . $relative;
                $saved = $rollback . '/' . str_replace('/', '__', $relative);
                if (is_dir($saved)) {
                    self::removeTree($live);
                    @rename($saved, $live);
                }
            }
            self::removeTree($rollback);
            throw $e;
        }
    }

    private static function addTree(ZipArchive $zip, string $dir, string $prefix, array &$files): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
        foreach ($iterator as $file) {
            if ($file->isFile() && !$file->isLink()) {
                self::addFile(
                    $zip,
                    $file->getPathname(),
                    $prefix . '/' . substr($file->getPathname(), strlen($dir) + 1),
                    $files,
                );
            }
        }
    }

    private static function addFile(ZipArchive $zip, string $source, string $name, array &$files): void
    {
        if (!is_file($source)) {
            return;
        }
        $name = str_replace('\\', '/', $name);
        if (!$zip->addFile($source, $name)) {
            throw new RuntimeException(t('service.backup.add_failed', ['name' => $name]));
        }
        $files[$name] = ['size' => filesize($source), 'sha256' => hash_file('sha256', $source)];
    }

    private static function describe(string $path): array
    {
        $modified = (new DateTimeImmutable('@' . (filemtime($path) ?: time())))->setTimezone(
            new DateTimeZone('Europe/Paris'),
        );
        return ['name' => basename($path), 'size' => filesize($path) ?: 0, 'date' => $modified->format('d/m/Y H:i:s')];
    }

    private static function prune(string $dir): void
    {
        $files = glob($dir . '/globinours-*.zip') ?: [];
        usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
        $keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10;
        foreach (array_slice($files, $keep) as $file) {
            @unlink($file);
        }
    }

    private static function removeTree(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST,
        );
        foreach ($it as $item) {
            $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
        }
        @rmdir($dir);
    }
}
