<?php
/**
 * Projet                    : MageStock
 * Logiciel                  : MageStock
 * Fichier                   : includes/update-rollup.php
 * Rôle du fichier           : Contrats des paquets cumulatifs, compatibilité des sources, migrations conditionnelles et planification du chemin de mise à jour
 * Auteur                    : Régis KREMER
 * Version du fichier        : 1.0.0
 * Version générale MageStock: 2.6.117
 * Lot                       : MAGESTOCK UPDATE CUMULATIVE ROLLUP ENGINE1
 * Base cumulative requise   : MageStock 2.6.116
 * Date de création          : 2026-07-25
 * Date de modification      : 2026-07-25
 * Dépendances               : includes/update-helper.php, version_compare()
 * Données persistantes      : aucune directement
 * Permissions requises      : update.manage via les points d’entrée du moteur UPDATE
 * Contraintes de sécurité   : aucune confiance accordée aux descripteurs avant validation/signature par l’appelant
 * Compatibilité             : PHP 8.1 à 8.4
 *
 * Résumé des modifications :
 * - définit le schéma UPDATE v2 avec modes exact, cumulative et bridge ;
 * - valide les plages de versions sources inclusives et les exclusions ;
 * - normalise les migrations conditionnelles et idempotentes ;
 * - calcule le chemin signé le plus court parmi des paquets déjà authentifiés.
 *
 * Historique :
 * - 1.0.0 / 2.6.117 — création du moteur de rollup cumulatif.
 */

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

function magestockUpdateVersion(string $value, string $field = 'version'): string {
    $value = trim($value);
    if ($value === '' || !preg_match('/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/', $value)) {
        throw new MageStockUpdateException('Version invalide pour ' . $field . '.', 'version_invalid');
    }
    return $value;
}

function magestockUpdateVersionList(mixed $value, string $field): array {
    if ($value === null || $value === '') return [];
    if (!is_array($value)) throw new MageStockUpdateException('Liste de versions invalide pour ' . $field . '.', 'source_contract_invalid');
    $out = [];
    foreach ($value as $version) {
        $normalized = magestockUpdateVersion((string)$version, $field);
        $out[$normalized] = true;
    }
    $versions = array_keys($out);
    usort($versions, 'version_compare');
    return $versions;
}

/**
 * Normalise le contrat de compatibilité d’un manifeste déjà authentifié.
 */
