<?php
namespace App\Services;

use App\Security\SqlSecurityPolicy;
use App\Services\Policy\PackagePolicyService;
use App\Services\Pipeline\PipelineOrchestrator;

use App\Core\Database;
use RuntimeException;
use Throwable;
use ZipArchive;

final class UpdatePackageService
{
    private string $packagesDir;
    private string $workDir;
    private string $logDir;

    public function __construct()
    {
        $this->packagesDir = BASE_PATH . '/storage/updates/packages';
        $this->workDir = BASE_PATH . '/storage/updates/work';
        $this->logDir = BASE_PATH . '/storage/updates/logs';
        foreach ([$this->packagesDir, $this->workDir, $this->logDir] as $dir) {
            if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
                throw new RuntimeException('Impossible de créer le dossier : ' . $dir);
            }
        }
    }

    public function inspect(string $zipPath): array
    {
        if (!is_file($zipPath)) throw new RuntimeException('Package introuvable.');
        if (!class_exists(ZipArchive::class)) throw new RuntimeException('L’extension PHP ZipArchive doit être activée.');
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) throw new RuntimeException('Archive ZIP illisible.');
        $manifestRaw = $zip->getFromName('manifest.json');
        $zip->close();
        if ($manifestRaw === false) throw new RuntimeException('manifest.json est absent du package.');
        $manifest = json_decode($manifestRaw, true);
        if (!is_array($manifest)) throw new RuntimeException('manifest.json est invalide.');
        foreach (['package','version','files'] as $required) {
            if (!array_key_exists($required, $manifest)) throw new RuntimeException('Champ manifeste manquant : ' . $required);
        }
        if (!is_array($manifest['files'])) throw new RuntimeException('La liste files du manifeste est invalide.');
        $this->verifyManifestSignature($manifest);
        return $manifest;
    }

    private function verifyManifestSignature(array $manifest): void
    {
        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            throw new RuntimeException('Extension Sodium obligatoire pour vérifier la signature du paquet.');
        }
        if (($manifest['signature_algorithm'] ?? '') !== 'Ed25519') {
            throw new RuntimeException('Algorithme de signature absent ou non autorisé.');
        }
        $encodedSignature = (string)($manifest['signature'] ?? '');
        $signature = base64_decode($encodedSignature, true);
        $config = require BASE_PATH . '/config/update_signing.php';
        $publicKey = base64_decode((string)($config['public_key_base64'] ?? ''), true);
        if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES || $publicKey === false) {
            throw new RuntimeException('Signature du paquet invalide.');
        }
        unset($manifest['signature']);
        $payload = json_encode(
            $this->canonicalize($manifest),
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        );
        if (!is_string($payload) || !sodium_crypto_sign_verify_detached($signature, $payload, $publicKey)) {
            throw new RuntimeException('Signature Ed25519 non reconnue. Installation interdite.');
        }
    }

    private function canonicalize(mixed $value): mixed
    {
        if (!is_array($value)) return $value;
        if (array_is_list($value)) {
            return array_map(fn(mixed $item): mixed => $this->canonicalize($item), $value);
        }
        ksort($value, SORT_STRING);
        foreach ($value as $key => $item) {
            $value[$key] = $this->canonicalize($item);
        }
        return $value;
    }

    public function preflight(array $package): array
    {
        $zipPath = $this->packagesDir . '/' . basename((string)$package['fichier_original']);
        $packageId = (int)($package['id'] ?? 0);
        $pipeline = (new PipelineOrchestrator())->execute(
            $zipPath,
            $packageId > 0 ? $packageId : null,
            null,
            $packageId > 0 ? 'preflight_registered' : 'preflight_import',
            true
        );
        $checks = [];
        foreach ((array)($pipeline['steps'] ?? []) as $step) {
            $blocked = in_array((string)($step['decision'] ?? 'ERROR'), ['BLOCKED','DENIED','REJECTED','INCOMPATIBLE','ERROR'], true);
            $checks[] = ['name'=>(string)($step['label']??'Pipeline'),'ok'=>!$blocked,'critical'=>(bool)($step['critical']??false),'message'=>(string)($step['decision']??'ERROR').' · '.(int)($step['score']??0).'/100'.(!empty($step['issues'])?' · '.implode(' · ',array_slice((array)$step['issues'],0,3)):'')];
        }
        return ['ok'=>(bool)($pipeline['ok']??false),'checks'=>$checks,'pipeline'=>$pipeline];
    }

    public function install(array $package, int $userId): array
    {
        $startedAt = microtime(true);
        $id = (int)$package['id'];
        $lock = new UpdateLockService();
        $lockToken = $lock->acquire($id, $userId);
        $zipPath = $this->packagesDir . '/' . basename((string)$package['fichier_original']);
        $manifest = $this->inspect($zipPath);
        $installed = $this->installedVersion();
        $preflight = (new UpdatePreflightService())->run($zipPath, $manifest, $installed);
        if (!$preflight['ok']) {
            $messages = array_map(static fn(array $check): string => $check['name'] . ' : ' . $check['message'], array_filter($preflight['checks'], static fn(array $check): bool => $check['critical'] && !$check['ok']));
            $lock->release();
            throw new RuntimeException('Préflight bloquant : ' . implode(' | ', $messages));
        }
        $compatibility=(new UpdateVersionCompatibility())->evaluate($installed,$manifest);
        if (!$compatibility['ok']) {
            $lock->release();
            throw new RuntimeException('Version incompatible. Installée : '.$compatibility['installed'].' · Acceptée : '.$compatibility['accepted'].'.');
        }

        $policy = (new PackagePolicyService())->evaluatePackageFile($zipPath, $id, $userId ?: null, true);
        if (($policy['decision'] ?? 'DENIED') === 'DENIED') {
            $lock->release();
            throw new RuntimeException('Package Policy bloquant : '.implode(' | ', (array)($policy['issues'] ?? ['Package refusé.'])));
        }

        $stage = $this->workDir . '/pkg_' . $id . '_' . bin2hex(random_bytes(4));
        if (!mkdir($stage, 0775, true) && !is_dir($stage)) {
            $lock->release();
            throw new RuntimeException('Impossible de créer le dossier temporaire.');
        }
        $backupDir = $stage . '/rollback';
        mkdir($backupDir, 0775, true);
        $createdFiles = [];
        $pdo = Database::connection();
        $logPath = $this->logDir . '/update_' . $id . '_' . date('Ymd_His') . '.log';
        $currentMigration = null;
        $this->log($logPath, 'Début installation ' . (string)$manifest['version']);
        $pdo->prepare("UPDATE cda_update_packages SET statut='installation', installation_debut=NOW(), message=NULL, journal_path=:journal, lock_token=:lock_token WHERE id=:id")
            ->execute(['journal' => $logPath, 'lock_token' => $lockToken, 'id' => $id]);

        try {
            $this->extractSafe($zipPath, $stage . '/extract');
            $this->verifyFiles($stage . '/extract', $manifest['files']);
            $this->validatePayload($stage . '/extract/payload');
            $compliance = (new SecurityComplianceService())->auditDirectory($stage . '/extract/payload');
            $this->log($logPath, 'Conformité sécurité : ' . $compliance['score'] . '/100, ' . $compliance['files_scanned'] . ' fichiers analysés.');
            foreach ($compliance['warnings'] as $warning) {
                $this->log($logPath, 'AVERTISSEMENT SECURITE : ' . $warning);
            }
            if (!$compliance['ok']) {
                throw new RuntimeException('Audit de sécurité bloquant : ' . implode(' | ', $compliance['critical']));
            }
            $this->log($logPath, 'Archive, empreintes SHA-256 et conformité sécurité validées.');

            $backupJobId = null;
            if (($manifest['automatic_backup'] ?? true) !== false) {
                $this->log($logPath, 'Création de la sauvegarde complète avant mise à jour.');
                $backupJobId = (new BackupService())->create(
                    $userId,
                    'avant_mise_a_jour'
                );
                $pdo->prepare('UPDATE cda_update_packages SET backup_job_id=:backup WHERE id=:id')
                    ->execute(['backup' => $backupJobId, 'id' => $id]);
                $this->log($logPath, 'Sauvegarde créée, identifiant ' . $backupJobId . '.');
            }

            $createdFiles = $this->backupTargetFiles($stage . '/extract/payload', $backupDir);
            $this->copyTree($stage . '/extract/payload', BASE_PATH);
            $this->verifyPayloadAfterCopy($stage . '/extract/payload');
            $this->log($logPath, 'Fichiers copiés et vérifiés.');

            $migrationResults = [];
            $runner = new UpdateMigrationRunner();
            foreach (($manifest['migrations'] ?? []) as $migration) {
                $currentMigration = (string)$migration;
                $this->log($logPath, 'Début migration : ' . $currentMigration);
                $path = $stage . '/extract/' . ltrim((string)$migration, '/');
                $key = (string)$manifest['package'] . ':' . ltrim((string)$migration, '/');
                $result = $runner->apply($key, $path, (string)$manifest['version']);
                $migrationResults[] = ['migration' => $migration] + $result;
                $this->log($logPath, 'Migration ' . $result['status'] . ' : ' . $migration . ' (' . $result['duration_ms'] . ' ms)');
                $currentMigration = null;
            }

            $targetVersion = (string)$manifest['version'];
            if (file_put_contents(BASE_PATH . '/VERSION', $targetVersion . PHP_EOL) === false) {
                throw new RuntimeException('Écriture de VERSION impossible.');
            }
            if (file_put_contents(BASE_PATH . '/CORE_VERSION', $targetVersion . PHP_EOL) === false) {
                throw new RuntimeException('Écriture de CORE_VERSION impossible.');
            }

            $pdo->prepare(
                "UPDATE cda_update_packages
                 SET statut='installe',
                     version_package=:version,
                     installation_fin=NOW(),
                     installe_par=:user,
                     message='Finalisation de la sauvegarde après mise à jour.',
                     lock_token=NULL
                 WHERE id=:id"
            )->execute([
                'version' => $targetVersion,
                'user' => $userId ?: null,
                'id' => $id,
            ]);

            $postBackupJobId = (new BackupService())->create(
                $userId,
                'apres_mise_a_jour'
            );
            $message = 'Installation terminée avec succès.'
                . ($backupJobId ? ' Point de retour #' . $backupJobId . '.' : '')
                . ' Sauvegarde fidèle de la version installée #'
                . $postBackupJobId . '.';
            $durationMs = (int)round((microtime(true) - $startedAt) * 1000);
            $resultJson = json_encode([
                'preflight'=>$preflight,
                'migrations'=>$migrationResults,
                'duration_ms'=>$durationMs,
                'backup_before_id'=>$backupJobId,
                'backup_after_id'=>$postBackupJobId,
                'installed_version'=>$targetVersion,
                'core_version'=>$targetVersion,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            $pdo->prepare("UPDATE cda_update_packages SET statut='installe', version_package=:version, installation_fin=NOW(), installe_par=:user, message=:message, duration_ms=:duration, result_json=:result_json, lock_token=NULL WHERE id=:id")
                ->execute(['version'=>$manifest['version'],'user'=>$userId ?: null,'message'=>$message,'duration'=>$durationMs,'result_json'=>$resultJson,'id'=>$id]);
            $this->log($logPath, $message);
            $this->removeTree($stage);
            $lock->release();
            return $manifest;
        } catch (Throwable $e) {
            $this->log($logPath, 'ERREUR : ' . $e->getMessage());
            if ($currentMigration !== null) {
                $this->log($logPath, 'MIGRATION ACTIVE : ' . $currentMigration);
            }
            $this->logExceptionDiagnostics($logPath, $e);
            try {
                foreach (($manifest['rollback_migrations'] ?? []) as $migration) {
                    $path = $stage . '/extract/' . ltrim((string)$migration, '/');
                    if (is_file($path)) {
                        $sql = file_get_contents($path);
                        if ($sql !== false) {
                            SqlSecurityPolicy::assertMigrationSql($sql, 'rollback ' . (string)$migration);
                            $pdo->exec($sql); // @sql-safe-dynamic validated by SqlSecurityPolicy
                        }
                    }
                }
                foreach ($createdFiles as $relative) {
                    $target = BASE_PATH . '/' . $relative;
                    if (is_file($target)) @unlink($target);
                }
                if (is_dir($backupDir)) $this->copyTree($backupDir, BASE_PATH);
                $this->log($logPath, 'Rollback fichiers exécuté.');
            } catch (Throwable $rollbackError) {
                $this->log($logPath, 'ERREUR ROLLBACK : ' . $rollbackError->getMessage());
            }
            $durationMs = (int)round((microtime(true) - $startedAt) * 1000);
            try {
                $statusStatement = $pdo->prepare("UPDATE cda_update_packages SET statut='echec', installation_fin=NOW(), message=:message, duration_ms=:duration, lock_token=NULL WHERE id=:id");
                $statusStatement->execute(['message'=>$e->getMessage() . ' Consultez le journal : ' . $logPath,'duration'=>$durationMs,'id'=>$id]);
                $statusStatement->closeCursor();
            } catch (Throwable $statusError) {
                $this->log($logPath, 'ERREUR MISE A JOUR STATUT : ' . $statusError->getMessage());
            }
            $this->removeTree($stage);
            $lock->release();
            throw $e;
        }
    }

    private function logExceptionDiagnostics(string $logPath, Throwable $exception): void
    {
        $level = 0;
        $current = $exception;
        while ($current !== null && $level < 8) {
            $prefix = $level === 0 ? 'DIAGNOSTIC' : 'CAUSE ' . $level;
            $this->log($logPath, $prefix . ' CLASSE : ' . get_class($current));
            $this->log($logPath, $prefix . ' CODE : ' . (string)$current->getCode());
            $this->log($logPath, $prefix . ' FICHIER : ' . $current->getFile());
            $this->log($logPath, $prefix . ' LIGNE : ' . (string)$current->getLine());
            if ($current instanceof \PDOException && is_array($current->errorInfo ?? null)) {
                $this->log($logPath, $prefix . ' SQLSTATE : ' . (string)($current->errorInfo[0] ?? ''));
                $this->log($logPath, $prefix . ' CODE PILOTE : ' . (string)($current->errorInfo[1] ?? ''));
                $this->log($logPath, $prefix . ' MESSAGE PILOTE : ' . (string)($current->errorInfo[2] ?? ''));
            }
            $current = $current->getPrevious();
            $level++;
        }

        $trace = $exception->getTraceAsString();
        if (strlen($trace) > 12000) {
            $trace = substr($trace, 0, 12000) . "\n… [trace tronquée]";
        }
        foreach (preg_split('/\R/', $trace) ?: [] as $traceLine) {
            $this->log($logPath, 'TRACE : ' . $traceLine);
        }
    }

    private function installedVersion(): string
    {
        $raw = is_file(BASE_PATH . '/VERSION') ? trim((string)file_get_contents(BASE_PATH . '/VERSION')) : '0.0.0';
        try { return (new UpdateVersionCompatibility())->normalize($raw); }
        catch (\Throwable) { return '0.0.0'; }
    }

    private function validatePayload(string $payload): void
    {
        if (!is_dir($payload)) throw new RuntimeException('Le dossier payload est absent.');
        $forbidden = ['.env', 'storage/', 'uploads/', 'documents/'];
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($payload, \FilesystemIterator::SKIP_DOTS));
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            $relative = str_replace('\\','/', substr($file->getPathname(), strlen($payload)+1));
            foreach ($forbidden as $prefix) {
                if ($relative === $prefix || str_starts_with($relative, $prefix)) {
                    throw new RuntimeException('Fichier sensible interdit dans le payload : ' . $relative);
                }
            }
        }
    }

    private function extractSafe(string $zipPath, string $destination): void
    {
        mkdir($destination, 0775, true);
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) throw new RuntimeException('Impossible d’ouvrir le ZIP.');
        for ($i=0; $i<$zip->numFiles; $i++) {
            $name = str_replace('\\','/', (string)$zip->getNameIndex($i));
            if ($name === '' || str_starts_with($name, '/') || str_contains($name, '../')) {
                $zip->close();
                throw new RuntimeException('Chemin dangereux détecté dans le ZIP.');
            }
        }
        if (!$zip->extractTo($destination)) {
            $zip->close();
            throw new RuntimeException('Extraction du package impossible.');
        }
        $zip->close();
    }

    private function verifyFiles(string $extract, array $files): void
    {
        foreach ($files as $entry) {
            if (!is_array($entry) || empty($entry['path']) || empty($entry['sha256'])) throw new RuntimeException('Entrée files invalide dans le manifeste.');
            $relative = ltrim(str_replace('\\','/', (string)$entry['path']), '/');
            if (str_contains($relative, '../')) throw new RuntimeException('Chemin interdit : ' . $relative);
            $path = $extract . '/' . $relative;
            if (!is_file($path)) throw new RuntimeException('Fichier absent : ' . $relative);
            if (!hash_equals(strtolower((string)$entry['sha256']), strtolower((string)hash_file('sha256',$path)))) {
                throw new RuntimeException('Checksum incorrect : ' . $relative);
            }
        }
    }

    /** @return array<int,string> */
    private function backupTargetFiles(string $payload, string $backup): array
    {
        $created = [];
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($payload, \FilesystemIterator::SKIP_DOTS));
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            $relative = str_replace('\\','/', substr($file->getPathname(), strlen($payload)+1));
            $target = BASE_PATH . '/' . $relative;
            if (is_file($target)) {
                $dest = $backup . '/' . $relative;
                if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0775, true);
                if (!copy($target, $dest)) throw new RuntimeException('Sauvegarde impossible : ' . $relative);
            } else {
                $created[] = $relative;
            }
        }
        return $created;
    }

    private function verifyPayloadAfterCopy(string $payload): void
    {
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($payload, \FilesystemIterator::SKIP_DOTS));
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            $relative = str_replace('\\','/', substr($file->getPathname(), strlen($payload)+1));
            $target = BASE_PATH . '/' . $relative;
            if (!is_file($target) || !hash_equals((string)hash_file('sha256', $file->getPathname()), (string)hash_file('sha256', $target))) {
                throw new RuntimeException('Contrôle après copie échoué : ' . $relative);
            }
        }
    }

    private function copyTree(string $source, string $destination): void
    {
        if (!is_dir($source)) return;
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
        foreach ($it as $item) {
            $relative = substr($item->getPathname(), strlen($source)+1);
            $target = $destination . '/' . $relative;
            if ($item->isDir()) {
                if (!is_dir($target)) mkdir($target, 0775, true);
            } else {
                if (!is_dir(dirname($target))) mkdir(dirname($target), 0775, true);
                if (!copy($item->getPathname(), $target)) throw new RuntimeException('Copie impossible : ' . $relative);
            }
        }
    }

    private function log(string $path, string $message): void
    {
        @file_put_contents($path, '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL, FILE_APPEND);
    }

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