<?php
/**
 * Projet                    : MageStock
 * Logiciel                  : MageStock
 * Fichier                   : includes/update-helper.php
 * Rôle du fichier           : Moteur sécurisé de mise à jour, validation des paquets, précontrôle, installation et rollback
 * Auteur                    : Régis KREMER
 * Version du fichier        : 2.6.118
 * Version générale MageStock: 2.6.118
 * Lot                       : MAGESTOCK REMOTE SIGNED UPDATE DISCOVERY1
 * Base cumulative requise   : MageStock 2.6.117
 * Date de création          : 2026-07-21
 * Date de modification      : 2026-07-25
 * Dépendances               : Sodium, PharData, config.php, includes/update-public-key.php
 * Données persistantes      : data/system_state.json, data/update_history.json, data/update_transaction.json, backups/updates/
 * Permissions requises      : update.manage via les points d’entrée appelants
 * Contraintes de sécurité   : signatures Ed25519, chemins protégés, staging privé, remplacement atomique, rollback
 * Compatibilité             : PHP 8.1 à 8.4
 *
 * Résumé des modifications :
 * - accepte les paquets exacts historiques et les paquets UPDATE v2 cumulatifs ou ponts ;
 * - contrôle une plage source inclusive, les exclusions et la baseline de migration ;
 * - exécute les migrations conditionnelles selon la version réellement installée ;
 * - expose les métadonnées nécessaires au planificateur distant sans relâcher les signatures ;
 * - accepte un paquet local téléchargé et vérifié avec une méthode de transaction distante traçable.
 *
 * Historique :
 * - 2.6.110 — Précontrôle du système de fichiers avant maintenance.
 * - 2.6.116 — Validation des distributions CLEAN sans rotation implicite de clé publique.
 * - 2.6.117 — Moteur cumulatif, contrats de source et migrations conditionnelles.
 * - 2.6.118 — stockage contrôlé des paquets distants et traçabilité de la méthode.
 */

defined('APP_ROOT') || exit('Bootstrap MageStock requis.');

if (!defined('MAGESTOCK_UPDATE_STATE_FILE')) define('MAGESTOCK_UPDATE_STATE_FILE', DATA_DIR . '/system_state.json');
if (!defined('MAGESTOCK_UPDATE_HISTORY_FILE')) define('MAGESTOCK_UPDATE_HISTORY_FILE', DATA_DIR . '/update_history.json');
if (!defined('MAGESTOCK_UPDATE_TRANSACTION_FILE')) define('MAGESTOCK_UPDATE_TRANSACTION_FILE', DATA_DIR . '/update_transaction.json');
if (!defined('MAGESTOCK_UPDATE_ROOT')) define('MAGESTOCK_UPDATE_ROOT', BACKUPS_DIR . '/updates');
if (!defined('MAGESTOCK_UPDATE_LOCK_FILE')) define('MAGESTOCK_UPDATE_LOCK_FILE', MAGESTOCK_UPDATE_ROOT . '/update.lock');
if (!defined('MAGESTOCK_UPDATE_MAX_ARCHIVE')) define('MAGESTOCK_UPDATE_MAX_ARCHIVE', 128 * 1024 * 1024);
if (!defined('MAGESTOCK_UPDATE_MAX_EXPANDED')) define('MAGESTOCK_UPDATE_MAX_EXPANDED', 512 * 1024 * 1024);

class MageStockUpdateException extends RuntimeException {
    public string $reasonCode;
    public int $httpStatus;
    public array $details;
    public function __construct(string $message, string $reasonCode = 'update_error', int $httpStatus = 422, array $details = []) {
        parent::__construct($message);
        $this->reasonCode = $reasonCode;
        $this->httpStatus = $httpStatus;
        $this->details = $details;
    }
}

require_once APP_ROOT . '/includes/update-rollup.php';

