<?php
namespace App\Services;

use App\Core\Database;
use App\Models\BackupJob;
use App\Security\BackupArchiveCrypto;
use PDO;
use PharData;
use RuntimeException;
use Throwable;

final class BackupService
{
    private string $root;

    public function __construct()
    {
        $this->root = BASE_PATH . '/storage/backups';

        if (
            !is_dir($this->root) &&
            !mkdir($this->root, 0775, true) &&
            !is_dir($this->root)
        ) {
            throw new RuntimeException(
                'Impossible de créer storage/backups.'
            );
        }
    }

    public function create(int $userId, string $type = 'manuel'): int
    {
        $allowedTypes = ['manuel', 'avant_mise_a_jour', 'apres_mise_a_jour'];
        if (!in_array($type, $allowedTypes, true)) {
            throw new RuntimeException('Type de sauvegarde non autorisé.');
        }

        $id = BackupJob::create($userId, $type);
        $stamp = date('Ymd_His');
        $dir = $this->root . '/backup_' . $stamp . '_' . $id;

        if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new RuntimeException(
                'Impossible de créer le dossier temporaire.'
            );
        }

        try {
            BackupJob::update($id, [
                'statut' => 'en_cours',
                'chemin' => $dir,
            ]);

            $dbFile = $dir . '/database.sql';
            $this->dumpDatabase($dbFile);

            $projectDir = $dir . '/project';

            $this->copyTree(
                BASE_PATH,
                $projectDir,
                [
                    '.env',
                    'storage/backups',
                    'storage/cache',
                    'storage/sessions',
                    'storage/logs',
                    'storage/updates',
                    'storage/consolidation_backups',
                ]
            );

            $version = $this->readVersion('VERSION');
            $coreVersion = $this->readVersion('CORE_VERSION');
            if ($version === 'inconnue' || $coreVersion === 'inconnue') {
                throw new RuntimeException(
                    'Versions applicative et cœur introuvables. Sauvegarde refusée.'
                );
            }

            $manifest = [
                'format' => 2,
                'project' => 'CDA',
                'snapshot_kind' => $type,
                'created_at' => date(DATE_ATOM),
                'database' => basename($dbFile),
                'version' => $version,
                'core_version' => $coreVersion,
                'archive_engine' => $this->detectArchiveEngine(),
                'excluded_regenerable_paths' => [
                    '.env',
                    'storage/backups',
                    'storage/cache',
                    'storage/sessions',
                    'storage/logs',
                    'storage/updates',
                    'storage/consolidation_backups',
                ],
            ];

            $manifest['project_tree_sha256'] = $this->treeChecksum($projectDir);
            $manifest['database_sha256'] = hash_file('sha256', $dbFile);

            file_put_contents(
                $dir . '/manifest.json',
                json_encode(
                    $manifest,
                    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
                )
            );

            $plainArchivePath = $this->createArchive($dir);
            $archivePath = BackupArchiveCrypto::encrypt($plainArchivePath);
            @unlink($plainArchivePath);

            $fileSize = $this->directorySize($projectDir);
            $dbSize = is_file($dbFile)
                ? (int)filesize($dbFile)
                : 0;
            $totalSize = is_file($archivePath)
                ? (int)filesize($archivePath)
                : 0;

            BackupJob::update($id, [
                'statut' => 'succes',
                'chemin' => $archivePath,
                'base_nom' => $_ENV['DB_NAME'] ?? '',
                'taille_fichiers' => $fileSize,
                'taille_base' => $dbSize,
                'taille_totale' => $totalSize,
                'checksum' => hash_file('sha256', $archivePath),
                'message' => 'Instantané fidèle ' . $version . ' / cœur '
                    . $coreVersion . ' créé avec '
                    . $this->archiveEngineLabel($archivePath)
                    . ' (archives, caches et temporaires exclus).',
            ]);

            $this->removeTree($dir);

            return $id;
        } catch (Throwable $error) {
            BackupJob::update($id, [
                'statut' => 'echec',
                'chemin' => $dir,
                'message' => $error->getMessage(),
            ]);

            throw $error;
        }
    }

    /**
     * Construit un export technique non chiffré strictement réservé au poste local.
     * Le fichier retourné doit être supprimé immédiatement après téléchargement.
     */
    public function createDeveloperExport(int $userId): string
    {
        $environment = strtolower(trim((string)($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: 'production')));
        if ($environment !== 'local') {
            throw new RuntimeException('Export développeur désactivé hors environnement local.');
        }
        if (!class_exists(\ZipArchive::class)) {
            throw new RuntimeException('Extension ZIP obligatoire pour l’export développeur.');
        }

        $stamp = date('Ymd_His');
        $workRoot = BASE_PATH . '/storage/updates/developer_exports';
        $stage = $workRoot . '/stage_' . bin2hex(random_bytes(8));
        if (!is_dir($stage) && !mkdir($stage, 0700, true) && !is_dir($stage)) {
            throw new RuntimeException('Création de l’espace temporaire impossible.');
        }

        try {
            $this->dumpDatabase($stage . '/database.sql');
            $this->copyTree(BASE_PATH, $stage . '/project', [
                '.env', '.git',
                'storage/private', 'storage/backups', 'storage/cache',
                'storage/sessions', 'storage/logs', 'storage/updates',
                'storage/consolidation_backups',
            ]);

            $manifest = [
                'format' => 1,
                'kind' => 'axiom-developer-export',
                'environment' => 'local',
                'created_at' => date(DATE_ATOM),
                'created_by' => $userId,
                'version' => $this->readVersion('VERSION'),
                'core_version' => $this->readVersion('CORE_VERSION'),
                'secrets_included' => false,
                'database_sha256' => hash_file('sha256', $stage . '/database.sql'),
                'project_tree_sha256' => $this->treeChecksum($stage . '/project'),
                'excluded_paths' => [
                    '.env', '.git', 'storage/private', 'storage/backups',
                    'storage/cache', 'storage/sessions', 'storage/logs',
                    'storage/updates', 'storage/consolidation_backups',
                ],
            ];
            file_put_contents($stage . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

            $target = $workRoot . '/axiom_developer_export_' . $stamp . '.zip';
            $this->createWithZipArchive($stage, $target);
            if (!is_file($target) || filesize($target) < 1) {
                throw new RuntimeException('Export développeur vide ou introuvable.');
            }
            return $target;
        } finally {
            $this->removeTree($stage);
        }
    }

    public function restore(int $id): void
    {
        $job = BackupJob::find($id);

        if (
            !$job ||
            $job['statut'] !== 'succes' ||
            !is_file((string)$job['chemin'])
        ) {
            throw new RuntimeException(
                'Sauvegarde introuvable.'
            );
        }

        $archivePath = (string)$job['chemin'];
        $expectedChecksum = (string)($job['checksum_sha256'] ?? '');
        if ($expectedChecksum === '' || !hash_equals($expectedChecksum, (string)hash_file('sha256', $archivePath))) {
            throw new RuntimeException('Intégrité de la sauvegarde non vérifiée. Restauration refusée.');
        }
        $archiveToExtract = $archivePath;
        $decryptedTemporary = null;
        if (str_ends_with(strtolower($archivePath), '.axiombak')) {
            $decryptedTemporary = $this->root . '/decrypt_' . date('Ymd_His') . '_' . bin2hex(random_bytes(3)) . '.zip';
            BackupArchiveCrypto::decrypt($archivePath, $decryptedTemporary);
            $archiveToExtract = $decryptedTemporary;
        } elseif (!str_ends_with(strtolower($archivePath), '.zip')) {
            throw new RuntimeException('Restauration confinée aux sauvegardes AXIOM chiffrées ou ZIP historiques.');
        }
        $stage = $this->root
            . '/restore_' . date('Ymd_His')
            . '_' . bin2hex(random_bytes(3));

        if (!mkdir($stage, 0775, true) && !is_dir($stage)) {
            throw new RuntimeException(
                'Impossible de créer le dossier de restauration.'
            );
        }

        try {
            $this->extractArchive($archiveToExtract, $stage);
        } finally {
            if ($decryptedTemporary !== null) @unlink($decryptedTemporary);
        }

        if (
            !is_file($stage . '/database.sql') ||
            !is_dir($stage . '/project') ||
            !is_file($stage . '/manifest.json')
        ) {
            $this->removeTree($stage);

            throw new RuntimeException(
                'Sauvegarde incomplète.'
            );
        }

        $manifest = json_decode(
            (string)file_get_contents($stage . '/manifest.json'),
            true
        );
        if (!is_array($manifest)) {
            $this->removeTree($stage);
            throw new RuntimeException('Manifeste de sauvegarde invalide.');
        }
        if (
            isset($manifest['database_sha256']) &&
            !hash_equals(
                (string)$manifest['database_sha256'],
                (string)hash_file('sha256', $stage . '/database.sql')
            )
        ) {
            $this->removeTree($stage);
            throw new RuntimeException('Export SQL altéré. Restauration refusée.');
        }
        if (
            isset($manifest['project_tree_sha256']) &&
            !hash_equals(
                (string)$manifest['project_tree_sha256'],
                $this->treeChecksum($stage . '/project')
            )
        ) {
            $this->removeTree($stage);
            throw new RuntimeException('Fichiers projet altérés. Restauration refusée.');
        }

        $safety = $this->root
            . '/before_restore_' . date('Ymd_His');

        $this->copyTree(
            BASE_PATH,
            $safety . '/project',
            ['storage/backups']
        );

        $this->copyTree(
            $stage . '/project',
            BASE_PATH,
            ['.env', 'storage/backups', 'storage/updates']
        );

        $sql = file_get_contents(
            $stage . '/database.sql'
        );

        if ($sql === false) {
            throw new RuntimeException(
                'Lecture de la sauvegarde SQL impossible.'
            );
        }

        Database::connection()->exec($sql); // @sql-safe-dynamic verified backup restore
        $this->removeTree($stage);

        Database::connection()->prepare(
            "UPDATE cda_backup_jobs
             SET statut='restaure',
                 restored_at=NOW()
             WHERE id=:id"
        )->execute(['id' => $id]);
    }

    public function delete(int $id): void
    {
        $job = BackupJob::find($id);

        if (!$job) {
            return;
        }

        $path = (string)$job['chemin'];

        if (is_file($path)) {
            unlink($path);
        } elseif (is_dir($path)) {
            $this->removeTree($path);
        }

        Database::connection()->prepare(
            'DELETE FROM cda_backup_jobs WHERE id=:id'
        )->execute(['id' => $id]);
    }

    public function diagnostic(): array
    {
        return [
            'zip_archive' => class_exists(\ZipArchive::class),
            'phar' => class_exists(PharData::class),
            'selected_engine' => $this->detectArchiveEngine(),
            'encrypted_backups' => function_exists('sodium_crypto_secretstream_xchacha20poly1305_init_push'),
            'master_key_configured' => trim((string)($_ENV['AXIOM_MASTER_KEY_BASE64'] ?? getenv('AXIOM_MASTER_KEY_BASE64') ?: '')) !== '',
        ];
    }

    private function createArchive(string $source): string
    {
        $zipPath = $source . '.zip';

        if (class_exists(\ZipArchive::class)) {
            $this->createWithZipArchive(
                $source,
                $zipPath
            );

            return $zipPath;
        }


        if (class_exists(PharData::class)) {
            return $this->createWithPhar($source);
        }

        throw new RuntimeException(
            'Aucun moteur d’archive disponible : '
            . 'ZipArchive ou PharData.'
        );
    }

    private function extractArchive(
        string $archivePath,
        string $destination
    ): void {
        $lower = strtolower($archivePath);

        if (str_ends_with($lower, '.zip')) {
            if (class_exists(\ZipArchive::class)) {
                $zip = new \ZipArchive();

                if ($zip->open($archivePath) !== true) {
                    throw new RuntimeException(
                        'Archive ZIP illisible.'
                    );
                }

                for ($index = 0; $index < $zip->numFiles; $index++) {
                    $name = str_replace('\\', '/', (string)$zip->getNameIndex($index));
                    if (
                        $name === ''
                        || str_starts_with($name, '/')
                        || preg_match('/^[A-Za-z]:\//', $name)
                        || in_array('..', explode('/', $name), true)
                    ) {
                        $zip->close();
                        throw new RuntimeException('Chemin dangereux détecté dans la sauvegarde.');
                    }
                }

                if (!$zip->extractTo($destination)) {
                    $zip->close();

                    throw new RuntimeException(
                        'Extraction ZIP impossible.'
                    );
                }

                $zip->close();

                return;
            }


            throw new RuntimeException(
                'Aucun moteur disponible pour extraire le ZIP.'
            );
        }

        if (
            str_ends_with($lower, '.tar.gz') ||
            str_ends_with($lower, '.tgz')
        ) {
            if (!class_exists(PharData::class)) {
                throw new RuntimeException(
                    'PharData est nécessaire pour restaurer cette archive.'
                );
            }

            $tarPath = preg_replace(
                '/\.(tar\.gz|tgz)$/i',
                '.tar',
                $archivePath
            );

            if (!is_file($tarPath)) {
                $compressed = new PharData($archivePath);
                $compressed->decompress();
            }

            $tar = new PharData($tarPath);
            $tar->extractTo($destination, null, true);

            if (is_file($tarPath)) {
                unlink($tarPath);
            }

            return;
        }

        throw new RuntimeException(
            'Format de sauvegarde non reconnu.'
        );
    }

    private function createWithZipArchive(
        string $source,
        string $zipPath
    ): void {
        $zip = new \ZipArchive();

        if (
            $zip->open(
                $zipPath,
                \ZipArchive::CREATE |
                \ZipArchive::OVERWRITE
            ) !== true
        ) {
            throw new RuntimeException(
                'Création ZIP impossible.'
            );
        }

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $source,
                \FilesystemIterator::SKIP_DOTS
            ),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );

        foreach ($iterator as $file) {
            if (!$file->isFile()) {
                continue;
            }

            $relative = substr(
                $file->getPathname(),
                strlen($source) + 1
            );

            if (!$zip->addFile(
                $file->getPathname(),
                str_replace('\\', '/', $relative)
            )) {
                $zip->close();

                throw new RuntimeException(
                    'Ajout impossible au ZIP : ' . $relative
                );
            }
        }

        if (!$zip->close()) {
            throw new RuntimeException(
                'Finalisation du ZIP impossible.'
            );
        }
    }

    private function createWithPhar(
        string $source
    ): string {
        $tarPath = $source . '.tar';
        $gzipPath = $tarPath . '.gz';

        if (is_file($tarPath)) {
            unlink($tarPath);
        }

        if (is_file($gzipPath)) {
            unlink($gzipPath);
        }

        $phar = new PharData($tarPath);

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $source,
                \FilesystemIterator::SKIP_DOTS
            ),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );

        foreach ($iterator as $file) {
            if (!$file->isFile()) {
                continue;
            }

            $relative = str_replace(
                '\\',
                '/',
                substr(
                    $file->getPathname(),
                    strlen($source) + 1
                )
            );

            $phar->addFile(
                $file->getPathname(),
                $relative
            );
        }

        $phar->compress(\Phar::GZ);
        unset($phar);

        if (is_file($tarPath)) {
            unlink($tarPath);
        }

        if (!is_file($gzipPath)) {
            throw new RuntimeException(
                'Création TAR.GZ impossible.'
            );
        }

        return $gzipPath;
    }

    private function detectArchiveEngine(): string
    {
        if (class_exists(\ZipArchive::class)) {
            return 'ZipArchive';
        }


        if (class_exists(PharData::class)) {
            return 'PharData TAR.GZ';
        }

        return 'Aucun';
    }

    private function archiveEngineLabel(
        string $archivePath
    ): string {
        return 'Archive chiffrée AXIOM (' . $this->detectArchiveEngine() . ')';
    }

    private function dumpDatabase(string $file): void
    {
        $pdo = Database::connection();
        $handle = fopen($file, 'wb');

        if (!$handle) {
            throw new RuntimeException(
                'Création export SQL impossible.'
            );
        }

        fwrite(
            $handle,
            "SET FOREIGN_KEY_CHECKS=0;\n"
            . "SET NAMES utf8mb4;\n"
        );

        $tables = $pdo->query(
            "SHOW FULL TABLES WHERE Table_type='BASE TABLE'"
        )->fetchAll(PDO::FETCH_NUM);

        foreach ($tables as $row) {
            $table = (string)$row[0];
            $safe = str_replace('`', '``', $table);

            $create = $pdo->query(
                "SHOW CREATE TABLE `{$safe}`"
            )->fetch(PDO::FETCH_NUM);

            fwrite(
                $handle,
                "DROP TABLE IF EXISTS `{$safe}`;\n"
                . ($create[1] ?? '')
                . ";\n"
            );

            $rows = $pdo->query(
                "SELECT * FROM `{$safe}`"
            );

            while (
                $record = $rows->fetch(PDO::FETCH_ASSOC)
            ) {
                $columns = array_map(
                    static fn(string $column): string =>
                        '`' . str_replace(
                            '`',
                            '``',
                            $column
                        ) . '`',
                    array_keys($record)
                );

                $values = array_map(
                    static fn(mixed $value): string =>
                        $value === null
                            ? 'NULL'
                            : $pdo->quote((string)$value),
                    array_values($record)
                );

                fwrite(
                    $handle,
                    "INSERT INTO `{$safe}` ("
                    . implode(',', $columns)
                    . ") VALUES ("
                    . implode(',', $values)
                    . ");\n"
                );
            }
        }

        fwrite(
            $handle,
            "SET FOREIGN_KEY_CHECKS=1;\n"
        );
        fclose($handle);
    }

    private function copyTree(
        string $source,
        string $destination,
        array $excluded
    ): void {
        if (!is_dir($destination)) {
            mkdir($destination, 0775, true);
        }

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $source,
                \FilesystemIterator::SKIP_DOTS
            ),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($iterator as $item) {
            $relative = str_replace(
                '\\',
                '/',
                substr(
                    $item->getPathname(),
                    strlen($source) + 1
                )
            );

            foreach ($excluded as $prefix) {
                if (
                    $relative === $prefix ||
                    str_starts_with(
                        $relative,
                        $prefix . '/'
                    )
                ) {
                    continue 2;
                }
            }

            $target = $destination . '/' . $relative;

            if ($item->isDir()) {
                if (!is_dir($target)) {
                    mkdir($target, 0775, true);
                }
            } else {
                $parent = dirname($target);

                if (!is_dir($parent)) {
                    mkdir($parent, 0775, true);
                }

                if (!copy(
                    $item->getPathname(),
                    $target
                )) {
                    throw new RuntimeException(
                        'Copie impossible : ' . $relative
                    );
                }
            }
        }
    }

    private function readVersion(string $file): string
    {
        $path = BASE_PATH . '/' . $file;
        if (!is_file($path)) {
            return 'inconnue';
        }

        $version = trim((string)file_get_contents($path));
        return $version !== '' ? $version : 'inconnue';
    }

    private function treeChecksum(string $directory): string
    {
        $files = [];
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $directory,
                \FilesystemIterator::SKIP_DOTS
            ),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );

        foreach ($iterator as $file) {
            if (!$file->isFile()) {
                continue;
            }
            $relative = str_replace(
                '\\',
                '/',
                substr($file->getPathname(), strlen($directory) + 1)
            );
            $files[$relative] = (string)hash_file('sha256', $file->getPathname());
        }

        ksort($files, SORT_STRING);
        $context = hash_init('sha256');
        foreach ($files as $relative => $checksum) {
            hash_update($context, $relative . "\0" . $checksum . "\n");
        }

        return hash_final($context);
    }

    private function directorySize(string $directory): int
    {
        $size = 0;

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $directory,
                \FilesystemIterator::SKIP_DOTS
            )
        );

        foreach ($iterator as $file) {
            if ($file->isFile()) {
                $size += $file->getSize();
            }
        }

        return $size;
    }

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

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $path,
                \FilesystemIterator::SKIP_DOTS
            ),
            \RecursiveIteratorIterator::CHILD_FIRST
        );

        foreach ($iterator as $item) {
            $item->isDir()
                ? rmdir($item->getPathname())
                : unlink($item->getPathname());
        }

        rmdir($path);
    }
}
