<?php
declare(strict_types=1);

namespace PSLauncher\Tools;

/**
 * Logique de re-signature du manifest, sans dépendance shell.
 * Utilisable depuis le CLI (tools/sign-manifest.php) et depuis le backoffice
 * web (admin/versions.php → action 'sync'). Évite tout appel à exec().
 */
final class SignManifest
{
    public string $manifestPath;
    public string $buildsDir;
    public ?string $configPath;
    public string $hashCachePath;
    /** @var string[] */
    public array $log = [];

    public function __construct(string $rootDir)
    {
        $this->manifestPath  = $rootDir . '/manifest/versions.json';
        $this->buildsDir     = $rootDir . '/builds';
        $this->configPath    = $rootDir . '/api/config.php';
        // Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
        // { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
        // Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
        // de pouvoir lire le cache).
        $this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
    }

    private function out(string $line): void { $this->log[] = $line; }

    /**
     * Lit le cache des hashs précalculés. Retourne un dictionnaire
     * [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
     * @return array<string, array{size:int,mtime:int,sha256:string}>
     */
    private function loadHashCache(): array
    {
        if (!is_file($this->hashCachePath)) return [];
        $raw = @file_get_contents($this->hashCachePath);
        if ($raw === false) return [];
        $j = json_decode($raw, true);
        return is_array($j) ? $j : [];
    }

    /** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
    private function saveHashCache(array $cache): void
    {
        @file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
    }

    /**
     * Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
     * Le cache est invalidé dès que la taille OU mtime du fichier a changé,
     * ce qui permet de garder le cache à jour sans intervention manuelle.
     *
     * @param array<string, array{size:int,mtime:int,sha256:string}> $cache
     * @return array{sha256:string, fromCache:bool, durationMs:int}
     */
    private function getOrComputeSha256(string $path, array &$cache): array
    {
        $size  = filesize($path) ?: 0;
        $mtime = filemtime($path) ?: 0;
        $key   = realpath($path) ?: $path;

        if (isset($cache[$key])
            && ($cache[$key]['size'] ?? -1)  === $size
            && ($cache[$key]['mtime'] ?? -1) === $mtime
            && !empty($cache[$key]['sha256'])) {
            return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
        }

        $start = microtime(true);
        $sha = hash_file('sha256', $path);
        $duration = (int)((microtime(true) - $start) * 1000);

        $cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
        return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
    }