function magestockUpdateNow(): string { return date('c'); }
function magestockUpdateId(): string { return date('YmdHis') . '-' . bin2hex(random_bytes(6)); }
function magestockUpdateEnsureDir(string $dir, int $mode = 0775): void {
    if (!is_dir($dir) && !@mkdir($dir, $mode, true) && !is_dir($dir)) {
        throw new MageStockUpdateException('Impossible de créer le dossier de mise à jour.', 'mkdir_failed', 500);
    }
}
function magestockUpdateAtomicWrite(string $path, string $contents, int $mode = 0664): void {
    magestockUpdateEnsureDir(dirname($path));
    $tmp = $path . '.tmp-' . bin2hex(random_bytes(5));
    if (@file_put_contents($tmp, $contents, LOCK_EX) === false) {
        @unlink($tmp);
        throw new MageStockUpdateException('Écriture de l’état de mise à jour impossible.', 'write_failed', 500);
    }
    @chmod($tmp, $mode);
    if (!@rename($tmp, $path)) {
        @unlink($tmp);
        throw new MageStockUpdateException('Finalisation de l’écriture impossible.', 'rename_failed', 500);
    }
}
function magestockUpdateReadJson(string $path, array $default = []): array {
    if (!is_file($path)) return $default;
    $raw = @file_get_contents($path);
    if (!is_string($raw) || trim($raw) === '') return $default;
    $data = json_decode($raw, true);
    return is_array($data) ? $data : $default;
}
function magestockUpdateWriteJson(string $path, array $data, int $mode = 0664): void {
    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if (!is_string($json)) throw new MageStockUpdateException('Encodage JSON impossible.', 'json_encode_failed', 500);
    magestockUpdateAtomicWrite($path, $json . "\n", $mode);
}
function magestockUpdateRelative(string $path): string {
    $root = rtrim(str_replace('\\', '/', APP_ROOT), '/') . '/';
    $path = str_replace('\\', '/', $path);
    return str_starts_with($path, $root) ? substr($path, strlen($root)) : $path;
}
function magestockUpdateReleaseManifest(): array {
    return magestockUpdateReadJson(APP_ROOT . '/release-manifest.json', []);
}
function magestockUpdatePublicKey(): array {
    $file = APP_ROOT . '/includes/update-public-key.php';
    $cfg = is_file($file) ? require $file : [];
    return is_array($cfg) ? $cfg : [];
}
function magestockUpdateCoreIssues(array $release): array {
    $issues = [];
    if (($release['application'] ?? '') !== 'MageStock') $issues[] = 'Manifeste de version absent ou invalide.';
    if (($release['version'] ?? '') !== APP_VER) $issues[] = 'Le manifeste ne correspond pas au fichier VERSION.';
    foreach (($release['coreFiles'] ?? []) as $file) {
        if (!is_array($file)) continue;
        $path = (string)($file['path'] ?? '');
        $expected = strtolower((string)($file['sha256'] ?? ''));
        if ($path === '' || $expected === '') continue;
        $full = APP_ROOT . '/' . $path;
        if (!is_file($full) || !hash_equals($expected, strtolower((string)hash_file('sha256', $full)))) {
            $issues[] = 'Fichier essentiel incohérent : ' . $path;
        }
    }
    return $issues;
}
function magestockUpdateRecoverBootstrapOrphan(array $state, array $release): array {
    if (($state['updateStatus'] ?? '') !== 'rollback_required') return $state;
    $tx = magestockUpdateReadJson(MAGESTOCK_UPDATE_TRANSACTION_FILE, []);
    if (!$tx || ($tx['status'] ?? '') !== 'rollback_required') return $state;

    $packagePath = trim((string)($tx['packagePath'] ?? ''));
    $rollbackDir = trim((string)($tx['rollbackDir'] ?? ''));
    if (($packagePath !== '' && is_file($packagePath)) || ($rollbackDir !== '' && is_dir($rollbackDir))) return $state;

    $installed = trim((string)($state['installedVersion'] ?? APP_VER));
    $releaseVersion = trim((string)($release['version'] ?? ''));
    if ($installed !== APP_VER || $releaseVersion !== APP_VER || magestockUpdateCoreIssues($release)) return $state;

    $now = magestockUpdateNow();
    $tx['status'] = 'abandoned';
    $tx['lastError'] = 'Ancien état de restauration sans archive ni point de restauration, nettoyé automatiquement.';
    $tx['updatedAt'] = $now;
    magestockUpdateWriteJson(MAGESTOCK_UPDATE_TRANSACTION_FILE, $tx, 0600);

    $history = magestockUpdateReadJson(MAGESTOCK_UPDATE_HISTORY_FILE, []);
    $alreadyLogged = false;
    foreach ($history as $entry) {
        if (($entry['id'] ?? null) === ($tx['id'] ?? null) && ($entry['status'] ?? '') === 'abandoned') { $alreadyLogged = true; break; }
    }
    if (!$alreadyLogged) {
        array_unshift($history, [
            'id' => $tx['id'] ?? magestockUpdateId(),
            'fromVersion' => $tx['fromVersion'] ?? $installed,
            'toVersion' => $tx['targetVersion'] ?? null,
            'method' => 'recovery',
            'status' => 'abandoned',
            'actor' => $tx['actorName'] ?? 'Administrateur',
            'completedAt' => $now,
            'error' => $tx['lastError'],
            'packageSha256' => $tx['packageSha256'] ?? null,
        ]);
        if (count($history) > 100) $history = array_slice($history, 0, 100);
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_HISTORY_FILE, $history);
    }

    $state['codeVersion'] = APP_VER;
    $state['installedVersion'] = APP_VER;
    $state['migrationVersion'] = APP_VER;
    $state['targetVersion'] = null;
    $state['updateStatus'] = 'completed';
    $state['method'] = 'recovery';
    $state['lastUpdateAt'] = $now;
    $state['lastError'] = null;
    $state['lastCheckedAt'] = $now;
    return $state;
}
function magestockUpdateDefaultState(string $installedVersion, string $status = 'completed'): array {
    return [
        'installedVersion' => $installedVersion,
        'migrationVersion' => $installedVersion,
        'codeVersion' => APP_VER,
        'targetVersion' => null,
        'updateStatus' => $status,
        'method' => null,
        'lastUpdateAt' => null,
        'lastCheckedAt' => magestockUpdateNow(),
        'lastError' => null,
    ];
}
function magestockUpdateBootstrapState(): array {
    magestockUpdateEnsureDir(MAGESTOCK_UPDATE_ROOT);
    $state = magestockUpdateReadJson(MAGESTOCK_UPDATE_STATE_FILE, []);
    $release = magestockUpdateReleaseManifest();
    $users = function_exists('loadData') ? loadData('users') : [];
    $freshInstall = count($users) === 0;

    if (!$state) {
        $from = trim((string)($release['fromVersion'] ?? ''));
        if (!$freshInstall && $from !== '' && version_compare(APP_VER, $from, '>')) {
            $state = magestockUpdateDefaultState($from, 'pending_manual');
            $state['targetVersion'] = APP_VER;
            $state['method'] = 'manual_deploy';
        } else {
            $state = magestockUpdateDefaultState(APP_VER, 'completed');
        }
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
    }

    $state['codeVersion'] = APP_VER;
    $installed = trim((string)($state['installedVersion'] ?? APP_VER));
    $status = (string)($state['updateStatus'] ?? 'completed');
    if (!in_array($status, ['uploaded','verified','backup_created','staged','files_replaced','migrations_running','health_check','rollback_required'], true)) {
        if (version_compare(APP_VER, $installed, '>')) {
            $state['updateStatus'] = 'pending_manual';
            $state['targetVersion'] = APP_VER;
            $state['method'] = 'manual_deploy';
        } elseif (version_compare(APP_VER, $installed, '<')) {
            $state['updateStatus'] = 'inconsistent';
            $state['targetVersion'] = APP_VER;
            $state['lastError'] = 'La version du code est antérieure à la version installée.';
        } elseif (($state['updateStatus'] ?? '') === 'pending_manual') {
            $state['updateStatus'] = 'completed';
            $state['targetVersion'] = null;
        }
    }
    $state = magestockUpdateRecoverBootstrapOrphan($state, $release);
    $state['lastCheckedAt'] = magestockUpdateNow();
    magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
    return $state;
}
function magestockUpdateState(): array { return magestockUpdateBootstrapState(); }
function magestockUpdateTransaction(): array { return magestockUpdateReadJson(MAGESTOCK_UPDATE_TRANSACTION_FILE, []); }
function magestockUpdateSaveTransaction(array $tx): void { magestockUpdateWriteJson(MAGESTOCK_UPDATE_TRANSACTION_FILE, $tx, 0600); }
function magestockUpdateHistory(): array { return magestockUpdateReadJson(MAGESTOCK_UPDATE_HISTORY_FILE, []); }
function magestockUpdateAppendHistory(array $entry): void {
    $history = magestockUpdateHistory();
    array_unshift($history, $entry);
    if (count($history) > 100) $history = array_slice($history, 0, 100);
    magestockUpdateWriteJson(MAGESTOCK_UPDATE_HISTORY_FILE, $history);
}
function magestockUpdateStatus(): array {
    $state = magestockUpdateState();
    $release = magestockUpdateReleaseManifest();
    $issues = magestockUpdateCoreIssues($release);
    if ($issues && !in_array((string)$state['updateStatus'], ['uploaded','verified','backup_created','staged','files_replaced','migrations_running','health_check','rollback_required'], true)) {
        $state['updateStatus'] = 'inconsistent';
        $state['lastError'] = implode(' ', $issues);
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
    }
    return [
        'state' => $state,
        'transaction' => magestockUpdateTransaction(),
        'history' => array_slice(magestockUpdateHistory(), 0, 20),
        'release' => $release,
        'issues' => $issues,
        'requiresAttention' => !in_array((string)($state['updateStatus'] ?? ''), ['completed'], true),
        'canUpload' => class_exists('PharData') && function_exists('sodium_crypto_sign_verify_detached'),
        'zipReader' => class_exists('ZipArchive') ? 'ZipArchive' : (class_exists('PharData') ? 'PharData' : 'absent'),
        'signatureReady' => !empty(magestockUpdatePublicKey()['publicKey']),
    ];
}
function magestockUpdateNeedsAttention(): bool { return (bool)(magestockUpdateStatus()['requiresAttention'] ?? false); }
function magestockUpdateIsBusyStatus(string $status): bool {
    return in_array($status, ['uploaded','verified','backup_created','staged','files_replaced','migrations_running','health_check','rollback_required'], true);
}
function magestockUpdateNormalizePath(string $path): string {
    $path = str_replace('\\', '/', trim($path));
    if ($path === '' || str_contains($path, "\0") || str_starts_with($path, '/') || preg_match('/^[A-Za-z]:\//', $path)) {
        throw new MageStockUpdateException('Chemin d’archive invalide.', 'unsafe_path');
    }
    $parts = [];
    foreach (explode('/', $path) as $part) {
        if ($part === '' || $part === '.') continue;
        if ($part === '..') throw new MageStockUpdateException('Traversée de dossier interdite.', 'path_traversal');
        $parts[] = $part;
    }
    if (!$parts) throw new MageStockUpdateException('Chemin d’archive vide.', 'unsafe_path');
    return implode('/', $parts);
}
function magestockUpdateProtectedPath(string $path): bool {
    $path = strtolower(magestockUpdateNormalizePath($path));
    $first = explode('/', $path)[0] ?? '';
    if (in_array($first, ['data','uploads','backups','logs','secrets','.git'], true)) return true;
    if (in_array($path, ['.env','includes/update-public-key.php'], true)) return true;
    if (preg_match('/(^|\/)\.secrets(\/|$)/', $path)) return true;
    return false;
}
function magestockUpdateArchiveEntries(PharData $phar, string $zipPath): array {
    $entries = [];
    $prefix = 'phar://' . str_replace('\\', '/', $zipPath) . '/';
    $it = new RecursiveIteratorIterator($phar, RecursiveIteratorIterator::LEAVES_ONLY);
    foreach ($it as $file) {
        if (!$file instanceof SplFileInfo || $file->isDir()) continue;
        $pathname = str_replace('\\', '/', $file->getPathname());
        $name = str_starts_with($pathname, $prefix) ? substr($pathname, strlen($prefix)) : $pathname;
        $name = magestockUpdateNormalizePath($name);
        if ($file->isLink()) throw new MageStockUpdateException('Les liens symboliques sont interdits.', 'symlink_forbidden');
        $entries[$name] = ['size'=>(int)$file->getSize(), 'file'=>$file];
    }
    return $entries;
}
function magestockUpdateReadArchiveEntry(array $entries, string $name): string {
    if (!isset($entries[$name])) throw new MageStockUpdateException('Fichier absent de l’archive : ' . $name, 'archive_entry_missing');
    $raw = @file_get_contents($entries[$name]['file']->getPathname());
    if (!is_string($raw)) throw new MageStockUpdateException('Lecture impossible dans l’archive : ' . $name, 'archive_read_failed');
    return $raw;
}
/**
 * Une distribution complète doit embarquer la clé publique qui permettra les mises à jour
 * futures. Elle n’est acceptée que si ses octets sont identiques à la racine déjà installée.
 * Cette exception ne s’applique jamais à un paquet UPDATE ordinaire.
 */