function magestockUpdatePackageContract(array $manifest, string $packageType, string $targetVersion): array {
    $targetVersion = magestockUpdateVersion($targetVersion, 'toVersion');
    if ($packageType === 'distribution') {
        $minimum = trim((string)($manifest['minimumUpgradeFrom'] ?? $manifest['fromVersion'] ?? ''));
        $minimum = $minimum !== '' ? magestockUpdateVersion($minimum, 'minimumUpgradeFrom') : '0.0.0';
        return [
            'schemaVersion' => 1,
            'packageMode' => 'distribution',
            'minimumSourceVersion' => $minimum,
            'maximumSourceVersion' => null,
            'supportedSourceVersions' => [],
            'excludedSourceVersions' => [],
            'migrationBaseline' => $minimum,
        ];
    }

    $schemaVersion = (int)($manifest['schemaVersion'] ?? 1);
    if ($schemaVersion < 1 || $schemaVersion > 2) {
        throw new MageStockUpdateException('Schéma de paquet UPDATE non pris en charge.', 'schema_unsupported');
    }
    $mode = strtolower(trim((string)($manifest['packageMode'] ?? 'exact')));
    if (!in_array($mode, ['exact', 'cumulative', 'bridge'], true)) {
        throw new MageStockUpdateException('Mode de paquet UPDATE inconnu.', 'package_mode_invalid');
    }
    if ($mode !== 'exact' && $schemaVersion < 2) {
        throw new MageStockUpdateException('Un paquet cumulatif ou pont exige le schéma UPDATE v2.', 'schema_required');
    }

    if ($mode === 'exact') {
        $from = magestockUpdateVersion((string)($manifest['fromVersion'] ?? ''), 'fromVersion');
        $minimum = $from;
        $maximum = $from;
    } else {
        $minimum = magestockUpdateVersion((string)($manifest['minimumSourceVersion'] ?? $manifest['fromVersion'] ?? ''), 'minimumSourceVersion');
        $maximum = magestockUpdateVersion((string)($manifest['maximumSourceVersion'] ?? ''), 'maximumSourceVersion');
        if (version_compare($minimum, $maximum, '>')) {
            throw new MageStockUpdateException('La plage de versions sources est inversée.', 'source_range_invalid');
        }
        if (!version_compare($maximum, $targetVersion, '<')) {
            throw new MageStockUpdateException('La version source maximale doit être antérieure à la cible.', 'source_range_invalid');
        }
    }

    $supported = magestockUpdateVersionList($manifest['supportedSourceVersions'] ?? [], 'supportedSourceVersions');
    $excluded = magestockUpdateVersionList($manifest['excludedSourceVersions'] ?? [], 'excludedSourceVersions');
    foreach ($supported as $version) {
        if (version_compare($version, $minimum, '<') || version_compare($version, $maximum, '>')) {
            throw new MageStockUpdateException('Une version explicitement supportée est hors de la plage déclarée.', 'source_contract_invalid');
        }
    }

    $baseline = trim((string)($manifest['migrationBaseline'] ?? $minimum));
    $baseline = magestockUpdateVersion($baseline, 'migrationBaseline');
    if (version_compare($baseline, $minimum, '<') || version_compare($baseline, $maximum, '>')) {
        throw new MageStockUpdateException('La baseline de migration est hors de la plage source.', 'migration_baseline_invalid');
    }

    return [
        'schemaVersion' => $schemaVersion,
        'packageMode' => $mode,
        'minimumSourceVersion' => $minimum,
        'maximumSourceVersion' => $maximum,
        'supportedSourceVersions' => $supported,
        'excludedSourceVersions' => $excluded,
        'migrationBaseline' => $baseline,
    ];
}

function magestockUpdateContractSupports(array $contract, string $sourceVersion): bool {
    $sourceVersion = magestockUpdateVersion($sourceVersion, 'version installée');
    $minimum = (string)($contract['minimumSourceVersion'] ?? '0.0.0');
    $maximum = $contract['maximumSourceVersion'] ?? null;
    if (version_compare($sourceVersion, $minimum, '<')) return false;
    if (is_string($maximum) && $maximum !== '' && version_compare($sourceVersion, $maximum, '>')) return false;
    $supported = is_array($contract['supportedSourceVersions'] ?? null) ? $contract['supportedSourceVersions'] : [];
    if ($supported && !in_array($sourceVersion, $supported, true)) return false;
    $excluded = is_array($contract['excludedSourceVersions'] ?? null) ? $contract['excludedSourceVersions'] : [];
    return !in_array($sourceVersion, $excluded, true);
}

function magestockUpdateContractLabel(array $contract): string {
    $mode = (string)($contract['packageMode'] ?? 'exact');
    if ($mode === 'distribution') return 'distribution complète';
    if ($mode === 'exact') return 'mise à jour directe';
    if ($mode === 'bridge') return 'pont de compatibilité';
    return 'mise à jour cumulative';
}

