<?php
/**
 * Projet                    : MageStock
 * Logiciel                  : MageStock
 * Fichier                   : includes/update-discovery.php
 * Rôle du fichier           : Découverte distante, authentification et téléchargement contrôlé des mises à jour officielles MageKorp
 * Auteur                    : Régis KREMER
 * Version du fichier        : 2.6.120
 * Version générale MageStock: 2.6.120
 * Lot                       : MAGESTOCK FINAL CONSOLIDATION DISTRIBUTION RECETTE1
 * Base cumulative requise   : MageStock 2.6.119
 * Date de création          : 2026-07-25
 * Date de modification      : 2026-07-25
 * Dépendances               : config.php, includes/update-helper.php, Sodium, cURL ou flux HTTPS PHP
 * Données persistantes      : data/update_discovery.json, backups/updates/remote/
 * Permissions requises      : update.manage via les points d’entrée appelants
 * Contraintes de sécurité   : HTTPS strict, origine épinglée, clé Ed25519 épinglée, limites de taille, aucune télémétrie
 * Compatibilité             : PHP 8.1 à 8.4, Apache/Nginx, installation racine ou sous-dossier
 *
 * Résumé des modifications :
 * - consulte le contrat public MageKorp Hub pour MageStock sans transmettre de donnée métier ;
 * - authentifie le manifeste distant avec la racine MageStock déjà intégrée ;
 * - sélectionne un artefact UPDATE compatible avec la version installée, vérifie taille et SHA-256, puis le remet au moteur interne ;
 * - limite les contrôles automatiques à une fois toutes les douze heures et conserve l’import manuel.
 *
 * Historique :
 * - 2.6.118 — création du client de découverte distante signé.
 * - 2.6.120 — filtrage strict des artefacts exacts et cumulatifs selon leur plage source déclarée.
 */

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

if (!defined('MAGESTOCK_DISCOVERY_ORIGIN')) define('MAGESTOCK_DISCOVERY_ORIGIN', 'https://magekorp.com');
if (!defined('MAGESTOCK_DISCOVERY_PRODUCT')) define('MAGESTOCK_DISCOVERY_PRODUCT', 'magestock');
if (!defined('MAGESTOCK_DISCOVERY_CHANNEL')) define('MAGESTOCK_DISCOVERY_CHANNEL', 'stable');
if (!defined('MAGESTOCK_DISCOVERY_CACHE_TTL')) define('MAGESTOCK_DISCOVERY_CACHE_TTL', 12 * 60 * 60);
if (!defined('MAGESTOCK_DISCOVERY_CACHE_FILE')) define('MAGESTOCK_DISCOVERY_CACHE_FILE', DATA_DIR . '/update_discovery.json');
if (!defined('MAGESTOCK_DISCOVERY_MAX_JSON')) define('MAGESTOCK_DISCOVERY_MAX_JSON', 1024 * 1024);
if (!defined('MAGESTOCK_DISCOVERY_MAX_SIGNATURE')) define('MAGESTOCK_DISCOVERY_MAX_SIGNATURE', 64 * 1024);
if (!defined('MAGESTOCK_DISCOVERY_CONNECT_TIMEOUT')) define('MAGESTOCK_DISCOVERY_CONNECT_TIMEOUT', 5);
if (!defined('MAGESTOCK_DISCOVERY_TOTAL_TIMEOUT')) define('MAGESTOCK_DISCOVERY_TOTAL_TIMEOUT', 20);
if (!defined('MAGESTOCK_DISCOVERY_DOWNLOAD_TIMEOUT')) define('MAGESTOCK_DISCOVERY_DOWNLOAD_TIMEOUT', 180);
if (!defined('MAGESTOCK_DISCOVERY_MAX_REDIRECTS')) define('MAGESTOCK_DISCOVERY_MAX_REDIRECTS', 2);