function magestockUpdateDistributionTrustRootMatches(array $entries, string $entry): bool {
    $installedPath = APP_ROOT . '/includes/update-public-key.php';
    if (!is_file($installedPath) || !is_readable($installedPath) || !isset($entries[$entry])) return false;
    $installedRaw = @file_get_contents($installedPath);
    if (!is_string($installedRaw)) return false;
    $packagedRaw = magestockUpdateReadArchiveEntry($entries, $entry);
    return hash_equals(hash('sha256', $installedRaw), hash('sha256', $packagedRaw));
}
function magestockUpdateVerifySignature(string $manifestRaw, string $signatureRaw): array {
    $key = magestockUpdatePublicKey();
    $public = base64_decode((string)($key['publicKey'] ?? ''), true);
    $signature = base64_decode(trim($signatureRaw), true);
    if (!is_string($public) || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
        throw new MageStockUpdateException('Clé publique de mise à jour absente ou invalide.', 'public_key_invalid', 500);
    }
    if (!is_string($signature) || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES || !sodium_crypto_sign_verify_detached($signature, $manifestRaw, $public)) {
        throw new MageStockUpdateException('La signature de cette mise à jour est invalide.', 'invalid_signature');
    }
    return $key;
}
function magestockUpdateAnalyzePackage(string $zipPath): array {
    if (!is_file($zipPath) || !is_readable($zipPath)) throw new MageStockUpdateException('Archive introuvable.', 'archive_missing');
    $size = (int)filesize($zipPath);
    if ($size < 1 || $size > MAGESTOCK_UPDATE_MAX_ARCHIVE) throw new MageStockUpdateException('Archive trop volumineuse ou vide.', 'archive_size');
    try { $phar = new PharData($zipPath); } catch (Throwable $e) { throw new MageStockUpdateException('Archive ZIP illisible.', 'archive_invalid'); }
    $entries = magestockUpdateArchiveEntries($phar, $zipPath);
    if (count($entries) > 5000) throw new MageStockUpdateException('Archive contenant trop de fichiers.', 'too_many_entries');
    $expanded = array_sum(array_column($entries, 'size'));
    if ($expanded > MAGESTOCK_UPDATE_MAX_EXPANDED) throw new MageStockUpdateException('Archive décompressée trop volumineuse.', 'expanded_size');
    if ($expanded > max(32 * 1024 * 1024, $size * 200)) throw new MageStockUpdateException('Taux de compression anormal : archive potentiellement dangereuse.', 'compression_ratio');

    $manifestEntry = null; $signatureEntry = null; $packageType = null; $rootPrefix = '';
    foreach (array_keys($entries) as $entry) {
        if ($entry === 'UPDATE-MANIFEST.json') { $manifestEntry = $entry; $signatureEntry = 'UPDATE-SIGNATURE.txt'; $packageType = 'update'; break; }
    }
    if (!$manifestEntry) {
        foreach (array_keys($entries) as $entry) {
            if ($entry === 'release-manifest.json' || str_ends_with($entry, '/release-manifest.json')) {
                $manifestEntry = $entry;
                $rootPrefix = substr($entry, 0, -strlen('release-manifest.json'));
                $signatureEntry = $rootPrefix . 'release-signature.txt';
                $packageType = 'distribution';
                break;
            }
        }
    }
    if (!$manifestEntry || !$signatureEntry || !isset($entries[$signatureEntry])) throw new MageStockUpdateException('Manifeste signé MageStock absent.', 'manifest_missing');
    $manifestRaw = magestockUpdateReadArchiveEntry($entries, $manifestEntry);
    $signatureRaw = magestockUpdateReadArchiveEntry($entries, $signatureEntry);
    $key = magestockUpdateVerifySignature($manifestRaw, $signatureRaw);
    $manifest = json_decode($manifestRaw, true);
    if (!is_array($manifest) || ($manifest['application'] ?? '') !== 'MageStock') throw new MageStockUpdateException('Manifeste MageStock invalide.', 'manifest_invalid');
    $target = magestockUpdateVersion((string)($manifest[$packageType === 'update' ? 'toVersion' : 'version'] ?? ''), 'version cible');
    $state = magestockUpdateState();
    $installed = magestockUpdateVersion((string)($state['installedVersion'] ?? APP_VER), 'version installée');
    if (!version_compare($target, $installed, '>')) throw new MageStockUpdateException('La version cible doit être plus récente que la version installée.', 'not_newer');
    $contract = magestockUpdatePackageContract($manifest, $packageType, $target);
    if (!magestockUpdateContractSupports($contract, $installed)) {
        $minimum = (string)($contract['minimumSourceVersion'] ?? '');
        $maximum = (string)($contract['maximumSourceVersion'] ?? '');
        $expected = $maximum !== '' && $minimum !== $maximum ? $minimum . ' à ' . $maximum : $minimum;
        throw new MageStockUpdateException(
            'Ce paquet ' . magestockUpdateContractLabel($contract) . ' accepte MageStock ' . $expected . ', pas la version ' . $installed . '.',
            'incompatible_source',
            422,
            ['installedVersion'=>$installed,'minimumSourceVersion'=>$minimum,'maximumSourceVersion'=>$maximum,'packageMode'=>$contract['packageMode'] ?? null]
        );
    }
    $minimumPhp = (string)($manifest['minimumPhp'] ?? '8.1');
    if (version_compare(PHP_VERSION, $minimumPhp, '<')) throw new MageStockUpdateException('PHP ' . $minimumPhp . ' ou supérieur est requis.', 'php_incompatible');
    $missingExt = [];
    foreach (($manifest['requiredExtensions'] ?? []) as $ext) if (!extension_loaded((string)$ext) && !in_array((string)$ext, ['zip'], true)) $missingExt[] = (string)$ext;
    if ($missingExt) throw new MageStockUpdateException('Extensions PHP manquantes : ' . implode(', ', $missingExt), 'extensions_missing');

    $files = $manifest['files'] ?? [];
    if (!is_array($files) || !$files) throw new MageStockUpdateException('Liste des fichiers absente.', 'files_missing');
    $normalizedFiles = [];
    foreach ($files as $record) {
        if (!is_array($record)) throw new MageStockUpdateException('Entrée de manifeste invalide.', 'file_record_invalid');
        $path = magestockUpdateNormalizePath((string)($record['path'] ?? ''));
        $entry = $packageType === 'update' ? 'payload/' . $path : $rootPrefix . $path;
        if (magestockUpdateProtectedPath($path)) {
            $sameInstalledTrustRoot = $packageType === 'distribution'
                && $path === 'includes/update-public-key.php'
                && magestockUpdateDistributionTrustRootMatches($entries, $entry);
            if (!$sameInstalledTrustRoot) {
                throw new MageStockUpdateException('Le paquet tente de modifier un chemin protégé : ' . $path, 'protected_path');
            }
        }
        if (!isset($entries[$entry])) throw new MageStockUpdateException('Fichier déclaré absent : ' . $path, 'file_missing');
        $expected = strtolower((string)($record['sha256'] ?? ''));
        $actual = strtolower(hash('sha256', magestockUpdateReadArchiveEntry($entries, $entry)));
        if ($expected === '' || !hash_equals($expected, $actual)) throw new MageStockUpdateException('Checksum invalide : ' . $path, 'checksum_failed');
        $normalizedFiles[] = ['path'=>$path,'entry'=>$entry,'sha256'=>$actual,'size'=>(int)$entries[$entry]['size']];
    }
    $migrations = magestockUpdateNormalizeMigrations($manifest, $contract);
    if (($contract['schemaVersion'] ?? 1) >= 2) {
        $declaredPaths = array_fill_keys(array_column($normalizedFiles, 'path'), true);
        foreach ($migrations as $migration) {
            if (!isset($declaredPaths[$migration['path']])) {
                throw new MageStockUpdateException('La migration doit être incluse et déclarée dans le paquet : ' . $migration['path'], 'migration_undeclared');
            }
        }
    }
    if ($packageType === 'distribution') {
        $normalizedFiles[] = ['path'=>'release-manifest.json','entry'=>$manifestEntry,'sha256'=>strtolower(hash('sha256',$manifestRaw)),'size'=>(int)$entries[$manifestEntry]['size']];
        $normalizedFiles[] = ['path'=>'release-signature.txt','entry'=>$signatureEntry,'sha256'=>strtolower(hash('sha256',$signatureRaw)),'size'=>(int)$entries[$signatureEntry]['size']];
    }
    $declaredEntries = array_fill_keys(array_column($normalizedFiles, 'entry'), true);
    foreach (array_keys($entries) as $entryName) {
        if (isset($declaredEntries[$entryName]) || $entryName === $manifestEntry || $entryName === $signatureEntry) continue;
        if ($packageType === 'update') {
            if (str_starts_with($entryName, 'payload/')) throw new MageStockUpdateException('Fichier non déclaré dans le paquet : ' . $entryName, 'undeclared_file');
            continue;
        }
        if (!str_starts_with($entryName, $rootPrefix)) continue;
        $relativeExtra = substr($entryName, strlen($rootPrefix));
        if ($relativeExtra === '' || magestockUpdateProtectedPath($relativeExtra)) continue;
        throw new MageStockUpdateException('Fichier non déclaré dans la distribution : ' . $relativeExtra, 'undeclared_file');
    }
    $deletions = [];
    foreach (($manifest['deletions'] ?? []) as $path) {
        $path = magestockUpdateNormalizePath((string)$path);
        if (magestockUpdateProtectedPath($path)) throw new MageStockUpdateException('Suppression d’un chemin protégé interdite : ' . $path, 'protected_path');
        $deletions[] = $path;
    }
    return [
        'packageType'=>$packageType,'targetVersion'=>$target,'installedVersion'=>$installed,
        'manifest'=>$manifest,'manifestRaw'=>$manifestRaw,'signatureRaw'=>trim($signatureRaw),
        'manifestEntry'=>$manifestEntry,'signatureEntry'=>$signatureEntry,'rootPrefix'=>$rootPrefix,
        'files'=>$normalizedFiles,'deletions'=>$deletions,'migrations'=>$migrations,'packageSha256'=>hash_file('sha256',$zipPath),
        'archiveSize'=>$size,'expandedSize'=>$expanded,'fileCount'=>count($normalizedFiles),
        'keyId'=>$key['keyId'] ?? null,'zipPath'=>$zipPath,
        'schemaVersion'=>$contract['schemaVersion'] ?? 1,'packageMode'=>$contract['packageMode'] ?? $packageType,
        'minimumSourceVersion'=>$contract['minimumSourceVersion'] ?? null,
        'maximumSourceVersion'=>$contract['maximumSourceVersion'] ?? null,
        'migrationBaseline'=>$contract['migrationBaseline'] ?? null,
    ];
}