/** Normalise les migrations, dans l’ordre signé du manifeste. */
function magestockUpdateNormalizeMigrations(array $manifest, array $contract): array {
    $normalized = [];
    $seen = [];
    foreach (($manifest['migrations'] ?? []) as $migration) {
        if (is_string($migration)) {
            $migration = ['id' => $migration, 'path' => $migration];
        }
        if (!is_array($migration)) throw new MageStockUpdateException('Migration invalide.', 'migration_invalid');
        $id = trim((string)($migration['id'] ?? ''));
        $path = trim((string)($migration['path'] ?? ''));
        if ($id === '' || !preg_match('/^[A-Za-z0-9._-]{3,120}$/', $id) || $path === '') {
            throw new MageStockUpdateException('Identifiant ou chemin de migration invalide.', 'migration_invalid');
        }
        if (isset($seen[$id])) throw new MageStockUpdateException('Migration déclarée plusieurs fois : ' . $id, 'migration_duplicate');
        $seen[$id] = true;
        $path = magestockUpdateNormalizePath($path);
        if (magestockUpdateProtectedPath($path)) throw new MageStockUpdateException('Migration placée dans un chemin protégé.', 'protected_path');

        $minimum = trim((string)($migration['minimumSourceVersion'] ?? $contract['minimumSourceVersion'] ?? ''));
        $maximum = trim((string)($migration['maximumSourceVersion'] ?? $contract['maximumSourceVersion'] ?? ''));
        $minimum = $minimum !== '' ? magestockUpdateVersion($minimum, 'minimumSourceVersion de migration') : null;
        $maximum = $maximum !== '' ? magestockUpdateVersion($maximum, 'maximumSourceVersion de migration') : null;
        if ($minimum !== null && $maximum !== null && version_compare($minimum, $maximum, '>')) {
            throw new MageStockUpdateException('Plage inversée pour la migration ' . $id . '.', 'migration_range_invalid');
        }
        $normalized[] = [
            'id' => $id,
            'path' => $path,
            'minimumSourceVersion' => $minimum,
            'maximumSourceVersion' => $maximum,
        ];
    }
    return $normalized;
}

function magestockUpdateMigrationApplies(array $migration, string $sourceVersion): bool {
    $minimum = $migration['minimumSourceVersion'] ?? null;
    $maximum = $migration['maximumSourceVersion'] ?? null;
    if (is_string($minimum) && version_compare($sourceVersion, $minimum, '<')) return false;
    if (is_string($maximum) && version_compare($sourceVersion, $maximum, '>')) return false;
    return true;
}

/**
 * Normalise un descripteur provenant d’un catalogue déjà authentifié.
 * Cette fonction ne vérifie ni URL, ni checksum, ni signature : l’appelant doit le faire.
 */
function magestockUpdatePlanDescriptor(array $descriptor): array {
    $to = magestockUpdateVersion((string)($descriptor['toVersion'] ?? ''), 'toVersion');
    $mode = strtolower(trim((string)($descriptor['packageMode'] ?? 'exact')));
    if (!in_array($mode, ['exact', 'cumulative', 'bridge'], true)) {
        throw new MageStockUpdateException('Mode invalide dans le catalogue de mises à jour.', 'plan_descriptor_invalid');
    }
    if ($mode === 'exact') {
        $from = magestockUpdateVersion((string)($descriptor['fromVersion'] ?? ''), 'fromVersion');
        $minimum = $maximum = $from;
    } else {
        $minimum = magestockUpdateVersion((string)($descriptor['minimumSourceVersion'] ?? $descriptor['fromVersion'] ?? ''), 'minimumSourceVersion');
        $maximum = magestockUpdateVersion((string)($descriptor['maximumSourceVersion'] ?? ''), 'maximumSourceVersion');
        if (version_compare($minimum, $maximum, '>') || !version_compare($maximum, $to, '<')) {
            throw new MageStockUpdateException('Plage invalide dans le catalogue de mises à jour.', 'plan_descriptor_invalid');
        }
    }
    $id = trim((string)($descriptor['id'] ?? $descriptor['sha256'] ?? ($minimum . '-' . $to)));
    if ($id === '') throw new MageStockUpdateException('Descripteur sans identifiant.', 'plan_descriptor_invalid');
    return [
        'id' => $id,
        'packageMode' => $mode,
        'fromVersion' => $descriptor['fromVersion'] ?? $minimum,
        'minimumSourceVersion' => $minimum,
        'maximumSourceVersion' => $maximum,
        'toVersion' => $to,
        'size' => max(0, (int)($descriptor['size'] ?? 0)),
        'sha256' => $descriptor['sha256'] ?? null,
        'url' => $descriptor['url'] ?? null,
    ];
}