    /**
     * Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
     *
     * Stratégie de hash :
     *   - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
     *   - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
     *   - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
     *     on évite carrément l'appel à hash_file()
     *   - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
     *     dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
     *   - $force = true pour forcer un recalcul intégral (utile en cas de doute)
     *
     * Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
     * pour les versions inchangées + ~1 min par nouvelle version uploadée.
     *
     * @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
     * @param bool   $force Si true, ignore le cache et recalcule tous les hashs
     * @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
     *                              versions[] (les autres restent inchangées). Le scope 'launcher'
     *                              est ignoré dans ce cas.
     * @return array{ok:bool, log:string[]}
     */
    public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
    {
        if (!is_file($this->manifestPath)) {
            $this->out("Manifest introuvable : {$this->manifestPath}");
            return ['ok' => false, 'log' => $this->log];
        }

        $manifest = json_decode(file_get_contents($this->manifestPath), true);
        if (!is_array($manifest)) {
            $this->out("Manifest illisible (JSON invalide)");
            return ['ok' => false, 'log' => $this->log];
        }

        $doVersions = ($scope === 'all' || $scope === 'versions');
        $doLauncher = ($scope === 'all' || $scope === 'launcher');
        if ($onlyVersion !== null) {
            // Mode "hash une seule version" : on ne touche pas au launcher,
            // et on ne hash que la version demandée dans la section versions[].
            $doLauncher = false;
            $doVersions = true;
        }

        // Charge le cache des hashs (indexé par chemin réel du ZIP)
        $cache = $this->loadHashCache();
        $cacheChanged = false;

        $hashedVersions = [];
        if ($doVersions) foreach ($manifest['versions'] as &$v) {
            $version = $v['version'] ?? '?';
            if ($onlyVersion !== null && $version !== $onlyVersion) {
                continue; // skip silently les autres versions
            }
            $url     = $v['download']['url'] ?? '';
            if ($url === '') {
                $this->out("  [skip] $version : pas d'URL dans le manifest");
                continue;
            }

            $filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
            $zip = "{$this->buildsDir}/$filename";

            if (!is_file($zip)) {
                $candidates = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [];
                $candidates = array_values(array_filter($candidates, 'is_file'));
                if (count($candidates) === 1) {
                    $zip = $candidates[0];
                    $this->out("  [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place");
                } else {
                    $this->out("  [skip] $version : ZIP introuvable pour $url");
                    if (count($candidates) > 1) {
                        $this->out("         Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)));
                    }
                    continue;
                }
            }

            $size = filesize($zip) ?: 0;
            $v['download']['sizeBytes'] = $size;

            // Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
            // on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
            // internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
            // signature Ed25519 du manifest reste). À utiliser avec parcimonie.
            $algo     = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
            $skipFlag = !empty($v['download']['skipHash']);
            if ($algo === 'none' || $skipFlag) {
                $v['download']['sha256']        = '';
                $v['download']['hashAlgorithm'] = 'none';
                $this->out("  [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
                $hashedVersions[] = $version;
                continue;
            }

            // Cache lookup (sauf si --force)
            if (!$force) {
                $key = realpath($zip) ?: $zip;
                $existingSha = (string)($v['download']['sha256'] ?? '');
                $hasValidSha = $existingSha !== ''
                            && !str_starts_with($existingSha, 'REPLACE');
                if ($hasValidSha
                    && isset($cache[$key])
                    && ($cache[$key]['size'] ?? -1)  === $size
                    && ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
                    && ($cache[$key]['sha256'] ?? '') === $existingSha) {
                    $this->out("  [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
                    $hashedVersions[] = $version;
                    continue;
                }
            }

            $r = $this->getOrComputeSha256($zip, $cache);
            $cacheChanged = true;
            $note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
            $this->out("  [hash] $version : " . basename($zip) . " ($size octets)  sha256={$r['sha256']} $note");
            $v['download']['sha256'] = $r['sha256'];
            $hashedVersions[] = $version;
        }
        unset($v);

        // Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
        // dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
        if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
            $launcher = &$manifest['launcher'];
            $lver = $launcher['version'] ?? '';
            $lurl = $launcher['download']['url'] ?? '';
            if ($lver !== '' && $lurl !== '') {
                $lfile = basename(parse_url($lurl, PHP_URL_PATH) ?: '');
                $lpath = "{$this->buildsDir}/launcher/{$lfile}";
                if (!is_file($lpath)) {
                    // Fallback : tolérant sur le nom (PSLauncher-X.Y.Z.exe, etc.)
                    $candidates = glob("{$this->buildsDir}/launcher/*{$lver}*.exe", GLOB_NOSORT) ?: [];
                    if (count($candidates) === 1) $lpath = $candidates[0];
                }
                if (is_file($lpath)) {
                    $lsize = filesize($lpath) ?: 0;
                    $launcher['download']['sizeBytes'] = $lsize;

                    // Cache lookup pour le launcher exe aussi
                    $lkey = realpath($lpath) ?: $lpath;
                    $existingLsha = (string)($launcher['download']['sha256'] ?? '');
                    $hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
                    if (!$force
                        && $hasValidLsha
                        && isset($cache[$lkey])
                        && ($cache[$lkey]['size'] ?? -1)  === $lsize
                        && ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
                        && ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
                        $this->out("  [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
                    } else {
                        $r = $this->getOrComputeSha256($lpath, $cache);
                        $cacheChanged = true;
                        $launcher['download']['sha256'] = $r['sha256'];
                        $note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
                        $this->out("  [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets)  sha256={$r['sha256']} $note");
                    }
                } else {
                    $this->out("  [launcher] v{$lver} : exe introuvable dans builds/launcher/");
                }
            }
            unset($launcher);
        }

        // Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
        // hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
        // qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
        // ancienne version isolée via $onlyVersion.
        if ($doVersions) {
            $eligible = [];
            foreach ($manifest['versions'] as $vEntry) {
                $sha  = (string)($vEntry['download']['sha256'] ?? '');
                $algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
                $hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
                if ($hasValid) $eligible[] = $vEntry['version'];
            }
            if (!empty($eligible)) {
                usort($eligible, 'version_compare');
                $newLatest = end($eligible);
                if (($manifest['latest'] ?? null) !== $newLatest) {
                    $this->out("  [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
                    $manifest['latest'] = $newLatest;
                }
            }
        }

        // Signature Ed25519
        if ($this->configPath && is_file($this->configPath)) {
            $config = require $this->configPath;
            $sk = $config['ed25519']['private_key_hex'] ?? '';
            if ($sk !== '' && strlen($sk) === 128) {
                $manifest['signature'] = null;
                $cryptoPath = dirname($this->configPath) . '/lib/Crypto.php';
                if (is_file($cryptoPath)) require_once $cryptoPath;
                if (class_exists('\PSLauncher\Crypto')) {
                    $payload = \PSLauncher\Crypto::canonicalJson($manifest);
                    $manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk);
                    $this->out("  [sign] manifest signé (Ed25519)");
                } else {
                    $this->out("  [warn] Classe \\PSLauncher\\Crypto introuvable, manifest non signé");
                }
            } else {
                $this->out("  [warn] ed25519.private_key_hex non configuré, manifest non signé");
            }
        }

        $jsonOut = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
        if (file_put_contents($this->manifestPath, $jsonOut) === false) {
            $this->out("Échec d'écriture du manifest : {$this->manifestPath}");
            return ['ok' => false, 'log' => $this->log];
        }

        // Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
        // obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
        if ($cacheChanged) {
            foreach (array_keys($cache) as $cachedPath) {
                if (!is_file($cachedPath)) unset($cache[$cachedPath]);
            }
            $this->saveHashCache($cache);
        }

        $this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
        return ['ok' => true, 'log' => $this->log];
    }
}