/** Convertit une taille en libellé lisible sans dépendance externe. */
function magestockUpdateFormatBytes(int $bytes): string {
    if ($bytes < 1024) return $bytes . ' o';
    $units = ['Ko','Mo','Go','To']; $value = $bytes / 1024;
    foreach ($units as $unit) {
        if ($value < 1024 || $unit === 'To') return number_format($value, $value >= 10 ? 0 : 1, ',', ' ') . ' ' . $unit;
        $value /= 1024;
    }
    return $bytes . ' o';
}
function magestockUpdateFsMode(string $path): ?string {
    $perms = @fileperms($path);
    return is_int($perms) ? sprintf('%04o', $perms & 0777) : null;
}
function magestockUpdateFsRelative(string $path): string {
    $root = rtrim(str_replace('\\', '/', APP_ROOT), '/');
    $normalized = rtrim(str_replace('\\', '/', $path), '/');
    if ($normalized === $root) return '.';
    $prefix = $root . '/';
    return str_starts_with($normalized, $prefix) ? substr($normalized, strlen($prefix)) : basename($normalized);
}
function magestockUpdateFsRecommendation(): string {
    return 'Corrigez le propriétaire ou les ACL afin que le processus PHP puisse créer, renommer et supprimer des fichiers. Valeurs habituelles : dossiers 0755/0775, fichiers 0644/0664. N’utilisez pas 0777.';
}
function magestockUpdateFsResolveParent(string $directory): array {
    $directory = rtrim($directory, DIRECTORY_SEPARATOR); $missing = []; $cursor = $directory;
    while (!is_dir($cursor)) {
        if (file_exists($cursor)) return ['ok'=>false,'existing'=>$cursor,'missing'=>$missing,'reason'=>'Un fichier occupe la place d’un dossier requis.'];
        $missing[] = basename($cursor); $parent = dirname($cursor);
        if ($parent === $cursor) return ['ok'=>false,'existing'=>$cursor,'missing'=>$missing,'reason'=>'Aucun dossier parent exploitable.'];
        $cursor = $parent;
    }
    return ['ok'=>true,'existing'=>$cursor,'missing'=>array_reverse($missing),'reason'=>null];
}
/** Test non destructif de création, remplacement atomique et suppression. */
function magestockUpdateFsProbeDirectory(string $directory): array {
    if (!is_dir($directory)) return ['ok'=>false,'reason'=>'Dossier parent absent.'];
    if (is_link($directory)) return ['ok'=>false,'reason'=>'Un lien symbolique ne peut pas servir de dossier cible.'];
    $token = '.magestock-update-preflight-' . bin2hex(random_bytes(6));
    $first = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $token . '-current';
    $next = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $token . '-next';
    $cleanup = static function() use ($first, $next): void { @unlink($first); @unlink($next); };
    if (@file_put_contents($first, 'current', LOCK_EX) === false) { $cleanup(); return ['ok'=>false,'reason'=>'PHP ne peut pas créer de fichier temporaire dans ce dossier.']; }
    if (@file_put_contents($next, 'next', LOCK_EX) === false) { $cleanup(); return ['ok'=>false,'reason'=>'PHP ne peut pas créer le fichier de remplacement dans ce dossier.']; }
    if (!@rename($next, $first)) { $cleanup(); return ['ok'=>false,'reason'=>'PHP ne peut pas remplacer atomiquement un fichier existant dans ce dossier.']; }
    if (!@unlink($first)) { $cleanup(); return ['ok'=>false,'reason'=>'PHP ne peut pas supprimer un fichier temporaire dans ce dossier.']; }
    return ['ok'=>true,'reason'=>null];
}
function magestockUpdateFsTreeStats(string $root): array {
    $bytes = 0; $unreadable = [];
    if (!is_dir($root)) return ['bytes'=>0,'unreadable'=>[]];
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY);
    foreach ($it as $item) {
        if (!$item->isFile()) continue;
        $bytes += max(0, (int)$item->getSize());
        if (!is_readable($item->getPathname())) $unreadable[] = magestockUpdateRelative($item->getPathname());
    }
    return ['bytes'=>$bytes,'unreadable'=>$unreadable];
}
/** Vérifie toutes les opérations avant sauvegarde et maintenance. */
function magestockUpdateFilesystemPreflight(array $analysis): array {
    $blockers = []; $warnings = []; $groups = []; $backupBytes = 0; $records = [];
    foreach (($analysis['files'] ?? []) as $record) if (is_array($record) && !empty($record['path'])) $records[] = ['path'=>(string)$record['path'],'operation'=>'replace'];
    foreach (($analysis['deletions'] ?? []) as $path) $records[] = ['path'=>(string)$path,'operation'=>'delete'];

    foreach ($records as $record) {
        $path = magestockUpdateNormalizePath($record['path']); $dest = APP_ROOT . '/' . $path; $parent = dirname($dest);
        if (is_link($dest)) { $blockers[] = ['code'=>'target_symlink','path'=>$path,'reason'=>'La cible est un lien symbolique, opération refusée.','recommendation'=>'Remplacez le lien symbolique par un fichier normal avant la mise à jour.']; continue; }
        if (file_exists($dest) && !is_file($dest)) { $blockers[] = ['code'=>'target_not_file','path'=>$path,'reason'=>'La cible existe mais n’est pas un fichier ordinaire.','recommendation'=>'Corrigez le conflit de chemin avant la mise à jour.']; continue; }
        if (is_file($dest)) {
            $backupBytes += max(0, (int)@filesize($dest));
            if (!is_readable($dest)) $blockers[] = ['code'=>'target_not_readable','path'=>$path,'reason'=>'Le fichier existant ne peut pas être lu pour la sauvegarde.','mode'=>magestockUpdateFsMode($dest),'recommendation'=>'Autorisez la lecture du fichier par le processus PHP avant la mise à jour.'];
            elseif (!is_writable($dest)) $warnings[] = ['code'=>'target_not_directly_writable','path'=>$path,'reason'=>'Le fichier n’est pas directement inscriptible ; le remplacement atomique dépendra des droits du dossier parent.','mode'=>magestockUpdateFsMode($dest)];
        }
        $resolved = magestockUpdateFsResolveParent($parent);
        if (!$resolved['ok']) { $blockers[] = ['code'=>'parent_conflict','path'=>$path,'directory'=>magestockUpdateFsRelative($parent),'reason'=>$resolved['reason'],'recommendation'=>magestockUpdateFsRecommendation()]; continue; }
        $probeDir = $resolved['existing']; $key = str_replace('\\','/',$probeDir);
        if (!isset($groups[$key])) $groups[$key] = ['directory'=>$probeDir,'paths'=>[]];
        $groups[$key]['paths'][] = $path;
    }
    foreach ($groups as $group) {
        $probe = magestockUpdateFsProbeDirectory($group['directory']);
        if (!$probe['ok']) {
            $paths = array_values(array_unique($group['paths'])); sort($paths);
            $blockers[] = ['code'=>'directory_replace_failed','directory'=>magestockUpdateFsRelative($group['directory']),'affectedPaths'=>$paths,'reason'=>$probe['reason'],'mode'=>magestockUpdateFsMode($group['directory']),'recommendation'=>magestockUpdateFsRecommendation()];
        }
    }
    $updateResolved = magestockUpdateFsResolveParent(MAGESTOCK_UPDATE_ROOT);
    if (!$updateResolved['ok']) $blockers[] = ['code'=>'update_root_unavailable','directory'=>'backups/updates','reason'=>$updateResolved['reason'],'recommendation'=>magestockUpdateFsRecommendation()];
    else {
        $probe = magestockUpdateFsProbeDirectory($updateResolved['existing']);
        if (!$probe['ok']) $blockers[] = ['code'=>'update_root_not_writable','directory'=>magestockUpdateFsRelative($updateResolved['existing']),'reason'=>'Le stockage de staging et de restauration n’est pas exploitable : ' . $probe['reason'],'mode'=>magestockUpdateFsMode($updateResolved['existing']),'recommendation'=>magestockUpdateFsRecommendation()];
    }
    $dataStats = magestockUpdateFsTreeStats(DATA_DIR);
    foreach ($dataStats['unreadable'] as $path) $blockers[] = ['code'=>'data_not_readable','path'=>$path,'reason'=>'Ce fichier persistant ne peut pas être lu pour la sauvegarde préalable.','recommendation'=>'Autorisez sa lecture par le processus PHP.'];
    if (!is_dir(DATA_DIR) || !is_writable(DATA_DIR)) $blockers[] = ['code'=>'data_not_writable','directory'=>'data','reason'=>'Le dossier data doit rester inscriptible pendant les migrations et la restauration.','mode'=>magestockUpdateFsMode(DATA_DIR),'recommendation'=>'Corrigez le propriétaire ou les ACL du dossier data sans l’exposer au Web.'];

    $requiredBytes = (int)($analysis['expandedSize'] ?? 0) + $backupBytes + (int)$dataStats['bytes'] + 16 * 1024 * 1024;
    $freeBytes = @disk_free_space(is_dir(MAGESTOCK_UPDATE_ROOT) ? MAGESTOCK_UPDATE_ROOT : BACKUPS_DIR);
    if (is_float($freeBytes) || is_int($freeBytes)) {
        $freeBytes = (int)$freeBytes;
        if ($freeBytes < $requiredBytes) $blockers[] = ['code'=>'disk_space_insufficient','reason'=>'Espace disque insuffisant pour le staging, la sauvegarde et le rollback.','requiredBytes'=>$requiredBytes,'freeBytes'=>$freeBytes,'recommendation'=>'Libérez au minimum ' . magestockUpdateFormatBytes($requiredBytes - $freeBytes) . ' supplémentaires avant de relancer.'];
    } else $freeBytes = null;
    if (getenv('MAGESTOCK_UPDATE_TEST_MODE') === '1' && getenv('MAGESTOCK_UPDATE_FORCE_PREFLIGHT_FAIL') === '1') $blockers[] = ['code'=>'preflight_forced','path'=>'CHANGELOG.md','reason'=>'Échec de précontrôle simulé.','recommendation'=>'Simulation de recette uniquement.'];
    return ['ready'=>count($blockers) === 0,'checkedAt'=>magestockUpdateNow(),'targetCount'=>count($records),'directoryCount'=>count($groups),'requiredBytes'=>$requiredBytes,'freeBytes'=>$freeBytes,'blockers'=>$blockers,'warnings'=>$warnings];
}
function magestockUpdatePreflightMessage(array $preflight): string {
    $count = count($preflight['blockers'] ?? []);
    return $count < 1 ? 'Le système de fichiers est prêt pour la mise à jour.' : 'Précontrôle bloquant : ' . $count . ' problème' . ($count > 1 ? 's' : '') . ' de permissions ou de stockage. Aucun fichier applicatif n’a été modifié.';
}
function magestockUpdateSetPreflightState(array $analysis, array $preflight, array $tx, bool $ready): array {
    $tx['analysis'] = array_diff_key($analysis, array_flip(['manifestRaw','signatureRaw','zipPath']));
    $tx['analysis']['preflight'] = $preflight;
    $tx['status'] = $ready ? 'verified' : 'preflight_failed';
    $tx['lastError'] = $ready ? null : magestockUpdatePreflightMessage($preflight);
    $tx['updatedAt'] = magestockUpdateNow();
    magestockUpdateSaveTransaction($tx);
    $state = magestockUpdateState();
    if ($ready) {
        $state['updateStatus'] = 'verified';
        $state['targetVersion'] = $analysis['targetVersion'];
        $state['method'] = (string)($tx['method'] ?? 'upload');
        $state['lastError'] = null;
    } else {
        $state['updateStatus'] = 'completed';
        $state['targetVersion'] = null;
        $state['method'] = null;
        $state['lastError'] = $tx['lastError'];
    }
    magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
    return ['transaction'=>$tx,'analysis'=>$tx['analysis'],'preflight'=>$preflight];
}
function magestockUpdateRecheckPreflight(?array $actor = null): array {
    $tx = magestockUpdateTransaction(); if (!$tx) throw new MageStockUpdateException('Aucun paquet à contrôler.', 'no_package', 409);
    $packagePath = trim((string)($tx['packagePath'] ?? '')); if ($packagePath === '' || !is_file($packagePath)) throw new MageStockUpdateException('Archive introuvable.', 'archive_missing', 422);
    $analysis = magestockUpdateAnalyzePackage($packagePath); $preflight = magestockUpdateFilesystemPreflight($analysis);
    if ($actor) { $tx['actorId']=$actor['id'] ?? $tx['actorId'] ?? null; $tx['actorName']=$actor['name'] ?? $actor['username'] ?? $tx['actorName'] ?? 'Administrateur'; }
    return magestockUpdateSetPreflightState($analysis, $preflight, $tx, (bool)$preflight['ready']);
}

