<?php

declare(strict_types=1);

final class SystemBackupService
{
    public function __construct(private PDO $pdo)
    {
    }

    public function create(string $type = 'complete', ?string $password = null): array
    {
        if (!in_array($type, ['database', 'files', 'complete'], true)) {
            throw new InvalidArgumentException('Unsupported backup type.');
        }
        $directory = backup_directory();
        $stamp = date('Ymd_His');
        $random = bin2hex(random_bytes(4));
        $temporary = $directory . '/.build_' . $stamp . '_' . $random;
        if (!mkdir($temporary, 0770, true) && !is_dir($temporary)) {
            throw new RuntimeException('Unable to create the temporary backup directory.');
        }

        try {
            $manifest = [
                'format' => 'DentalCare System Backup',
                'format_version' => 1,
                'backup_type' => $type,
                'application_version' => app_release_version(),
                'installed_version' => installed_application_version(),
                'created_at' => date(DATE_ATOM),
                'created_by' => [
                    'id' => (int) (current_user()['id'] ?? 0),
                    'name' => (string) (current_user()['name'] ?? ''),
                    'email' => (string) (current_user()['email'] ?? ''),
                ],
                'host' => dental_current_host(),
                'database' => [
                    'name' => (string) app_config('database.name'),
                    'prefix' => (string) app_config('database.prefix', 'dent_'),
                    'included' => in_array($type, ['database', 'complete'], true),
                ],
                'files' => [],
                'warnings' => [
                    'This package may contain database credentials, SMTP credentials, patient records, clinical files, and license information.',
                    'Store it outside the public web directory and restrict access to authorized recovery personnel.',
                ],
            ];

            if (in_array($type, ['database', 'complete'], true)) {
                $databasePath = $temporary . '/database.sql';
                $this->writeDatabaseDump($databasePath);
                $manifest['files'][] = $this->fileManifestEntry($databasePath, 'database.sql', 'database');
            }

            if (in_array($type, ['files', 'complete'], true)) {
                foreach ($this->backupSources() as $source) {
                    $absolute = $source['path'];
                    $target = $source['target'];
                    if (is_file($absolute)) {
                        $this->copyFile($absolute, $temporary . '/' . $target);
                        $manifest['files'][] = $this->fileManifestEntry($temporary . '/' . $target, $target, $source['category']);
                    } elseif (is_dir($absolute)) {
                        foreach ($this->walkFiles($absolute) as $filePath) {
                            $relative = ltrim(str_replace('\\', '/', substr($filePath, strlen($absolute))), '/');
                            if ($relative === '' || $this->shouldSkipFile($relative)) continue;
                            $archivePath = rtrim($target, '/') . '/' . $relative;
                            $this->copyFile($filePath, $temporary . '/' . $archivePath);
                            $manifest['files'][] = $this->fileManifestEntry($temporary . '/' . $archivePath, $archivePath, $source['category']);
                        }
                    }
                }
            }

            $manifest['file_count'] = count($manifest['files']);
            $manifest['uncompressed_bytes'] = array_sum(array_column($manifest['files'], 'size'));
            $manifestPath = $temporary . '/manifest.json';
            file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

            $checksums = [];
            foreach ($manifest['files'] as $file) {
                $checksums[] = $file['sha256'] . '  ' . $file['path'];
            }
            $checksums[] = hash_file('sha256', $manifestPath) . '  manifest.json';
            file_put_contents($temporary . '/checksums.sha256', implode("\n", $checksums) . "\n");
            file_put_contents($temporary . '/RESTORE_README.txt', $this->restoreReadme($type));

            if ($type === 'database') {
                $fileName = 'dentalcare_database_' . $stamp . '_' . $random . '.sql';
                $finalPath = $directory . '/' . $fileName;
                if (!rename($temporary . '/database.sql', $finalPath)) {
                    throw new RuntimeException('Unable to finalize the database backup.');
                }
                $encrypted = 0;
            } else {
                if (!class_exists('ZipArchive')) {
                    throw new RuntimeException('PHP ZIP extension is required to create files-only and complete backups.');
                }
                $fileName = 'dentalcare_' . $type . '_' . $stamp . '_' . $random . '.zip';
                $finalPath = $directory . '/' . $fileName;
                $zip = new ZipArchive();
                if ($zip->open($finalPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
                    throw new RuntimeException('Unable to create the backup archive.');
                }
                $password = trim((string) $password);
                $encrypted = 0;
                if ($password !== '') {
                    if (!method_exists($zip, 'setPassword') || !method_exists($zip, 'setEncryptionName')) {
                        $zip->close();
                        @unlink($finalPath);
                        throw new RuntimeException('This server does not support AES ZIP encryption. Leave the password blank or enable a compatible PHP ZIP build.');
                    }
                    $zip->setPassword($password);
                    $encrypted = 1;
                }
                foreach ($this->walkFiles($temporary) as $filePath) {
                    $relative = ltrim(str_replace('\\', '/', substr($filePath, strlen($temporary))), '/');
                    if ($relative === '') continue;
                    $zip->addFile($filePath, $relative);
                    if ($encrypted) {
                        $zip->setEncryptionName($relative, ZipArchive::EM_AES_256, $password);
                    }
                }
                if (!$zip->close()) {
                    throw new RuntimeException('Unable to finalize the backup archive.');
                }
            }

            @chmod($finalPath, 0640);
            $size = (int) (filesize($finalPath) ?: 0);
            $checksum = hash_file('sha256', $finalPath) ?: '';
            $this->pdo->prepare(
                'INSERT INTO `' . table('backup_logs') . '` '
                . '(file_name,file_size,status,created_by,notes,created_at,backup_type,checksum_sha256,file_count,is_encrypted,verified_at) '
                . 'VALUES (?,? ,"created",?,?,NOW(),?,?,?,?,NULL)'
            )->execute([
                $fileName,
                $size,
                (int) (current_user()['id'] ?? 0),
                ucfirst($type) . ' system backup',
                $type,
                $checksum,
                (int) $manifest['file_count'],
                $encrypted,
            ]);
            $backupId = (int) $this->pdo->lastInsertId();
            audit('system_backup_created', 'backup', $backupId, [
                'file' => $fileName,
                'type' => $type,
                'size' => $size,
                'file_count' => (int) $manifest['file_count'],
                'encrypted' => (bool) $encrypted,
                'sha256' => $checksum,
            ]);

            return [
                'id' => $backupId,
                'file_name' => $fileName,
                'file_size' => $size,
                'checksum' => $checksum,
                'file_count' => (int) $manifest['file_count'],
                'encrypted' => (bool) $encrypted,
                'backup_type' => $type,
            ];
        } finally {
            $this->deleteDirectory($temporary);
        }
    }

    public function verify(array $backup, ?string $password = null): array
    {
        $file = basename((string) ($backup['file_name'] ?? ''));
        $path = backup_directory() . '/' . $file;
        if ($file === '' || !is_file($path)) throw new RuntimeException('The backup file is missing.');

        $recordedSize = (int) ($backup['file_size'] ?? 0);
        $actualSize = (int) (filesize($path) ?: 0);
        if ($recordedSize > 0 && $recordedSize !== $actualSize) {
            throw new RuntimeException('Backup size verification failed. The file may be incomplete or modified.');
        }

        $expected = trim((string) ($backup['checksum_sha256'] ?? ''));
        $actual = hash_file('sha256', $path) ?: '';
        if ($expected !== '' && !hash_equals($expected, $actual)) {
            throw new RuntimeException('Backup checksum verification failed. The file may be incomplete or modified.');
        }

        $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
        $evidence = [
            'verified_at' => date(DATE_ATOM),
            'outer_sha256' => $actual,
            'outer_size' => $actualSize,
            'format' => $extension,
            'deep_verified' => false,
            'file_count' => 0,
            'format_version' => null,
        ];

        if ($extension === 'zip') {
            $evidence = array_merge($evidence, $this->verifyZipBackup($path, $backup, $password));
        } elseif ($extension === 'sql') {
            $evidence = array_merge($evidence, $this->verifySqlBackup($path));
        } elseif ($extension === 'gz') {
            $evidence = array_merge($evidence, $this->verifyGzipSqlBackup($path));
        } else {
            throw new RuntimeException('Unsupported backup format.');
        }

        if (empty($evidence['deep_verified'])) {
            throw new RuntimeException('The backup package could not be fully verified.');
        }

        $sets = ['verified_at=NOW()'];
        $params = [];
        if (schema_column_exists(table('backup_logs'), 'verification_evidence_json')) {
            $sets[] = 'verification_evidence_json=?';
            $params[] = json_encode($evidence, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        }
        if (schema_column_exists(table('backup_logs'), 'verification_file_count')) {
            $sets[] = 'verification_file_count=?';
            $params[] = (int) ($evidence['file_count'] ?? 0);
        }
        if (schema_column_exists(table('backup_logs'), 'verification_format_version')) {
            $sets[] = 'verification_format_version=?';
            $params[] = isset($evidence['format_version']) ? (int) $evidence['format_version'] : null;
        }
        $params[] = (int) ($backup['id'] ?? 0);
        $this->pdo->prepare('UPDATE `' . table('backup_logs') . '` SET ' . implode(',', $sets) . ' WHERE id=?')->execute($params);

        audit('system_backup_verified', 'backup', (int) ($backup['id'] ?? 0), [
            'sha256' => $actual,
            'deep_verified' => true,
            'format' => (string) ($evidence['format'] ?? $extension),
            'format_version' => $evidence['format_version'] ?? null,
            'file_count' => (int) ($evidence['file_count'] ?? 0),
        ]);
        return $evidence;
    }

    private function verifyZipBackup(string $path, array $backup, ?string $password): array
    {
        if (!class_exists('ZipArchive')) throw new RuntimeException('PHP ZIP extension is required to verify this backup.');
        $zip = new ZipArchive();
        if ($zip->open($path) !== true) throw new RuntimeException('The ZIP archive cannot be opened.');
        try {
            $password = trim((string) $password);
            if (!empty($backup['is_encrypted'])) {
                if ($password === '') throw new RuntimeException('Enter the ZIP password to verify this encrypted backup.');
                if (!$zip->setPassword($password)) throw new RuntimeException('The ZIP password could not be applied.');
            }

            $manifestRaw = $zip->getFromName('manifest.json');
            if (!is_string($manifestRaw) || $manifestRaw === '') {
                throw new RuntimeException(!empty($backup['is_encrypted'])
                    ? 'The manifest cannot be read. Check the ZIP password and archive integrity.'
                    : 'The archive does not contain a readable DentalCare manifest.');
            }
            $manifest = json_decode($manifestRaw, true);
            if (!is_array($manifest)
                || (string) ($manifest['format'] ?? '') !== 'DentalCare System Backup'
                || (int) ($manifest['format_version'] ?? 0) < 1
                || !isset($manifest['files'])
                || !is_array($manifest['files'])) {
                throw new RuntimeException('The archive manifest is invalid or is not a DentalCare system backup.');
            }
            if (count($manifest['files']) > 20000) throw new RuntimeException('The archive manifest contains an unreasonable number of files.');

            $checksumsRaw = $zip->getFromName('checksums.sha256');
            if (!is_string($checksumsRaw) || trim($checksumsRaw) === '') {
                throw new RuntimeException('The archive does not contain readable internal checksums.');
            }
            $checksumMap = $this->parseChecksumList($checksumsRaw);
            $manifestHash = hash('sha256', $manifestRaw);
            if (!isset($checksumMap['manifest.json']) || !hash_equals($checksumMap['manifest.json'], $manifestHash)) {
                throw new RuntimeException('The internal manifest checksum does not match.');
            }

            $verified = 0;
            $databaseSqlFound = false;
            foreach ($manifest['files'] as $entry) {
                if (!is_array($entry)) throw new RuntimeException('The archive manifest contains an invalid file entry.');
                $name = str_replace('\\', '/', trim((string) ($entry['path'] ?? '')));
                $expectedHash = strtolower(trim((string) ($entry['sha256'] ?? '')));
                $expectedSize = (int) ($entry['size'] ?? -1);
                if (!$this->isSafeArchivePath($name) || !preg_match('/^[a-f0-9]{64}$/', $expectedHash)) {
                    throw new RuntimeException('The archive manifest contains an unsafe or invalid file entry.');
                }
                $index = $zip->locateName($name, ZipArchive::FL_NOCASE);
                if ($index === false) throw new RuntimeException('Backup file missing from archive: ' . $name);
                $stat = $zip->statIndex($index);
                if (!is_array($stat) || ($expectedSize >= 0 && (int) ($stat['size'] ?? -1) !== $expectedSize)) {
                    throw new RuntimeException('Backup file size mismatch: ' . $name);
                }
                $actualHash = $this->hashZipEntry($zip, $name);
                if (!hash_equals($expectedHash, $actualHash)) {
                    throw new RuntimeException('Backup file checksum mismatch: ' . $name);
                }
                if (!isset($checksumMap[$name]) || !hash_equals($checksumMap[$name], $actualHash)) {
                    throw new RuntimeException('Internal checksum list mismatch: ' . $name);
                }
                if ($name === 'database.sql') $databaseSqlFound = true;
                $verified++;
            }

            $declaredCount = (int) ($manifest['file_count'] ?? count($manifest['files']));
            if ($declaredCount !== $verified) throw new RuntimeException('The archive file count does not match its manifest.');
            $type = (string) ($manifest['backup_type'] ?? ($backup['backup_type'] ?? ''));
            if (in_array($type, ['database', 'complete'], true) && !$databaseSqlFound) {
                throw new RuntimeException('This backup type requires database.sql, but it is missing.');
            }
            if ($databaseSqlFound) {
                // The complete SQL entry can be hundreds of megabytes after
                // decompression. Reading it through getFromName() duplicates the
                // whole entry in PHP memory and can exhaust common 128 MB hosting
                // limits even though its checksum was already verified by stream.
                $sqlSample = $this->readZipEntrySample($zip, 'database.sql', 1048576);
                if (!$this->sqlDumpLooksValid($sqlSample)) {
                    throw new RuntimeException('database.sql is unreadable or does not contain a valid DentalCare SQL dump.');
                }
            }

            return [
                'format' => 'DentalCare System Backup',
                'format_version' => (int) $manifest['format_version'],
                'backup_type' => $type,
                'application_version' => (string) ($manifest['application_version'] ?? ''),
                'installed_version' => (string) ($manifest['installed_version'] ?? ''),
                'file_count' => $verified,
                'deep_verified' => true,
                'manifest_sha256' => $manifestHash,
            ];
        } finally {
            $zip->close();
        }
    }

    private function verifySqlBackup(string $path): array
    {
        $sample = file_get_contents($path, false, null, 0, min(1048576, max(1, (int) (filesize($path) ?: 0))));
        if (!is_string($sample) || !$this->sqlDumpLooksValid($sample)) {
            throw new RuntimeException('The SQL backup is unreadable or does not contain a valid DentalCare database dump.');
        }
        return ['format' => 'DentalCare SQL Backup', 'format_version' => 1, 'backup_type' => 'database', 'file_count' => 1, 'deep_verified' => true];
    }

    private function verifyGzipSqlBackup(string $path): array
    {
        if (!function_exists('gzopen')) throw new RuntimeException('PHP zlib support is required to verify this compressed backup.');
        $handle = @gzopen($path, 'rb');
        if (!$handle) throw new RuntimeException('The compressed SQL backup cannot be opened.');
        $sample = '';
        try {
            while (!gzeof($handle) && strlen($sample) < 1048576) {
                $chunk = gzread($handle, 65536);
                if ($chunk === false) throw new RuntimeException('The compressed SQL backup cannot be read.');
                $sample .= $chunk;
            }
        } finally {
            gzclose($handle);
        }
        if (!$this->sqlDumpLooksValid($sample)) throw new RuntimeException('The compressed SQL backup does not contain a valid DentalCare database dump.');
        return ['format' => 'DentalCare GZIP SQL Backup', 'format_version' => 1, 'backup_type' => 'database', 'file_count' => 1, 'deep_verified' => true];
    }

    private function sqlDumpLooksValid(string $sql): bool
    {
        return str_starts_with(ltrim($sql), '-- DentalCare database backup')
            && str_contains($sql, 'SET FOREIGN_KEY_CHECKS=0')
            && preg_match('/CREATE\s+TABLE/i', $sql) === 1;
    }

    private function parseChecksumList(string $contents): array
    {
        $map = [];
        foreach (preg_split('/\R/', trim($contents)) ?: [] as $line) {
            if (!preg_match('/^([a-fA-F0-9]{64})\s{2}(.+)$/', trim($line), $match)) continue;
            $name = str_replace('\\', '/', trim($match[2]));
            if ($this->isSafeArchivePath($name)) $map[$name] = strtolower($match[1]);
        }
        return $map;
    }

    private function isSafeArchivePath(string $name): bool
    {
        if ($name === '' || str_starts_with($name, '/') || str_contains($name, "\0")) return false;
        foreach (explode('/', $name) as $part) if ($part === '..') return false;
        return true;
    }


    private function readZipEntrySample(ZipArchive $zip, string $name, int $maximumBytes = 1048576): string
    {
        $maximumBytes = max(4096, min(4194304, $maximumBytes));
        $stream = $zip->getStream($name);
        if (!is_resource($stream)) {
            throw new RuntimeException('Backup file cannot be sampled: ' . $name);
        }

        $sample = '';
        try {
            while (!feof($stream) && strlen($sample) < $maximumBytes) {
                $remaining = $maximumBytes - strlen($sample);
                $chunk = fread($stream, min(65536, $remaining));
                if ($chunk === false) {
                    throw new RuntimeException('Backup file sample read failed: ' . $name);
                }
                if ($chunk === '') {
                    if (feof($stream)) break;
                    continue;
                }
                $sample .= $chunk;
            }
        } finally {
            fclose($stream);
        }

        if ($sample === '') {
            throw new RuntimeException('Backup file sample is empty: ' . $name);
        }
        return $sample;
    }

    private function hashZipEntry(ZipArchive $zip, string $name): string
    {
        $stream = $zip->getStream($name);
        if (!is_resource($stream)) throw new RuntimeException('Backup file cannot be read: ' . $name);
        $context = hash_init('sha256');
        try {
            while (!feof($stream)) {
                $chunk = fread($stream, 1048576);
                if ($chunk === false) throw new RuntimeException('Backup file read failed: ' . $name);
                if ($chunk !== '') hash_update($context, $chunk);
            }
        } finally {
            fclose($stream);
        }
        return hash_final($context);
    }

    private function writeDatabaseDump(string $path): void
    {
        $prefix = (string) app_config('database.prefix', 'dent_');
        $likePrefix = str_replace(['\\','%','_'], ['\\\\','\\%','\\_'], $prefix) . '%';
        $stmt = $this->pdo->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema=? AND table_name LIKE ? ESCAPE \'\\\\\' ORDER BY table_name');
        $stmt->execute([(string) app_config('database.name'), $likePrefix]);
        $tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
        if (!$tables) throw new RuntimeException('No DentalCare database tables were found.');
        $handle = fopen($path, 'wb');
        if (!$handle) throw new RuntimeException('Unable to create database.sql.');
        fwrite($handle, "-- DentalCare database backup\n-- Created " . date(DATE_ATOM) . "\nSET FOREIGN_KEY_CHECKS=0;\nSET NAMES utf8mb4;\n\n");
        foreach ($tables as $tableName) {
            $safe = str_replace('`', '``', (string) $tableName);
            $create = $this->pdo->query('SHOW CREATE TABLE `' . $safe . '`')->fetch(PDO::FETCH_NUM);
            fwrite($handle, 'DROP TABLE IF EXISTS `' . $safe . "`;\n" . ($create[1] ?? '') . ";\n\n");
            $rows = $this->pdo->query('SELECT * FROM `' . $safe . '`');
            while ($row = $rows->fetch(PDO::FETCH_ASSOC)) {
                $columns = array_map(static fn(string $column): string => '`' . str_replace('`', '``', $column) . '`', array_keys($row));
                $values = array_map(fn($value): string => $value === null ? 'NULL' : $this->pdo->quote((string) $value), array_values($row));
                fwrite($handle, 'INSERT INTO `' . $safe . '` (' . implode(',', $columns) . ') VALUES (' . implode(',', $values) . ");\n");
            }
            fwrite($handle, "\n");
        }
        fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n");
        fclose($handle);
    }

    private function backupSources(): array
    {
        return [
            ['path' => DENTAL_ROOT . '/config/generated.php', 'target' => 'configuration/generated.php', 'category' => 'configuration'],
            ['path' => DENTAL_ROOT . '/storage/installed.lock', 'target' => 'installation/installed.lock', 'category' => 'installation'],
            ['path' => DENTAL_ROOT . '/storage/license.key', 'target' => 'licensing/license.key', 'category' => 'licensing'],
            ['path' => DENTAL_ROOT . '/storage/licensing', 'target' => 'licensing/private', 'category' => 'licensing'],
            ['path' => DENTAL_ROOT . '/storage/private', 'target' => 'private', 'category' => 'protected_upload'],
            ['path' => DENTAL_ROOT . '/public/uploads', 'target' => 'public_uploads', 'category' => 'public_upload'],
            ['path' => DENTAL_ROOT . '/storage/logs', 'target' => 'logs', 'category' => 'log'],
        ];
    }

    private function shouldSkipFile(string $relative): bool
    {
        $relative = str_replace('\\', '/', $relative);
        return str_contains($relative, '/.build_') || str_ends_with($relative, '.tmp') || basename($relative) === '.DS_Store';
    }

    private function walkFiles(string $directory): Generator
    {
        if (!is_dir($directory)) return;
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($iterator as $file) {
            if ($file->isFile() && !$file->isLink()) yield $file->getPathname();
        }
    }

    private function copyFile(string $source, string $target): void
    {
        $parent = dirname($target);
        if (!is_dir($parent) && !mkdir($parent, 0770, true) && !is_dir($parent)) {
            throw new RuntimeException('Unable to create backup folder ' . $parent);
        }
        if (!copy($source, $target)) throw new RuntimeException('Unable to include ' . basename($source) . ' in the backup.');
    }

    private function fileManifestEntry(string $absolute, string $archivePath, string $category): array
    {
        return [
            'path' => str_replace('\\', '/', $archivePath),
            'category' => $category,
            'size' => (int) (filesize($absolute) ?: 0),
            'sha256' => hash_file('sha256', $absolute) ?: '',
            'modified_at' => date(DATE_ATOM, (int) (filemtime($absolute) ?: time())),
        ];
    }

    private function restoreReadme(string $type): string
    {
        return "DentalCare {$type} backup\n\n"
            . "This package can contain protected patient records and credentials. Keep it confidential.\n\n"
            . "Recommended restore sequence:\n"
            . "1. Install the same or a compatible DentalCare application version.\n"
            . "2. Place the clinic in maintenance mode.\n"
            . "3. Create a pre-restore backup.\n"
            . "4. Restore database.sql when present.\n"
            . "5. Restore configuration, licensing, private, and public_uploads paths to their original locations.\n"
            . "6. Verify file permissions and run System Setup health checks.\n"
            . "7. Test login, patient access, clinical files, SMTP, licensing, and printing before reopening the clinic.\n";
    }

    private function deleteDirectory(string $path): void
    {
        if (!is_dir($path)) return;
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($iterator as $item) {
            if ($item->isDir()) @rmdir($item->getPathname()); else @unlink($item->getPathname());
        }
        @rmdir($path);
    }
}