function magestockDiscoveryNow(): string { return date('c'); }
function magestockDiscoveryTimestamp(?string $value): ?int {
    if ($value === null || trim($value) === '') return null;
    $timestamp = strtotime($value);
    return $timestamp === false ? null : $timestamp;
}
function magestockDiscoveryCache(): array {
    return magestockUpdateReadJson(MAGESTOCK_DISCOVERY_CACHE_FILE, []);
}
function magestockDiscoveryWriteCache(array $cache): void {
    $cache['schema'] = 'magestock.update-discovery-cache.v1';
    magestockUpdateWriteJson(MAGESTOCK_DISCOVERY_CACHE_FILE, $cache, 0600);
}
function magestockDiscoveryPublicCache(): array {
    $cache = magestockDiscoveryCache();
    $result = is_array($cache['result'] ?? null) ? $cache['result'] : [];
    unset($result['artifactUrl'], $result['manifestUrl'], $result['signatureUrl'], $result['trustStoreUrl']);
    return [
        'enabled' => true,
        'provider' => 'MageKorp Hub',
        'origin' => MAGESTOCK_DISCOVERY_ORIGIN,
        'channel' => MAGESTOCK_DISCOVERY_CHANNEL,
        'checkedAt' => $cache['checkedAt'] ?? null,
        'nextAutomaticCheckAt' => $cache['nextAutomaticCheckAt'] ?? null,
        'status' => $cache['status'] ?? 'never_checked',
        'result' => $result,
        'lastError' => $cache['lastError'] ?? null,
        'automaticIntervalSeconds' => MAGESTOCK_DISCOVERY_CACHE_TTL,
    ];
}
function magestockDiscoveryOriginParts(): array {
    $parts = parse_url(MAGESTOCK_DISCOVERY_ORIGIN);
    if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https' || empty($parts['host'])) {
        throw new MageStockUpdateException('Origine officielle de mise à jour invalide.', 'discovery_origin_invalid', 500);
    }
    return [
        'scheme' => 'https',
        'host' => strtolower((string)$parts['host']),
        'port' => isset($parts['port']) ? (int)$parts['port'] : 443,
    ];
}
function magestockDiscoveryValidateUrl(string $url): string {
    $url = trim($url);
    $parts = parse_url($url);
    $expected = magestockDiscoveryOriginParts();
    if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https' || empty($parts['host'])) {
        throw new MageStockUpdateException('Une URL distante non HTTPS a été refusée.', 'discovery_url_not_https');
    }
    $port = isset($parts['port']) ? (int)$parts['port'] : 443;
    if (strtolower((string)$parts['host']) !== $expected['host'] || $port !== $expected['port']) {
        throw new MageStockUpdateException('Une URL distante hors du domaine officiel a été refusée.', 'discovery_external_origin');
    }
    if (isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])) {
        throw new MageStockUpdateException('URL distante non conforme.', 'discovery_url_invalid');
    }
    return $url;
}
function magestockDiscoveryResolveUrl(string $value): string {
    $value = trim($value);
    if ($value === '') throw new MageStockUpdateException('URL distante absente.', 'discovery_url_missing');
    if (preg_match('#^https://#i', $value)) return magestockDiscoveryValidateUrl($value);
    if (!str_starts_with($value, '/')) throw new MageStockUpdateException('URL distante relative invalide.', 'discovery_relative_url_invalid');
    return magestockDiscoveryValidateUrl(rtrim(MAGESTOCK_DISCOVERY_ORIGIN, '/') . $value);
}
function magestockDiscoveryHeaderValue(array $headers, string $name): ?string {
    $name = strtolower($name);
    foreach ($headers as $key => $value) {
        if (strtolower((string)$key) === $name) return is_array($value) ? (string)end($value) : (string)$value;
    }
    return null;
}
function magestockDiscoveryNormalizeResponse(array $response, string $requestedUrl): array {
    $status = (int)($response['status'] ?? 0);
    $headers = is_array($response['headers'] ?? null) ? $response['headers'] : [];
    $normalized = [];
    foreach ($headers as $key => $value) $normalized[strtolower((string)$key)] = $value;
    $body = $response['body'] ?? '';
    if (!is_string($body)) throw new MageStockUpdateException('Réponse distante illisible.', 'discovery_response_invalid');
    return [
        'status' => $status,
        'headers' => $normalized,
        'body' => $body,
        'url' => magestockDiscoveryValidateUrl((string)($response['url'] ?? $requestedUrl)),
    ];
}
/**
 * Le transport optionnel sert uniquement aux recettes isolées. L’API Web utilise toujours
 * le transport HTTPS natif et n’accepte jamais une URL fournie par l’utilisateur.
 */