function magestockUpdateCleanupPreviousIncoming(): void {
    $previous = magestockUpdateTransaction();
    $path = trim((string)($previous['packagePath'] ?? ''));
    if ($path === '' || !is_file($path)) return;
    $incomingRoot = rtrim(str_replace('\\', '/', MAGESTOCK_UPDATE_ROOT . '/incoming'), '/') . '/';
    $normalized = str_replace('\\', '/', $path);
    if (str_starts_with($normalized, $incomingRoot) && !magestockUpdateIsBusyStatus((string)($previous['status'] ?? ''))) @unlink($path);
}
function magestockUpdateStoreLocalPackage(string $sourcePath, ?array $actor = null, string $method = 'upload', bool $move = false, array $metadata = []): array {
    if (!is_file($sourcePath) || !is_readable($sourcePath)) throw new MageStockUpdateException('Archive locale introuvable.', 'package_local_missing');
    $size = (int)filesize($sourcePath);
    if ($size < 1 || $size > MAGESTOCK_UPDATE_MAX_ARCHIVE) throw new MageStockUpdateException('Archive locale vide ou trop volumineuse.', 'package_local_size');
    magestockUpdateCleanupPreviousIncoming();
    $id = magestockUpdateId();
    $dir = MAGESTOCK_UPDATE_ROOT . '/incoming';
    magestockUpdateEnsureDir($dir);
    $dest = $dir . '/' . $id . '.zip';
    $stored = $move ? @rename($sourcePath, $dest) : @copy($sourcePath, $dest);
    if (!$stored) throw new MageStockUpdateException('Enregistrement du ZIP impossible.', 'package_store_failed', 500);
    @chmod($dest, 0600);
    try {
        $analysis = magestockUpdateAnalyzePackage($dest);
        $expectedTarget = trim((string)($metadata['expectedTargetVersion'] ?? ''));
        if ($expectedTarget !== '' && !hash_equals($expectedTarget, (string)$analysis['targetVersion'])) {
            throw new MageStockUpdateException('La cible du ZIP ne correspond pas à la version publiée.', 'remote_target_mismatch');
        }
        $expectedSha = strtolower(trim((string)($metadata['expectedPackageSha256'] ?? '')));
        if ($expectedSha !== '' && !hash_equals($expectedSha, strtolower((string)$analysis['packageSha256']))) {
            throw new MageStockUpdateException('Le ZIP analysé ne correspond plus à son manifeste distant.', 'remote_package_mismatch');
        }
    } catch (Throwable $e) {
        @unlink($dest);
        throw $e;
    }
    $preflight = magestockUpdateFilesystemPreflight($analysis);
    $tx = [
        'id'=>$id,
        'status'=>'uploaded',
        'method'=>$method,
        'packagePath'=>$dest,
        'packageSha256'=>$analysis['packageSha256'],
        'packageType'=>$analysis['packageType'],
        'fromVersion'=>$analysis['installedVersion'],
        'targetVersion'=>$analysis['targetVersion'],
        'analysis'=>[],
        'actorId'=>$actor['id'] ?? null,
        'actorName'=>$actor['name'] ?? $actor['username'] ?? 'Administrateur',
        'createdAt'=>magestockUpdateNow(),
        'updatedAt'=>magestockUpdateNow(),
        'lastError'=>null,
    ];
    foreach (['remoteProvider','remoteFilename','remoteManifestSha256'] as $key) {
        if (isset($metadata[$key]) && is_scalar($metadata[$key])) $tx[$key] = (string)$metadata[$key];
    }
    return magestockUpdateSetPreflightState($analysis, $preflight, $tx, (bool)$preflight['ready']);
}
function magestockUpdateStoreUpload(array $file, ?array $actor = null): array {
    if ((int)($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) throw new MageStockUpdateException('Le ZIP n’a pas pu être envoyé.', 'upload_failed');
    $tmp = (string)($file['tmp_name'] ?? '');
    if ($tmp === '' || (!is_uploaded_file($tmp) && PHP_SAPI !== 'cli')) throw new MageStockUpdateException('Fichier envoyé invalide.', 'upload_invalid');
    return magestockUpdateStoreLocalPackage($tmp, $actor, 'upload', true);
}
function magestockUpdateCopyTree(string $source, string $dest, array $exclude = []): void {
    if (!is_dir($source)) return;
    magestockUpdateEnsureDir($dest);
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
    foreach ($it as $item) {
        $relative = substr(str_replace('\\','/',$item->getPathname()), strlen(rtrim(str_replace('\\','/',$source),'/')) + 1);
        if (in_array($relative, $exclude, true)) continue;
        $target = $dest . '/' . $relative;
        if ($item->isDir()) magestockUpdateEnsureDir($target);
        elseif (!@copy($item->getPathname(), $target)) throw new MageStockUpdateException('Sauvegarde impossible : ' . $relative, 'backup_failed', 500);
    }
}
function magestockUpdateRemoveTree(string $path): void {
    if (!file_exists($path)) return;
    if (is_file($path) || is_link($path)) { @unlink($path); return; }
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
    foreach ($it as $item) $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
    @rmdir($path);
}
function magestockUpdateFailpoint(string $phase): void {
    if (getenv('MAGESTOCK_UPDATE_TEST_MODE') !== '1') return;
    if (getenv('MAGESTOCK_UPDATE_CRASH_PHASE') === $phase) {
        // Recette uniquement : simule une coupure franche sans exécuter le rollback du même processus.
        exit(86);
    }
    if (getenv('MAGESTOCK_UPDATE_FAIL_PHASE') === $phase) {
        throw new MageStockUpdateException('Échec simulé à la phase ' . $phase . '.', 'simulated_' . $phase, 500);
    }
}
function magestockUpdateSnapshotData(string $dest): void {
    if (getenv('MAGESTOCK_UPDATE_TEST_MODE') === '1' && getenv('MAGESTOCK_UPDATE_FORCE_DISK_FULL') === '1') {
        throw new MageStockUpdateException('Espace disque insuffisant (simulation).', 'disk_full', 507);
    }
    magestockUpdateCopyTree(DATA_DIR, $dest, ['update_transaction.json']);
}
function magestockUpdateExtractToStage(array $analysis, string $stage): void {
    magestockUpdateEnsureDir($stage);
    $phar = new PharData($analysis['zipPath']);
    $entries = magestockUpdateArchiveEntries($phar, $analysis['zipPath']);
    foreach ($analysis['files'] as $record) {
        $target = $stage . '/' . $record['path'];
        magestockUpdateEnsureDir(dirname($target));
        magestockUpdateAtomicWrite($target, magestockUpdateReadArchiveEntry($entries, $record['entry']), 0644);
    }
    if ($analysis['packageType'] === 'distribution') {
        magestockUpdateAtomicWrite($stage . '/release-manifest.json', $analysis['manifestRaw'], 0644);
        magestockUpdateAtomicWrite($stage . '/release-signature.txt', $analysis['signatureRaw'] . "\n", 0644);
    }
}
function magestockUpdateBackupTargets(array $analysis, string $rollbackDir): array {
    $missing = [];
    foreach ($analysis['files'] as $record) {
        $path = $record['path']; $source = APP_ROOT . '/' . $path;
        if (is_file($source)) { $dest = $rollbackDir . '/files/' . $path; magestockUpdateEnsureDir(dirname($dest)); if (!@copy($source,$dest)) throw new MageStockUpdateException('Sauvegarde fichier impossible : '.$path,'backup_failed',500); }
        else $missing[] = $path;
    }
    foreach ($analysis['deletions'] as $path) {
        $source = APP_ROOT . '/' . $path;
        if (is_file($source)) { $dest = $rollbackDir . '/files/' . $path; magestockUpdateEnsureDir(dirname($dest)); if (!@copy($source,$dest)) throw new MageStockUpdateException('Sauvegarde fichier impossible : '.$path,'backup_failed',500); }
    }
    magestockUpdateWriteJson($rollbackDir . '/missing.json', $missing, 0600);
    return $missing;
}
function magestockUpdateReplaceFiles(array $analysis, string $stage): void {
    foreach ($analysis['files'] as $record) {
        $path = $record['path']; $source = $stage . '/' . $path; $dest = APP_ROOT . '/' . $path;
        magestockUpdateEnsureDir(dirname($dest));
        $tmp = $dest . '.update-' . bin2hex(random_bytes(4));
        if (!@copy($source, $tmp)) throw new MageStockUpdateException('Copie impossible : ' . $path, 'replace_failed', 500);
        $targetMode = is_file($dest) ? ((int)@fileperms($dest) & 0777) : 0644;
        @chmod($tmp, $targetMode > 0 ? $targetMode : 0644);
        if (!@rename($tmp, $dest)) { @unlink($tmp); throw new MageStockUpdateException('Remplacement impossible : ' . $path, 'replace_failed', 500); }
    }
    foreach ($analysis['deletions'] as $path) { $dest = APP_ROOT . '/' . $path; if (is_file($dest) && !@unlink($dest)) throw new MageStockUpdateException('Suppression impossible : '.$path,'delete_failed',500); }
}
function magestockUpdateRunMigrations(array $migrations, string $targetVersion, string $sourceVersion, array $contract = []): array {
    $done = [];
    $state = magestockUpdateState();
    $migrationHistory = is_array($state['migrations'] ?? null) ? $state['migrations'] : [];
    foreach ($migrations as $migration) {
        if (!is_array($migration)) throw new MageStockUpdateException('Migration invalide.', 'migration_invalid', 500);
        $id = (string)($migration['id'] ?? '');
        $path = (string)($migration['path'] ?? '');
        if ($id === '' || $path === '') throw new MageStockUpdateException('Migration invalide.', 'migration_invalid', 500);
        if (!magestockUpdateMigrationApplies($migration, $sourceVersion)) {
            $done[] = ['id'=>$id,'status'=>'skipped','reason'=>'Version source hors plage','sourceVersion'=>$sourceVersion];
            continue;
        }
        $already = false;
        foreach ($migrationHistory as $row) {
            if (($row['id'] ?? '') === $id && ($row['status'] ?? '') === 'completed') { $already = true; break; }
        }
        if ($already) {
            $done[] = ['id'=>$id,'status'=>'already_completed'];
            continue;
        }
        $full = APP_ROOT . '/' . magestockUpdateNormalizePath($path);
        if (!is_file($full)) throw new MageStockUpdateException('Migration absente : ' . $id, 'migration_missing', 500);
        $callable = require $full;
        if (!is_callable($callable)) throw new MageStockUpdateException('Contrat de migration invalide : ' . $id, 'migration_contract', 500);
        $started = magestockUpdateNow();
        $result = $callable([
            'appRoot'=>APP_ROOT,
            'dataDir'=>DATA_DIR,
            'sourceVersion'=>$sourceVersion,
            'targetVersion'=>$targetVersion,
            'packageMode'=>$contract['packageMode'] ?? 'exact',
            'migrationBaseline'=>$contract['migrationBaseline'] ?? $sourceVersion,
        ]);
        if ($result === false) throw new MageStockUpdateException('Échec de migration : ' . $id, 'migration_failed', 500);
        $row = ['id'=>$id,'status'=>'completed','sourceVersion'=>$sourceVersion,'targetVersion'=>$targetVersion,'startedAt'=>$started,'completedAt'=>magestockUpdateNow()];
        $migrationHistory[] = $row;
        $done[] = $row;
        $state['migrations'] = $migrationHistory;
        $state['migrationVersion'] = $targetVersion;
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
    }
    return $done;
}

function magestockUpdateHealthCheck(string $targetVersion): array {
    if (getenv('MAGESTOCK_UPDATE_TEST_MODE') === '1' && getenv('MAGESTOCK_UPDATE_FORCE_HEALTH_FAIL') === '1') {
        throw new MageStockUpdateException('Test de santé forcé en échec.', 'health_forced', 500);
    }
    $checks = [];
    $version = trim((string)@file_get_contents(APP_ROOT . '/VERSION'));
    $checks['version'] = $version === $targetVersion;
    foreach (['config.php','index.php','login.php','api/update.php','includes/update-helper.php','views/update.php'] as $path) $checks['file:' . $path] = is_file(APP_ROOT . '/' . $path) && is_readable(APP_ROOT . '/' . $path);
    foreach (glob(DATA_DIR . '/*.json') ?: [] as $jsonFile) {
        $raw = @file_get_contents($jsonFile); json_decode((string)$raw, true);
        $checks['json:' . basename($jsonFile)] = json_last_error() === JSON_ERROR_NONE;
    }
    $localAdmin = false;
    foreach (loadData('users') as $u) if (($u['role'] ?? '') === 'admin' && !empty($u['active']) && ($u['authSource'] ?? 'local') === 'local') $localAdmin = true;
    $checks['local_admin'] = $localAdmin;
    $checks['data_writable'] = is_writable(DATA_DIR);
    $checks['backups_writable'] = is_writable(BACKUPS_DIR) || (!is_dir(BACKUPS_DIR) && is_writable(APP_ROOT));
    $release = magestockUpdateReleaseManifest();
    $checks['release_manifest'] = ($release['application'] ?? '') === 'MageStock' && ($release['version'] ?? '') === $targetVersion;
    $failed = array_keys(array_filter($checks, static fn($ok): bool => !$ok));
    if ($failed) throw new MageStockUpdateException('Test de santé échoué : ' . implode(', ', $failed), 'health_failed', 500);
    return $checks;
}
function magestockUpdateRestoreTransaction(array $tx, string $reason = 'rollback'): void {
    $rollback = (string)($tx['rollbackDir'] ?? '');
    if ($rollback === '' || !is_dir($rollback)) throw new MageStockUpdateException('Point de restauration absent.', 'rollback_missing', 500);
    $filesDir = $rollback . '/files';
    if (is_dir($filesDir)) {
        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($filesDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY);
        foreach ($it as $file) {
            $relative = substr(str_replace('\\','/',$file->getPathname()), strlen(rtrim(str_replace('\\','/',$filesDir),'/')) + 1);
            $dest = APP_ROOT . '/' . $relative; magestockUpdateEnsureDir(dirname($dest)); @copy($file->getPathname(), $dest);
        }
    }
    foreach (magestockUpdateReadJson($rollback . '/missing.json', []) as $path) { $dest = APP_ROOT . '/' . $path; if (is_file($dest)) @unlink($dest); }
    $dataBackup = $rollback . '/data';
    if (is_dir($dataBackup)) {
        foreach (new DirectoryIterator(DATA_DIR) as $item) {
            if ($item->isDot() || in_array($item->getFilename(), ['update_transaction.json','update_history.json','system_state.json'], true)) continue;
            magestockUpdateRemoveTree($item->getPathname());
        }
        magestockUpdateCopyTree($dataBackup, DATA_DIR);
    }
    $state = magestockUpdateState();
    $state['installedVersion'] = (string)($tx['fromVersion'] ?? $state['installedVersion'] ?? APP_VER);
    $state['migrationVersion'] = $state['installedVersion'];
    $state['codeVersion'] = trim((string)@file_get_contents(APP_ROOT . '/VERSION')) ?: $state['installedVersion'];
    $state['targetVersion'] = null; $state['updateStatus'] = 'completed'; $state['method'] = 'rollback'; $state['lastError'] = $reason; $state['lastUpdateAt'] = magestockUpdateNow();
    magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
}

/**
 * Termine sans danger une transaction orpheline qui n'a ni archive ni point de restauration.
 * Cette récupération n'est autorisée que si le code, le manifeste et la version installée
 * sont déjà strictement cohérents : aucun fichier applicatif n'a alors besoin d'être restauré.
 */
function magestockUpdateAbandonOrphanedTransaction(array $tx, string $reason): array {
    $state = magestockUpdateState();
    $codeVersion = trim((string)@file_get_contents(APP_ROOT . '/VERSION')) ?: APP_VER;
    $installedVersion = trim((string)($state['installedVersion'] ?? APP_VER));
    $releaseVersion = trim((string)(magestockUpdateReleaseManifest()['version'] ?? ''));

    if ($codeVersion !== $installedVersion || $releaseVersion !== $codeVersion) {
        $state['updateStatus'] = 'inconsistent';
        $state['targetVersion'] = $codeVersion;
        $state['lastError'] = 'Transaction orpheline détectée alors que les versions du code et de l’installation diffèrent.';
        $state['lastCheckedAt'] = magestockUpdateNow();
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);
        throw new MageStockUpdateException(
            'La transaction est incomplète et les versions ne sont pas cohérentes. Une restauration manuelle est nécessaire.',
            'orphan_transaction_inconsistent',
            409
        );
    }

    $now = magestockUpdateNow();
    $tx['status'] = 'abandoned';
    $tx['lastError'] = $reason;
    $tx['updatedAt'] = $now;
    magestockUpdateSaveTransaction($tx);

    $state['codeVersion'] = $codeVersion;
    $state['installedVersion'] = $installedVersion;
    $state['migrationVersion'] = $installedVersion;
    $state['targetVersion'] = null;
    $state['updateStatus'] = 'completed';
    $state['method'] = 'recovery';
    $state['lastUpdateAt'] = $now;
    $state['lastError'] = null;
    $state['lastCheckedAt'] = $now;
    magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE, $state);

    magestockUpdateAppendHistory([
        'id' => $tx['id'] ?? magestockUpdateId(),
        'fromVersion' => $tx['fromVersion'] ?? $installedVersion,
        'toVersion' => $tx['targetVersion'] ?? null,
        'method' => 'recovery',
        'status' => 'abandoned',
        'actor' => $tx['actorName'] ?? 'Administrateur',
        'completedAt' => $now,
        'error' => $reason,
        'packageSha256' => $tx['packageSha256'] ?? null,
    ]);

    return [
        'state' => $state,
        'transaction' => $tx,
        'warning' => 'L’état de mise à jour orphelin a été nettoyé. Aucun fichier applicatif n’a été modifié.',
    ];
}