function magestockUpdatePlanDescriptorSupports(array $descriptor, string $sourceVersion): bool {
    return version_compare($sourceVersion, (string)$descriptor['minimumSourceVersion'], '>=')
        && version_compare($sourceVersion, (string)$descriptor['maximumSourceVersion'], '<=')
        && version_compare((string)$descriptor['toVersion'], $sourceVersion, '>');
}

/**
 * Recherche le chemin comportant le moins de paquets ; à égalité, le téléchargement le plus léger.
 */
function magestockUpdatePlanShortestPath(string $installedVersion, string $targetVersion, array $descriptors): array {
    $installedVersion = magestockUpdateVersion($installedVersion, 'version installée');
    $targetVersion = magestockUpdateVersion($targetVersion, 'version cible');
    if (version_compare($targetVersion, $installedVersion, '<')) {
        throw new MageStockUpdateException('Le planificateur refuse une rétrogradation.', 'plan_downgrade');
    }
    if ($installedVersion === $targetVersion) {
        return ['possible'=>true,'fromVersion'=>$installedVersion,'targetVersion'=>$targetVersion,'steps'=>[],'packageCount'=>0,'downloadBytes'=>0];
    }

    $packages = [];
    foreach ($descriptors as $descriptor) {
        if (!is_array($descriptor)) continue;
        $package = magestockUpdatePlanDescriptor($descriptor);
        if (version_compare($package['toVersion'], $targetVersion, '>')) continue;
        $packages[] = $package;
    }
    usort($packages, static function(array $a, array $b): int {
        $cmp = version_compare($a['toVersion'], $b['toVersion']);
        return $cmp !== 0 ? $cmp : strcmp($a['id'], $b['id']);
    });

    $queue = [[
        'version' => $installedVersion,
        'steps' => [],
        'hops' => 0,
        'bytes' => 0,
    ]];
    $best = [$installedVersion => ['hops'=>0,'bytes'=>0]];
    while ($queue) {
        usort($queue, static fn(array $a, array $b): int => ($a['hops'] <=> $b['hops']) ?: ($a['bytes'] <=> $b['bytes']));
        $current = array_shift($queue);
        if ($current['version'] === $targetVersion) {
            return [
                'possible' => true,
                'fromVersion' => $installedVersion,
                'targetVersion' => $targetVersion,
                'steps' => $current['steps'],
                'packageCount' => $current['hops'],
                'downloadBytes' => $current['bytes'],
            ];
        }
        foreach ($packages as $package) {
            if (!magestockUpdatePlanDescriptorSupports($package, $current['version'])) continue;
            $next = $package['toVersion'];
            $hops = $current['hops'] + 1;
            $bytes = $current['bytes'] + $package['size'];
            $known = $best[$next] ?? null;
            if ($known && ($known['hops'] < $hops || ($known['hops'] === $hops && $known['bytes'] <= $bytes))) continue;
            $best[$next] = ['hops'=>$hops,'bytes'=>$bytes];
            $step = $package;
            $step['selectedFromVersion'] = $current['version'];
            $queue[] = ['version'=>$next,'steps'=>array_merge($current['steps'],[$step]),'hops'=>$hops,'bytes'=>$bytes];
        }
    }
    return [
        'possible' => false,
        'fromVersion' => $installedVersion,
        'targetVersion' => $targetVersion,
        'steps' => [],
        'packageCount' => 0,
        'downloadBytes' => 0,
        'reason' => 'Aucun parcours compatible et strictement ascendant.',
    ];
}