function magestockDiscoveryHttpOnce(string $url, array $headers, int $maxBytes, ?callable $transport = null): array {
    $url = magestockDiscoveryValidateUrl($url);
    if ($transport !== null) return magestockDiscoveryNormalizeResponse($transport($url, $headers, $maxBytes, 'json'), $url);

    $requestHeaders = ['Accept: application/json', 'User-Agent: MageStock/' . APP_VER . ' UpdateDiscovery'];
    foreach ($headers as $name => $value) $requestHeaders[] = $name . ': ' . $value;

    if (function_exists('curl_init')) {
        $responseHeaders = [];
        $body = '';
        $tooLarge = false;
        $ch = curl_init($url);
        if ($ch === false) throw new MageStockUpdateException('Initialisation HTTPS impossible.', 'discovery_http_init', 500);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => false,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT => MAGESTOCK_DISCOVERY_CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => MAGESTOCK_DISCOVERY_TOTAL_TIMEOUT,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_ENCODING => '',
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int {
                $length = strlen($line);
                $pos = strpos($line, ':');
                if ($pos !== false) {
                    $name = strtolower(trim(substr($line, 0, $pos)));
                    $value = trim(substr($line, $pos + 1));
                    if ($name !== '') $responseHeaders[$name] = $value;
                }
                return $length;
            },
            CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body, &$tooLarge, $maxBytes): int {
                if (strlen($body) + strlen($chunk) > $maxBytes) {
                    $tooLarge = true;
                    return 0;
                }
                $body .= $chunk;
                return strlen($chunk);
            },
        ]);
        if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTPS')) curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
        if (defined('CURLOPT_REDIR_PROTOCOLS') && defined('CURLPROTO_HTTPS')) curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
        $ok = curl_exec($ch);
        $errorNo = curl_errno($ch);
        $error = curl_error($ch);
        $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $effective = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
        curl_close($ch);
        if ($tooLarge) throw new MageStockUpdateException('Réponse distante trop volumineuse.', 'discovery_response_too_large');
        if ($ok === false) {
            $code = $errorNo === CURLE_OPERATION_TIMEDOUT ? 'discovery_timeout' : 'discovery_network_error';
            throw new MageStockUpdateException('Le service de mise à jour est momentanément inaccessible.', $code, 503, ['transportError'=>$error]);
        }
        return magestockDiscoveryNormalizeResponse(['status'=>$status,'headers'=>$responseHeaders,'body'=>$body,'url'=>$effective ?: $url], $url);
    }

    $context = stream_context_create([
        'http' => [
            'method' => 'GET',
            'header' => implode("\r\n", $requestHeaders) . "\r\n",
            'timeout' => MAGESTOCK_DISCOVERY_TOTAL_TIMEOUT,
            'ignore_errors' => true,
            'follow_location' => 0,
            'max_redirects' => 0,
            'protocol_version' => 1.1,
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
            'allow_self_signed' => false,
            'SNI_enabled' => true,
        ],
    ]);
    $body = @file_get_contents($url, false, $context, 0, $maxBytes + 1);
    $responseHeaderLines = $http_response_header ?? [];
    if (!is_string($body)) throw new MageStockUpdateException('Le service de mise à jour est momentanément inaccessible.', 'discovery_network_error', 503);
    if (strlen($body) > $maxBytes) throw new MageStockUpdateException('Réponse distante trop volumineuse.', 'discovery_response_too_large');
    $status = 0;
    $responseHeaders = [];
    foreach ($responseHeaderLines as $line) {
        if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $line, $matches)) { $status = (int)$matches[1]; continue; }
        $pos = strpos($line, ':');
        if ($pos !== false) $responseHeaders[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1));
    }
    return magestockDiscoveryNormalizeResponse(['status'=>$status,'headers'=>$responseHeaders,'body'=>$body,'url'=>$url], $url);
}
function magestockDiscoveryHttpGet(string $url, array $headers = [], int $maxBytes = MAGESTOCK_DISCOVERY_MAX_JSON, ?callable $transport = null, int $redirects = 0): array {
    $response = magestockDiscoveryHttpOnce($url, $headers, $maxBytes, $transport);
    if (in_array($response['status'], [301,302,303,307,308], true)) {
        if ($redirects >= MAGESTOCK_DISCOVERY_MAX_REDIRECTS) throw new MageStockUpdateException('Trop de redirections distantes.', 'discovery_redirect_loop');
        $location = magestockDiscoveryHeaderValue($response['headers'], 'location');
        if ($location === null || $location === '') throw new MageStockUpdateException('Redirection distante invalide.', 'discovery_redirect_invalid');
        if (!preg_match('#^https://#i', $location)) {
            if (!str_starts_with($location, '/')) throw new MageStockUpdateException('Redirection distante invalide.', 'discovery_redirect_invalid');
            $location = rtrim(MAGESTOCK_DISCOVERY_ORIGIN, '/') . $location;
        }
        return magestockDiscoveryHttpGet(magestockDiscoveryValidateUrl($location), $headers, $maxBytes, $transport, $redirects + 1);
    }
    if ($response['status'] === 304) return $response;
    if ($response['status'] < 200 || $response['status'] >= 300) {
        throw new MageStockUpdateException('Le service de mise à jour a répondu avec une erreur.', 'discovery_http_status', 503, ['status'=>$response['status']]);
    }
    return $response;
}
function magestockDiscoveryDecodeJson(string $raw, string $label): array {
    if (trim($raw) === '') throw new MageStockUpdateException($label . ' vide.', 'discovery_json_empty');
    $decoded = json_decode($raw, true);
    if (!is_array($decoded) || json_last_error() !== JSON_ERROR_NONE) {
        throw new MageStockUpdateException($label . ' invalide ou tronqué.', 'discovery_json_invalid');
    }
    return $decoded;
}
function magestockDiscoverySemver(string $value, string $label): string {
    return magestockUpdateVersion($value, $label);
}
function magestockDiscoveryValidateClientConfig(array $config): array {
    if (($config['schema'] ?? '') !== 'magekorp.product-client-config.v1') throw new MageStockUpdateException('Contrat client MageKorp non reconnu.', 'discovery_client_schema');
    $product = is_array($config['product'] ?? null) ? $config['product'] : [];
    if (($product['slug'] ?? '') !== MAGESTOCK_DISCOVERY_PRODUCT || ($product['code'] ?? '') !== MAGESTOCK_DISCOVERY_PRODUCT || ($product['name'] ?? '') !== 'MageStock') {
        throw new MageStockUpdateException('Le contrat distant ne concerne pas MageStock.', 'discovery_wrong_product');
    }
    $contracts = is_array($config['contracts'] ?? null) ? $config['contracts'] : [];
    foreach ([
        'release_manifest'=>'magekorp.release-manifest.v1',
        'update_check'=>'magekorp.update-check.v1',
        'trust_store'=>'magekorp.trust-store.v1',
    ] as $key => $expected) {
        if (($contracts[$key] ?? '') !== $expected) throw new MageStockUpdateException('Contrat MageKorp incompatible : ' . $key . '.', 'discovery_contract_incompatible');
    }
    $channels = is_array($config['channels'] ?? null) ? $config['channels'] : [];
    if (!in_array(MAGESTOCK_DISCOVERY_CHANNEL, $channels, true)) throw new MageStockUpdateException('Canal stable MageStock absent.', 'discovery_channel_missing');
    $endpoints = is_array($config['endpoints'] ?? null) ? $config['endpoints'] : [];
    foreach (['update_check','release_manifest','trust_store'] as $key) {
        if (!isset($endpoints[$key]) || !is_string($endpoints[$key])) throw new MageStockUpdateException('Point d’accès MageKorp absent : ' . $key . '.', 'discovery_endpoint_missing');
    }
    return $config;
}
function magestockDiscoveryEndpoint(array $config, string $key, array $replacements = []): string {
    $template = (string)($config['endpoints'][$key] ?? '');
    foreach ($replacements as $name => $value) $template = str_replace('{' . $name . '}', rawurlencode((string)$value), $template);
    if (str_contains($template, '{') || str_contains($template, '}')) throw new MageStockUpdateException('Point d’accès MageKorp incomplet.', 'discovery_endpoint_template');
    return magestockDiscoveryResolveUrl($template);
}
function magestockDiscoveryValidateUpdateCheck(array $check, string $current): array {
    if (($check['schema'] ?? '') !== 'magekorp.update-check.v1' || ($check['product'] ?? '') !== MAGESTOCK_DISCOVERY_PRODUCT || ($check['channel'] ?? '') !== MAGESTOCK_DISCOVERY_CHANNEL) {
        throw new MageStockUpdateException('Réponse de recherche MageKorp non reconnue.', 'discovery_check_schema');
    }
    $declaredCurrent = magestockDiscoverySemver((string)($check['current_version'] ?? ''), 'version courante distante');
    if ($declaredCurrent !== $current) throw new MageStockUpdateException('La réponse distante ne correspond pas à la version demandée.', 'discovery_current_mismatch');
    if (!is_bool($check['update_available'] ?? null)) throw new MageStockUpdateException('État de mise à jour distant invalide.', 'discovery_check_invalid');
    $latest = magestockDiscoverySemver((string)($check['latest_version'] ?? $current), 'dernière version distante');
    if (($check['update_available'] ?? false) && !version_compare($latest, $current, '>')) throw new MageStockUpdateException('La version distante annoncée n’est pas plus récente.', 'discovery_not_newer');
    if (!($check['update_available'] ?? false) && version_compare($latest, $current, '>')) throw new MageStockUpdateException('Réponse distante incohérente.', 'discovery_check_inconsistent');
    if (($check['update_available'] ?? false) && (($check['release']['status'] ?? '') !== 'published')) throw new MageStockUpdateException('La version distante n’est pas publiée.', 'discovery_release_not_published');
    return $check;
}
function magestockDiscoveryValidateTrustStore(array $store, string $keyId, string $publishedAt): array {
    if (($store['schema'] ?? '') !== 'magekorp.trust-store.v1' || !is_array($store['keys'] ?? null)) throw new MageStockUpdateException('Registre de confiance MageKorp invalide.', 'discovery_trust_schema');
    $embedded = magestockUpdatePublicKey();
    if (($embedded['keyId'] ?? '') !== $keyId) throw new MageStockUpdateException('Identifiant de clé distante non approuvé.', 'discovery_unknown_key');
    $expectedRaw = base64_decode((string)($embedded['publicKey'] ?? ''), true);
    if (!is_string($expectedRaw) || strlen($expectedRaw) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new MageStockUpdateException('Racine MageStock intégrée invalide.', 'discovery_embedded_key_invalid', 500);

    $key = null;
    foreach ($store['keys'] as $candidate) {
        if (is_array($candidate) && ($candidate['key_id'] ?? '') === $keyId) { $key = $candidate; break; }
    }
    if (!is_array($key)) throw new MageStockUpdateException('Clé publique absente du registre MageKorp.', 'discovery_key_missing');
    if (($key['algorithm'] ?? '') !== 'ed25519') throw new MageStockUpdateException('Algorithme de clé distante non accepté.', 'discovery_key_algorithm');
    if (($key['status'] ?? '') === 'revoked') throw new MageStockUpdateException('La clé de publication a été révoquée.', 'discovery_key_revoked');
    if (($key['status'] ?? '') !== 'active') throw new MageStockUpdateException('La clé de publication n’est plus active.', 'discovery_key_inactive');
    $raw = base64_decode((string)($key['public_key_base64'] ?? ''), true);
    if (!is_string($raw) || strlen($raw) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new MageStockUpdateException('Clé publique MageKorp invalide.', 'discovery_key_invalid');
    if (!hash_equals($expectedRaw, $raw)) throw new MageStockUpdateException('La clé distante ne correspond pas à la racine MageStock intégrée.', 'discovery_key_pin_mismatch');
    $fingerprint = strtolower((string)($key['fingerprint_sha256'] ?? ''));
    if (!preg_match('/^[a-f0-9]{64}$/', $fingerprint) || !hash_equals($fingerprint, hash('sha256', $raw))) {
        throw new MageStockUpdateException('Empreinte de clé distante invalide.', 'discovery_key_fingerprint');
    }
    $validFrom = magestockDiscoveryTimestamp((string)($key['valid_from'] ?? ''));
    $validUntil = magestockDiscoveryTimestamp(isset($key['valid_until']) ? (string)$key['valid_until'] : null);
    $published = magestockDiscoveryTimestamp($publishedAt) ?? time();
    if ($validFrom === null || $published < $validFrom || ($validUntil !== null && $published > $validUntil)) {
        throw new MageStockUpdateException('La clé n’était pas valide à la date de publication.', 'discovery_key_validity');
    }
    return ['keyId'=>$keyId,'status'=>'active','fingerprintSha256'=>$fingerprint,'publicKeyRaw'=>$raw];
}
function magestockDiscoveryVerifyManifest(string $manifestRaw, array $sidecar, array $trust): void {
    if (($sidecar['schema'] ?? '') !== 'magekorp.manifest-signature.v1' || ($sidecar['algorithm'] ?? '') !== 'ed25519') throw new MageStockUpdateException('Signature de manifeste MageKorp invalide.', 'discovery_manifest_signature_schema');
    if (($sidecar['key_id'] ?? '') !== ($trust['keyId'] ?? '')) throw new MageStockUpdateException('La signature utilise une autre clé.', 'discovery_manifest_key_mismatch');
    $sha = strtolower((string)($sidecar['sha256'] ?? ''));
    if (!preg_match('/^[a-f0-9]{64}$/', $sha) || !hash_equals($sha, hash('sha256', $manifestRaw))) throw new MageStockUpdateException('Empreinte du manifeste distant invalide.', 'discovery_manifest_hash');
    $signature = base64_decode((string)($sidecar['value'] ?? ''), true);
    if (!is_string($signature) || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES || !sodium_crypto_sign_verify_detached($signature, $manifestRaw, $trust['publicKeyRaw'])) {
        throw new MageStockUpdateException('Signature Ed25519 du manifeste distant invalide.', 'discovery_manifest_signature');
    }
}
function magestockDiscoverySupportsPhp(string $requirement): bool {
    $requirement = trim($requirement);
    if (!preg_match('/^(>=|>|<=|<|=)?\s*(\d+\.\d+(?:\.\d+)?)$/', $requirement, $matches)) return false;
    $operator = $matches[1] !== '' ? $matches[1] : '>=';
    return version_compare(PHP_VERSION, $matches[2], $operator);
}
function magestockDiscoveryValidateManifest(array $manifest, string $latest, string $installed): array {
    if (($manifest['schema'] ?? '') !== 'magekorp.release-manifest.v1') throw new MageStockUpdateException('Manifeste de publication MageKorp non reconnu.', 'discovery_manifest_schema');
    if (($manifest['product']['slug'] ?? '') !== MAGESTOCK_DISCOVERY_PRODUCT || ($manifest['product']['name'] ?? '') !== 'MageStock') throw new MageStockUpdateException('Manifeste publié pour un autre produit.', 'discovery_manifest_product');
    $release = is_array($manifest['release'] ?? null) ? $manifest['release'] : [];
    $version = magestockDiscoverySemver((string)($release['version'] ?? ''), 'version du manifeste');
    if ($version !== $latest || ($release['channel'] ?? '') !== MAGESTOCK_DISCOVERY_CHANNEL || !is_bool($release['mandatory'] ?? null)) throw new MageStockUpdateException('Métadonnées de publication incohérentes.', 'discovery_manifest_release');
    if (magestockDiscoveryTimestamp((string)($release['published_at'] ?? '')) === null) throw new MageStockUpdateException('Date de publication invalide.', 'discovery_manifest_date');
    $minimum = isset($release['minimum_source_version']) && $release['minimum_source_version'] !== null ? magestockDiscoverySemver((string)$release['minimum_source_version'], 'version source minimale') : null;
    $maximum = isset($release['maximum_source_version']) && $release['maximum_source_version'] !== null ? magestockDiscoverySemver((string)$release['maximum_source_version'], 'version source maximale') : null;
    if ($minimum !== null && version_compare($installed, $minimum, '<')) throw new MageStockUpdateException('Aucun paquet distant n’accepte cette ancienne version de MageStock.', 'discovery_source_too_old');
    if ($maximum !== null && version_compare($installed, $maximum, '>')) throw new MageStockUpdateException('Le manifeste distant ne couvre pas cette version source.', 'discovery_source_too_new');
    $requirements = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : [];
    $php = trim((string)($requirements['php'] ?? ''));
    if ($php === '' || !magestockDiscoverySupportsPhp($php)) throw new MageStockUpdateException('La version de PHP du serveur ne satisfait pas la publication distante.', 'discovery_php_incompatible');
    if (($requirements['database'] ?? null) !== null) throw new MageStockUpdateException('Cette publication exige une base de données non compatible avec MageStock.', 'discovery_database_incompatible');
    if (!is_int($requirements['free_disk_bytes'] ?? null) || (int)$requirements['free_disk_bytes'] < 0) throw new MageStockUpdateException('Prérequis disque distant invalide.', 'discovery_disk_requirement');
    if (!is_array($manifest['artifacts'] ?? null) || !$manifest['artifacts']) throw new MageStockUpdateException('Aucun artefact publié.', 'discovery_artifact_missing');
    return $manifest;
}
function magestockDiscoveryArtifactScore(array $artifact, string $installed): int {
    $variant = strtolower((string)($artifact['variant'] ?? 'default'));
    $filename = strtolower((string)($artifact['filename'] ?? ''));
    $needle = strtolower($installed);
    if (str_contains($variant, $needle) || str_contains($filename, 'from-v' . $needle) || str_contains($filename, 'direct-v' . $needle)) return 0;
    if (str_contains($variant, 'direct') || str_contains($variant, 'exact')) return 10;
    if (str_contains($variant, 'cumulative') || str_contains($variant, 'rollup') || str_contains($filename, 'cumulative')) return 20;
    if ($variant === 'default') return 30;
    return 40;
}
function magestockDiscoverySelectArtifact(array $manifest, string $installed, string $keyId): array {
    $candidates = [];
    $updateArtifacts = array_values(array_filter($manifest['artifacts'] ?? [], static fn(mixed $artifact): bool => is_array($artifact) && ($artifact['type'] ?? '') === 'update'));
    $requiresArtifactCompatibility = count($updateArtifacts) > 1;
    foreach ($updateArtifacts as $artifact) {
        $filename = (string)($artifact['filename'] ?? '');
        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$/', $filename)) continue;
        $size = (int)($artifact['size_bytes'] ?? 0);
        $sha = strtolower((string)($artifact['sha256'] ?? ''));
        $signature = is_array($artifact['signature'] ?? null) ? $artifact['signature'] : [];
        if ($size < 1 || $size > MAGESTOCK_UPDATE_MAX_ARCHIVE || !preg_match('/^[a-f0-9]{64}$/', $sha)) continue;
        if (($signature['algorithm'] ?? '') !== 'ed25519' || ($signature['payload_schema'] ?? '') !== 'magekorp.artifact-signature-payload.v1' || ($signature['key_id'] ?? '') !== $keyId) continue;
        $declaredSignature = base64_decode((string)($signature['value'] ?? ''), true);
        if (!is_string($declaredSignature) || strlen($declaredSignature) !== SODIUM_CRYPTO_SIGN_BYTES) continue;

        try {
            $exactSource = isset($artifact['from_version']) && $artifact['from_version'] !== null
                ? magestockDiscoverySemver((string)$artifact['from_version'], 'version source exacte de l’artefact')
                : null;
            $minimumSource = isset($artifact['minimum_source_version']) && $artifact['minimum_source_version'] !== null
                ? magestockDiscoverySemver((string)$artifact['minimum_source_version'], 'version source minimale de l’artefact')
                : null;
            $maximumSource = isset($artifact['maximum_source_version']) && $artifact['maximum_source_version'] !== null
                ? magestockDiscoverySemver((string)$artifact['maximum_source_version'], 'version source maximale de l’artefact')
                : null;
        } catch (Throwable $e) {
            continue;
        }
        if ($exactSource !== null) {
            if ($minimumSource !== null || $maximumSource !== null || $installed !== $exactSource) continue;
            $minimumSource = $maximumSource = $exactSource;
        } elseif ($minimumSource !== null || $maximumSource !== null) {
            if ($minimumSource === null || $maximumSource === null || version_compare($minimumSource, $maximumSource, '>')) continue;
            if (version_compare($installed, $minimumSource, '<') || version_compare($installed, $maximumSource, '>')) continue;
        } elseif ($requiresArtifactCompatibility) {
            // Un manifeste contenant plusieurs ZIP doit déclarer la compatibilité de chacun.
            continue;
        }

        try { $url = magestockDiscoveryResolveUrl((string)($artifact['url'] ?? '')); } catch (Throwable $e) { continue; }
        $artifact['url'] = $url;
        $artifact['size_bytes'] = $size;
        $artifact['sha256'] = $sha;
        $artifact['_score'] = magestockDiscoveryArtifactScore($artifact, $installed);
        $artifact['_minimumSourceVersion'] = $minimumSource;
        $artifact['_maximumSourceVersion'] = $maximumSource;
        $candidates[] = $artifact;
    }
    if (!$candidates) throw new MageStockUpdateException('Aucun ZIP UPDATE compatible et signé n’est publié pour cette version.', 'discovery_update_artifact_missing');
    usort($candidates, static function (array $a, array $b): int {
        $score = ((int)$a['_score']) <=> ((int)$b['_score']);
        return $score !== 0 ? $score : ((int)$a['size_bytes'] <=> (int)$b['size_bytes']);
    });
    unset($candidates[0]['_score'], $candidates[0]['_minimumSourceVersion'], $candidates[0]['_maximumSourceVersion']);
    return $candidates[0];
}
function magestockDiscoverySafeLink(?string $value): ?string {
    if ($value === null || trim($value) === '') return null;
    try { return magestockDiscoveryResolveUrl($value); } catch (Throwable $e) { return null; }
}
function magestockDiscoveryCheck(bool $force = false, ?callable $transport = null): array {
    $cached = magestockDiscoveryCache();
    $next = magestockDiscoveryTimestamp(isset($cached['nextAutomaticCheckAt']) ? (string)$cached['nextAutomaticCheckAt'] : null);
    if (!$force && $next !== null && $next > time() && is_array($cached['result'] ?? null)) {
        $result = $cached['result'];
        $result['cached'] = true;
        return $result;
    }

    $checkedAt = magestockDiscoveryNow();
    $nextAt = date('c', time() + MAGESTOCK_DISCOVERY_CACHE_TTL);
    try {
        $state = magestockUpdateState();
        $installed = magestockDiscoverySemver((string)($state['installedVersion'] ?? APP_VER), 'version installée');
        $clientUrl = magestockDiscoveryResolveUrl('/api/v1/products/' . MAGESTOCK_DISCOVERY_PRODUCT . '/client-config');
        $clientResponse = magestockDiscoveryHttpGet($clientUrl, [], MAGESTOCK_DISCOVERY_MAX_JSON, $transport);
        $client = magestockDiscoveryValidateClientConfig(magestockDiscoveryDecodeJson($clientResponse['body'], 'Contrat client MageKorp'));
        $checkUrl = magestockDiscoveryEndpoint($client, 'update_check', ['channel'=>MAGESTOCK_DISCOVERY_CHANNEL,'version'=>$installed]);
        $checkResponse = magestockDiscoveryHttpGet($checkUrl, [], MAGESTOCK_DISCOVERY_MAX_JSON, $transport);
        $check = magestockDiscoveryValidateUpdateCheck(magestockDiscoveryDecodeJson($checkResponse['body'], 'Réponse de recherche MageKorp'), $installed);

        if (!($check['update_available'] ?? false)) {
            $result = [
                'status'=>'up_to_date','currentVersion'=>$installed,'latestVersion'=>$installed,'checkedAt'=>$checkedAt,
                'cached'=>false,'provider'=>'MageKorp Hub','channel'=>MAGESTOCK_DISCOVERY_CHANNEL,'updateAvailable'=>false,
            ];
            magestockDiscoveryWriteCache(['checkedAt'=>$checkedAt,'nextAutomaticCheckAt'=>$nextAt,'status'=>'up_to_date','result'=>$result,'lastError'=>null]);
            return $result;
        }

        $latest = magestockDiscoverySemver((string)$check['latest_version'], 'dernière version distante');
        $manifestUrl = magestockDiscoveryEndpoint($client, 'release_manifest', ['version'=>$latest,'channel'=>MAGESTOCK_DISCOVERY_CHANNEL]);
        $signatureUrl = magestockDiscoveryResolveUrl('/api/v1/products/' . MAGESTOCK_DISCOVERY_PRODUCT . '/releases/' . rawurlencode($latest) . '/manifest-signature?channel=' . rawurlencode(MAGESTOCK_DISCOVERY_CHANNEL));
        $trustUrl = magestockDiscoveryEndpoint($client, 'trust_store');
        $manifestResponse = magestockDiscoveryHttpGet($manifestUrl, [], MAGESTOCK_DISCOVERY_MAX_JSON, $transport);
        $signatureResponse = magestockDiscoveryHttpGet($signatureUrl, [], MAGESTOCK_DISCOVERY_MAX_SIGNATURE, $transport);
        $trustResponse = magestockDiscoveryHttpGet($trustUrl, [], MAGESTOCK_DISCOVERY_MAX_JSON, $transport);
        $manifestRaw = $manifestResponse['body'];
        $manifest = magestockDiscoveryValidateManifest(magestockDiscoveryDecodeJson($manifestRaw, 'Manifeste de publication'), $latest, $installed);
        $sidecar = magestockDiscoveryDecodeJson($signatureResponse['body'], 'Signature du manifeste');
        $trustStore = magestockDiscoveryDecodeJson($trustResponse['body'], 'Registre de confiance');
        $keyId = (string)($sidecar['key_id'] ?? '');
        $trust = magestockDiscoveryValidateTrustStore($trustStore, $keyId, (string)$manifest['release']['published_at']);
        magestockDiscoveryVerifyManifest($manifestRaw, $sidecar, $trust);
        $artifact = magestockDiscoverySelectArtifact($manifest, $installed, $keyId);
        $freeRequired = (int)$manifest['requirements']['free_disk_bytes'];
        $free = @disk_free_space(MAGESTOCK_UPDATE_ROOT);
        $serverReadyForDownload = !is_numeric($free) || (int)$free >= max($freeRequired, (int)$artifact['size_bytes'] * 2);
        $links = is_array($manifest['links'] ?? null) ? $manifest['links'] : [];
        $result = [
            'status'=>'available','currentVersion'=>$installed,'latestVersion'=>$latest,'checkedAt'=>$checkedAt,
            'cached'=>false,'provider'=>'MageKorp Hub','channel'=>MAGESTOCK_DISCOVERY_CHANNEL,'updateAvailable'=>true,
            'mandatory'=>(bool)$manifest['release']['mandatory'],'security'=>MAGESTOCK_DISCOVERY_CHANNEL === 'security',
            'publishedAt'=>(string)$manifest['release']['published_at'],
            'minimumSourceVersion'=>$manifest['release']['minimum_source_version'] ?? null,
            'maximumSourceVersion'=>$manifest['release']['maximum_source_version'] ?? null,
            'phpRequirement'=>(string)$manifest['requirements']['php'],'requiredFreeDiskBytes'=>$freeRequired,
            'serverReadyForDownload'=>$serverReadyForDownload,
            'artifact'=>[
                'filename'=>(string)$artifact['filename'],'sizeBytes'=>(int)$artifact['size_bytes'],'sha256'=>(string)$artifact['sha256'],
                'variant'=>(string)($artifact['variant'] ?? 'default'),'keyId'=>$keyId,'signatureDeclared'=>true,
            ],
            'trust'=>['keyId'=>$keyId,'status'=>$trust['status'],'fingerprintSha256'=>$trust['fingerprintSha256']],
            'links'=>[
                'releaseNotes'=>magestockDiscoverySafeLink(isset($links['release_notes']) ? (string)$links['release_notes'] : null),
                'documentation'=>magestockDiscoverySafeLink(isset($links['documentation']) ? (string)$links['documentation'] : null),
                'support'=>magestockDiscoverySafeLink(isset($links['support']) ? (string)$links['support'] : null),
            ],
            'artifactUrl'=>$artifact['url'],'manifestUrl'=>$manifestUrl,'signatureUrl'=>$signatureUrl,'trustStoreUrl'=>$trustUrl,
            'manifestSha256'=>hash('sha256', $manifestRaw),
        ];
        magestockDiscoveryWriteCache(['checkedAt'=>$checkedAt,'nextAutomaticCheckAt'=>$nextAt,'status'=>'available','result'=>$result,'lastError'=>null]);
        return $result;
    } catch (Throwable $e) {
        $message = $e instanceof MageStockUpdateException ? $e->getMessage() : 'Le contrôle distant a échoué.';
        magestockDiscoveryWriteCache([
            'checkedAt'=>$checkedAt,'nextAutomaticCheckAt'=>$nextAt,'status'=>'unavailable',
            'result'=>['status'=>'unavailable','checkedAt'=>$checkedAt,'cached'=>false,'provider'=>'MageKorp Hub','channel'=>MAGESTOCK_DISCOVERY_CHANNEL,'updateAvailable'=>false],
            'lastError'=>$message,
        ]);
        throw $e;
    }
}
function magestockDiscoveryDownloadOnce(string $url, string $destination, int $maxBytes, ?callable $transport = null): array {
    $url = magestockDiscoveryValidateUrl($url);
    if ($transport !== null) {
        $response = magestockDiscoveryNormalizeResponse($transport($url, [], $maxBytes, 'download'), $url);
        if (in_array($response['status'], [301,302,303,307,308], true)) return $response;
        if ($response['status'] !== 200) throw new MageStockUpdateException('Téléchargement distant refusé.', 'discovery_download_status', 503, ['status'=>$response['status']]);
        if (strlen($response['body']) > $maxBytes) throw new MageStockUpdateException('ZIP distant trop volumineux.', 'discovery_download_too_large');
        if (@file_put_contents($destination, $response['body'], LOCK_EX) === false) throw new MageStockUpdateException('Écriture du ZIP téléchargé impossible.', 'discovery_download_write', 500);
        return ['status'=>200,'headers'=>$response['headers'],'url'=>$response['url'],'bytes'=>strlen($response['body'])];
    }

    magestockUpdateEnsureDir(dirname($destination));
    $handle = @fopen($destination, 'wb');
    if (!$handle) throw new MageStockUpdateException('Création du fichier temporaire impossible.', 'discovery_download_write', 500);
    $bytes = 0;
    try {
        if (function_exists('curl_init')) {
            $responseHeaders = [];
            $tooLarge = false;
            $ch = curl_init($url);
            if ($ch === false) throw new MageStockUpdateException('Initialisation du téléchargement impossible.', 'discovery_http_init', 500);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER=>false,CURLOPT_FOLLOWLOCATION=>false,
                CURLOPT_CONNECTTIMEOUT=>MAGESTOCK_DISCOVERY_CONNECT_TIMEOUT,CURLOPT_TIMEOUT=>MAGESTOCK_DISCOVERY_DOWNLOAD_TIMEOUT,
                CURLOPT_HTTPHEADER=>['Accept: application/zip, application/octet-stream','User-Agent: MageStock/' . APP_VER . ' UpdateDiscovery'],
                CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,CURLOPT_ENCODING=>'identity',
                CURLOPT_HEADERFUNCTION=>static function ($curl, string $line) use (&$responseHeaders): int {
                    $length=strlen($line); $pos=strpos($line, ':');
                    if ($pos!==false) { $name=strtolower(trim(substr($line,0,$pos))); if($name!=='') $responseHeaders[$name]=trim(substr($line,$pos+1)); }
                    return $length;
                },
                CURLOPT_WRITEFUNCTION=>static function ($curl, string $chunk) use ($handle, &$bytes, &$tooLarge, $maxBytes): int {
                    $length=strlen($chunk); if($bytes+$length>$maxBytes){$tooLarge=true;return 0;}
                    $written=fwrite($handle,$chunk); if($written===false)return 0; $bytes+=$written; return $written;
                },
            ]);
            if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTPS')) curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
            $ok=curl_exec($ch); $errorNo=curl_errno($ch); $error=curl_error($ch); $status=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE); $effective=(string)curl_getinfo($ch,CURLINFO_EFFECTIVE_URL); curl_close($ch);
            if($tooLarge) throw new MageStockUpdateException('ZIP distant trop volumineux.','discovery_download_too_large');
            if($ok===false){$code=$errorNo===CURLE_OPERATION_TIMEDOUT?'discovery_download_timeout':'discovery_download_network';throw new MageStockUpdateException('Téléchargement interrompu.',$code,503,['transportError'=>$error]);}
            return ['status'=>$status,'headers'=>$responseHeaders,'url'=>magestockDiscoveryValidateUrl($effective?:$url),'bytes'=>$bytes];
        }

        $context=stream_context_create(['http'=>['method'=>'GET','header'=>'Accept: application/zip, application/octet-stream\r\nUser-Agent: MageStock/'.APP_VER.' UpdateDiscovery\r\n','timeout'=>MAGESTOCK_DISCOVERY_DOWNLOAD_TIMEOUT,'ignore_errors'=>true,'follow_location'=>0,'max_redirects'=>0],'ssl'=>['verify_peer'=>true,'verify_peer_name'=>true,'allow_self_signed'=>false]]);
        $remote=@fopen($url,'rb',false,$context); if(!$remote)throw new MageStockUpdateException('Téléchargement distant impossible.','discovery_download_network',503);
        while(!feof($remote)){ $chunk=fread($remote,65536); if($chunk===false)throw new MageStockUpdateException('Lecture distante interrompue.','discovery_download_network',503); if($bytes+strlen($chunk)>$maxBytes)throw new MageStockUpdateException('ZIP distant trop volumineux.','discovery_download_too_large'); if(fwrite($handle,$chunk)===false)throw new MageStockUpdateException('Écriture du ZIP impossible.','discovery_download_write',500); $bytes+=strlen($chunk); }
        $metadata=stream_get_meta_data($remote); fclose($remote); $headers=[]; $status=0;
        foreach(($metadata['wrapper_data']??[]) as $line){if(preg_match('#^HTTP/\S+\s+(\d{3})#i',$line,$m)){$status=(int)$m[1];continue;}$pos=strpos($line,':');if($pos!==false)$headers[strtolower(trim(substr($line,0,$pos)))]=trim(substr($line,$pos+1));}
        return ['status'=>$status,'headers'=>$headers,'url'=>$url,'bytes'=>$bytes];
    } finally { fclose($handle); }
}
function magestockDiscoveryDownloadFile(string $url, string $destination, int $maxBytes, ?callable $transport = null, int $redirects = 0): array {
    @unlink($destination);
    $result = magestockDiscoveryDownloadOnce($url, $destination, $maxBytes, $transport);
    if (in_array((int)$result['status'], [301,302,303,307,308], true)) {
        @unlink($destination);
        if ($redirects >= MAGESTOCK_DISCOVERY_MAX_REDIRECTS) throw new MageStockUpdateException('Trop de redirections de téléchargement.', 'discovery_download_redirect_loop');
        $location = magestockDiscoveryHeaderValue($result['headers'], 'location');
        if ($location === null || $location === '') throw new MageStockUpdateException('Redirection de téléchargement invalide.', 'discovery_download_redirect_invalid');
        if (!preg_match('#^https://#i', $location)) {
            if (!str_starts_with($location, '/')) throw new MageStockUpdateException('Redirection de téléchargement invalide.', 'discovery_download_redirect_invalid');
            $location = rtrim(MAGESTOCK_DISCOVERY_ORIGIN, '/') . $location;
        }
        return magestockDiscoveryDownloadFile(magestockDiscoveryValidateUrl($location), $destination, $maxBytes, $transport, $redirects + 1);
    }
    if ((int)$result['status'] !== 200) { @unlink($destination); throw new MageStockUpdateException('Téléchargement distant refusé.', 'discovery_download_status', 503, ['status'=>$result['status']]); }
    return $result;
}
function magestockDiscoveryDownload(?array $actor = null, ?callable $transport = null): array {
    $result = magestockDiscoveryCheck(true, $transport);
    if (($result['status'] ?? '') !== 'available' || !($result['updateAvailable'] ?? false)) throw new MageStockUpdateException('Aucune mise à jour distante à télécharger.', 'discovery_no_update', 409);
    if (!($result['serverReadyForDownload'] ?? false)) throw new MageStockUpdateException('Espace disque insuffisant pour télécharger et préparer la mise à jour.', 'discovery_disk_insufficient', 507);
    $artifact = is_array($result['artifact'] ?? null) ? $result['artifact'] : [];
    $expectedSize=(int)($artifact['sizeBytes']??0); $expectedSha=strtolower((string)($artifact['sha256']??''));
    if($expectedSize<1||$expectedSize>MAGESTOCK_UPDATE_MAX_ARCHIVE||!preg_match('/^[a-f0-9]{64}$/',$expectedSha))throw new MageStockUpdateException('Descripteur du ZIP distant invalide.','discovery_artifact_invalid');
    $dir=MAGESTOCK_UPDATE_ROOT.'/remote'; magestockUpdateEnsureDir($dir); $temporary=$dir.'/'.magestockUpdateId().'.part';
    try {
        magestockDiscoveryDownloadFile((string)$result['artifactUrl'],$temporary,min(MAGESTOCK_UPDATE_MAX_ARCHIVE,$expectedSize+1),$transport);
        $actualSize=(int)filesize($temporary); if($actualSize!==$expectedSize)throw new MageStockUpdateException('Taille du ZIP téléchargé différente du manifeste.','discovery_download_size');
        $actualSha=strtolower((string)hash_file('sha256',$temporary)); if(!hash_equals($expectedSha,$actualSha))throw new MageStockUpdateException('SHA-256 du ZIP téléchargé invalide.','discovery_download_checksum');
        $stored=magestockUpdateStoreLocalPackage($temporary,$actor,'remote',true,[
            'expectedTargetVersion'=>(string)$result['latestVersion'],'expectedPackageSha256'=>$expectedSha,
            'remoteProvider'=>'MageKorp Hub','remoteFilename'=>(string)($artifact['filename']??''),'remoteManifestSha256'=>(string)($result['manifestSha256']??''),
        ]);
        $stored['discovery']=array_diff_key($result,array_flip(['artifactUrl','manifestUrl','signatureUrl','trustStoreUrl']));
        return $stored;
    } catch(Throwable $e){@unlink($temporary);throw $e;}
}