function magestockUpdateApplyVerified(?array $actor = null): array {
    $lockDir = dirname(MAGESTOCK_UPDATE_LOCK_FILE); magestockUpdateEnsureDir($lockDir);
    $lock = fopen(MAGESTOCK_UPDATE_LOCK_FILE, 'c+');
    if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) throw new MageStockUpdateException('Une mise à jour est déjà en cours.', 'update_locked', 409);
    $tx = magestockUpdateTransaction();
    $method = (string)($tx['method'] ?? 'upload');
    try {
        if (!$tx || !in_array((string)($tx['status'] ?? ''), ['verified','uploaded'], true)) {
            throw new MageStockUpdateException('Aucun paquet vérifié prêt à installer.', 'no_verified_package', 409);
        }
        $packagePath = trim((string)($tx['packagePath'] ?? ''));
        if ($packagePath === '' || !is_file($packagePath)) {
            throw new MageStockUpdateException('Archive introuvable.', 'archive_missing', 422);
        }
        $packageSha256 = trim((string)($tx['packageSha256'] ?? ''));
        if ($packageSha256 === '') {
            throw new MageStockUpdateException('Empreinte du paquet absente.', 'package_hash_missing', 422);
        }
        $analysis = magestockUpdateAnalyzePackage($packagePath);
        if (!hash_equals($packageSha256, (string)$analysis['packageSha256'])) throw new MageStockUpdateException('Le paquet a été modifié après vérification.', 'package_changed');
        $preflight = magestockUpdateFilesystemPreflight($analysis);
        $tx['analysis'] = array_diff_key($analysis, array_flip(['manifestRaw','signatureRaw','zipPath'])); $tx['analysis']['preflight'] = $preflight;
        if (!$preflight['ready']) {
            $tx['status']='preflight_failed'; $tx['lastError']=magestockUpdatePreflightMessage($preflight); $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
            $state=magestockUpdateState(); $state['updateStatus']='completed'; $state['targetVersion']=null; $state['method']=null; $state['lastError']=$tx['lastError']; magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
            throw new MageStockUpdateException($tx['lastError'], 'filesystem_preflight_failed', 422, ['preflight'=>$preflight]);
        }
        $tx['status']='verified'; $tx['lastError']=null; $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
        $id = (string)$tx['id']; $workDir = MAGESTOCK_UPDATE_ROOT . '/transactions/' . $id; $stage = $workDir . '/staging'; $rollback = $workDir . '/rollback';
        magestockUpdateRemoveTree($stage); magestockUpdateEnsureDir($rollback);
        $tx['status']='backup_created'; $tx['rollbackDir']=$rollback; $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
        $state = magestockUpdateState(); $state['updateStatus']='backup_created'; $state['targetVersion']=$analysis['targetVersion']; $state['method']=$method; $state['packageMode']=$analysis['packageMode'] ?? 'exact'; magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
        magestockUpdateSnapshotData($rollback . '/data'); magestockUpdateBackupTargets($analysis, $rollback); magestockUpdateFailpoint('after_backup');

        magestockUpdateExtractToStage($analysis, $stage); $tx['status']='staged'; $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
        $state['updateStatus']='staged'; magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state); magestockUpdateFailpoint('after_staging');

        magestockUpdateReplaceFiles($analysis, $stage); $tx['status']='files_replaced'; $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
        $state['updateStatus']='files_replaced'; magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state); magestockUpdateFailpoint('after_files');

        $tx['status']='migrations_running'; magestockUpdateSaveTransaction($tx); $state['updateStatus']='migrations_running'; magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
        $migrations = magestockUpdateRunMigrations($analysis['migrations'] ?? [], $analysis['targetVersion'], $analysis['installedVersion'], $analysis); magestockUpdateFailpoint('after_migrations');

        $tx['status']='health_check'; magestockUpdateSaveTransaction($tx); $state['updateStatus']='health_check'; magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
        $checks = magestockUpdateHealthCheck($analysis['targetVersion']);

        $completedAt = magestockUpdateNow();
        $state['installedVersion']=$analysis['targetVersion']; $state['migrationVersion']=$analysis['targetVersion']; $state['codeVersion']=$analysis['targetVersion'];
        $state['targetVersion']=null; $state['updateStatus']='completed'; $state['method']=$method; $state['lastUpdateAt']=$completedAt; $state['lastError']=null;
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
        $tx['status']='completed'; $tx['completedAt']=$completedAt; $tx['updatedAt']=$completedAt; $tx['health']=$checks; $tx['migrations']=$migrations; magestockUpdateSaveTransaction($tx);
        magestockUpdateAppendHistory(['id'=>$id,'fromVersion'=>$analysis['installedVersion'],'toVersion'=>$analysis['targetVersion'],'method'=>$method,'status'=>'completed','actor'=>$tx['actorName'] ?? 'Administrateur','startedAt'=>$tx['createdAt'] ?? null,'completedAt'=>$completedAt,'packageSha256'=>$analysis['packageSha256'],'packageMode'=>$analysis['packageMode'] ?? 'exact','minimumSourceVersion'=>$analysis['minimumSourceVersion'] ?? null,'maximumSourceVersion'=>$analysis['maximumSourceVersion'] ?? null,'migrations'=>$migrations]);
        return ['state'=>$state,'transaction'=>$tx,'health'=>$checks];
    } catch (Throwable $e) {
        $tx = magestockUpdateTransaction();
        if (!empty($tx['rollbackDir']) && is_dir((string)$tx['rollbackDir'])) {
            try { magestockUpdateRestoreTransaction($tx, $e->getMessage()); $tx['status']='rolled_back'; }
            catch (Throwable $rollbackError) { $tx['status']='rollback_required'; $tx['rollbackError']=$rollbackError->getMessage(); }
        } elseif ((string)($tx['status'] ?? '') === 'preflight_failed') {
            $tx['status']='preflight_failed';
        } elseif (in_array((string)($tx['status'] ?? ''), ['verified','uploaded'], true)) {
            // Aucun fichier n'a encore été remplacé : il n'existe rien à restaurer.
            $tx['status']='failed';
        } else $tx['status']='rollback_required';
        $tx['lastError']=$e->getMessage(); $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
        $state=magestockUpdateState();
        if (($tx['status'] ?? '') === 'rollback_required') {
            $state['updateStatus']='rollback_required';
        } elseif (in_array((string)($tx['status'] ?? ''), ['failed','preflight_failed'], true)) {
            $codeVersion = trim((string)@file_get_contents(APP_ROOT . '/VERSION')) ?: APP_VER;
            $installedVersion = trim((string)($state['installedVersion'] ?? APP_VER));
            $releaseVersion = trim((string)(magestockUpdateReleaseManifest()['version'] ?? ''));
            $state['updateStatus'] = ($codeVersion === $installedVersion && $releaseVersion === $codeVersion) ? 'completed' : 'inconsistent';
            $state['targetVersion'] = $state['updateStatus'] === 'completed' ? null : $codeVersion;
            if ($state['updateStatus'] === 'completed') $state['method'] = null;
        }
        $state['lastError']=$e->getMessage(); magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
        magestockUpdateAppendHistory(['id'=>$tx['id'] ?? magestockUpdateId(),'fromVersion'=>$tx['fromVersion'] ?? null,'toVersion'=>$tx['targetVersion'] ?? null,'method'=>$method,'status'=>$tx['status'] ?? 'failed','actor'=>$tx['actorName'] ?? 'Administrateur','completedAt'=>magestockUpdateNow(),'error'=>$e->getMessage(),'packageSha256'=>$tx['packageSha256'] ?? null]);
        throw $e;
    } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } }
}
function magestockUpdateResume(?array $actor = null): array {
    $tx = magestockUpdateTransaction();
    if (!$tx) throw new MageStockUpdateException('Aucune mise à jour interrompue.', 'no_transaction', 409);

    $status = (string)($tx['status'] ?? '');
    $packagePath = trim((string)($tx['packagePath'] ?? ''));
    $packageAvailable = $packagePath !== '' && is_file($packagePath);
    $rollbackDir = trim((string)($tx['rollbackDir'] ?? ''));
    $rollbackAvailable = $rollbackDir !== '' && is_dir($rollbackDir);

    if ($rollbackAvailable && !in_array($status, ['verified','uploaded'], true)) {
        magestockUpdateRestoreTransaction($tx, 'Reprise après interruption.');
        $tx['status']='rolled_back';
        $tx['updatedAt']=magestockUpdateNow();
        magestockUpdateSaveTransaction($tx);
        magestockUpdateAppendHistory([
            'id'=>$tx['id'] ?? magestockUpdateId(),
            'fromVersion'=>$tx['fromVersion'] ?? null,
            'toVersion'=>$tx['targetVersion'] ?? null,
            'method'=>'recovery',
            'status'=>'rolled_back',
            'actor'=>$actor['name'] ?? $actor['username'] ?? 'Administrateur',
            'completedAt'=>magestockUpdateNow(),
            'error'=>'Reprise après interruption : restauration du point précédent.',
            'packageSha256'=>$tx['packageSha256'] ?? null,
        ]);
        return ['state'=>magestockUpdateState(),'transaction'=>$tx,'warning'=>'Le point précédent a été restauré. Réanalysez le paquet pour relancer la mise à jour.'];
    }

    if (!$packageAvailable) {
        return magestockUpdateAbandonOrphanedTransaction($tx, 'Transaction incomplète : archive de mise à jour absente.');
    }

    if ($status === 'rollback_required') {
        $tx['status']='verified';
        $tx['updatedAt']=magestockUpdateNow();
        magestockUpdateSaveTransaction($tx);
    }

    return magestockUpdateApplyVerified($actor);
}
function magestockUpdateRollbackCurrent(): array {
    $tx = magestockUpdateTransaction();
    if (!$tx) throw new MageStockUpdateException('Aucune transaction à restaurer.', 'no_transaction', 409);

    $rollbackDir = trim((string)($tx['rollbackDir'] ?? ''));
    if ($rollbackDir !== '' && is_dir($rollbackDir)) {
        magestockUpdateRestoreTransaction($tx, 'Restauration demandée par l’administrateur.');
        $tx['status']='rolled_back'; $tx['updatedAt']=magestockUpdateNow(); magestockUpdateSaveTransaction($tx);
        return ['state'=>magestockUpdateState(),'transaction'=>$tx];
    }

    $packagePath = trim((string)($tx['packagePath'] ?? ''));
    if ((string)($tx['status'] ?? '') === 'rollback_required' && ($packagePath === '' || !is_file($packagePath))) {
        return magestockUpdateAbandonOrphanedTransaction($tx, 'État de restauration orphelin : aucun point de restauration et aucune archive ne sont disponibles.');
    }

    throw new MageStockUpdateException('Point de restauration absent.', 'rollback_missing', 422);
}
function magestockUpdateFinalizeManual(?array $actor = null): array {
    $state = magestockUpdateState();
    if (($state['updateStatus'] ?? '') !== 'pending_manual') throw new MageStockUpdateException('Aucune mise à jour manuelle à finaliser.', 'no_manual_update', 409);
    $release = magestockUpdateReleaseManifest();
    if (($release['version'] ?? '') !== APP_VER) throw new MageStockUpdateException('Installation incohérente : manifeste/version.', 'manual_inconsistent');
    $status = magestockUpdateStatus(); if (!empty($status['issues'])) throw new MageStockUpdateException(implode(' ', $status['issues']), 'manual_inconsistent');
    $id = magestockUpdateId(); $rollback = MAGESTOCK_UPDATE_ROOT . '/transactions/' . $id . '/rollback'; magestockUpdateEnsureDir($rollback);
    magestockUpdateSnapshotData($rollback . '/data');
    try {
        $contract = magestockUpdatePackageContract($release, 'distribution', APP_VER);
        $normalizedMigrations = magestockUpdateNormalizeMigrations($release, $contract);
        $migrations = magestockUpdateRunMigrations($normalizedMigrations, APP_VER, (string)$state['installedVersion'], $contract);
        $checks = magestockUpdateHealthCheck(APP_VER);
        $from = $state['installedVersion']; $state['installedVersion']=APP_VER; $state['migrationVersion']=APP_VER; $state['codeVersion']=APP_VER; $state['targetVersion']=null; $state['updateStatus']='completed'; $state['method']='manual_deploy'; $state['lastUpdateAt']=magestockUpdateNow(); $state['lastError']=null;
        magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state);
        magestockUpdateAppendHistory(['id'=>$id,'fromVersion'=>$from,'toVersion'=>APP_VER,'method'=>'manual_deploy','status'=>'completed','actor'=>$actor['name'] ?? $actor['username'] ?? 'Administrateur','completedAt'=>magestockUpdateNow(),'migrations'=>$migrations]);
        return ['state'=>$state,'health'=>$checks,'migrations'=>$migrations,'warning'=>'Le dépôt manuel ne permet pas de restaurer automatiquement les anciens fichiers applicatifs déjà remplacés.'];
    } catch(Throwable $e) {
        if (is_dir($rollback . '/data')) { foreach (new DirectoryIterator(DATA_DIR) as $item) { if ($item->isDot() || in_array($item->getFilename(), ['system_state.json','update_history.json','update_transaction.json'],true)) continue; magestockUpdateRemoveTree($item->getPathname()); } magestockUpdateCopyTree($rollback . '/data', DATA_DIR); }
        $state['updateStatus']='inconsistent'; $state['lastError']=$e->getMessage(); magestockUpdateWriteJson(MAGESTOCK_UPDATE_STATE_FILE,$state); throw $e;
    }
}
function magestockUpdateIntegrity(): array {
    $status = magestockUpdateStatus();
    $checks = ['releaseManifest'=>empty($status['issues']),'dataWritable'=>is_writable(DATA_DIR),'backupsWritable'=>is_dir(BACKUPS_DIR)?is_writable(BACKUPS_DIR):is_writable(APP_ROOT),'localAdmin'=>false];
    foreach (loadData('users') as $u) if (($u['role']??'')==='admin' && !empty($u['active']) && ($u['authSource']??'local')==='local') $checks['localAdmin']=true;
    return ['checks'=>$checks,'ok'=>!in_array(false,$checks,true),'issues'=>$status['issues']];
}
function magestockUpdateEnforceRequestGate(): void {
    if (PHP_SAPI === 'cli' || function_exists('appInstallationRequired') && appInstallationRequired()) return;
    $script = basename((string)($_SERVER['SCRIPT_NAME'] ?? ''));
    $allowed = ['login.php','logout.php','install.php','update.php','lang.php','image.php'];
    if (in_array($script, $allowed, true)) return;
    if ($script === 'update.php' || str_ends_with((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/api/update.php')) return;
    $state = magestockUpdateState();
    if (($state['updateStatus'] ?? 'completed') === 'completed') return;
    if (str_contains((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/api/')) {
        http_response_code(503); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(['error'=>'Une mise à jour MageStock doit être finalisée par un administrateur.','updateRequired'=>true], JSON_UNESCAPED_UNICODE); exit;
    }
}
