<?php
declare(strict_types=1);

namespace PSLauncher\Routes;

use PSLauncher\Crypto;
use PSLauncher\Response;

/**
 * Endpoint manifest public. Reçoit ?channel=X depuis le launcher (channel
 * provenant de la license signée Ed25519). On filtre les versions pour ne
 * renvoyer que celles taggées sur ce channel ou sur "default", et on re-signe
 * le manifest filtré à la volée avec la clé privée Ed25519.
 *
 * Sémantique additive : un client sur channel "police" voit "default" + "police".
 * Pour rendre une version invisible au public, ne PAS la tagger "default".
 */
final class Manifest
{
    private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';

    public static function handle(array $config): void
    {
        $manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
        if (!is_file($manifestPath)) {
            Response::error('manifest_missing', 'versions.json not found on server', 404);
        }

        $raw = file_get_contents($manifestPath);
        $manifest = json_decode((string)$raw, true);
        if (!is_array($manifest)) {
            Response::error('manifest_invalid', 'versions.json is not valid JSON', 500);
        }

        // Channel demandé par le client. Whitelist regex anti-injection. Un
        // channel inconnu / mal formé est traité comme NULL → le client voit
        // uniquement les versions "default".
        $clientChannel = $_GET['channel'] ?? null;
        if (!is_string($clientChannel) || $clientChannel === '' || !preg_match(self::CHANNEL_REGEX, $clientChannel)) {
            $clientChannel = null;
        }

        $manifest['versions'] = self::filterVersions(
            $manifest['versions'] ?? [],
            $clientChannel
        );

        // Re-signature à la volée. La clé privée Ed25519 est dans config.php
        // (sert déjà aux licenses), accessible uniquement depuis le serveur.
        // Coût : ~1ms d'Ed25519 par requête, négligeable vs le réseau.
        $sk = $config['ed25519']['private_key_hex'] ?? '';
        if ($sk !== '') {
            unset($manifest['signature']); // important : canonical sans la signature
            $manifest['signature'] = Crypto::signEd25519(Crypto::canonicalJson($manifest), $sk);
        } else {
            unset($manifest['signature']); // pas de clé = pas de signature, le client échouera la vérif
        }

        $body = json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        // Cache court pour absorber les pics. ETag inclut le channel pour que
        // les caches HTTP intermédiaires séparent bien les vues par client.
        header('Cache-Control: public, max-age=300, must-revalidate');
        header('ETag: "' . md5(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
        header('Content-Type: application/json; charset=utf-8');
        header('Content-Length: ' . strlen((string)$body));
        echo $body;
    }

    /**
     * Filtre + dédupe par numéro de version.
     *
     * Étape 1 — filter : on garde les versions visibles par ce client. Un
     * client sur channel X voit les entries taggées 'default' OU 'X'.
     *
     * Étape 2 — dedupe : pour chaque numéro de version qui apparaît plusieurs
     * fois (cas v1.5.3 default + v1.5.3 firefighter), on ne renvoie qu'UNE
     * seule entry — la plus spécifique au client. Priorité : l'entry taggée
     * avec le channel propre du client gagne ; à défaut, l'entry default
     * sert de fallback. Sans cette dédup côté serveur, le launcher recevait
     * deux entries v1.5.3 et son `ToDictionary(v => v.Version)` collisionnait
     * silencieusement, ce qui faisait apparaître dans l'UI le mauvais ZIP
     * pour un user firefighter.
     *
     * @param array<int, array<string,mixed>> $versions
     * @return list<array<string,mixed>>
     */
    private static function filterVersions(array $versions, ?string $clientChannel): array
    {
        // Étape 1 : filter visible par ce client
        $visible = [];
        foreach ($versions as $v) {
            $channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels']))
                ? $v['channels']
                : ['default'];

            $isPublic    = in_array('default', $channels, true);
            $matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true);

            if ($isPublic || $matchesUser) {
                $visible[] = ['entry' => $v, 'channels' => $channels];
            }
        }

        // Étape 2 : group by version, pick most specific per group
        $byVersion = [];
        foreach ($visible as $item) {
            $key = (string)($item['entry']['version'] ?? '?');
            $byVersion[$key][] = $item;
        }

        $result = [];
        foreach ($byVersion as $items) {
            if (count($items) === 1) {
                $result[] = $items[0]['entry'];
                continue;
            }
            // Plusieurs entries pour ce numéro : on cherche d'abord une qui
            // matche le channel spécifique du client (firefighter, police…),
            // sinon on retombe sur l'entry default.
            $specific     = null;
            $defaultEntry = null;
            foreach ($items as $item) {
                if ($clientChannel !== null
                    && $specific === null
                    && in_array($clientChannel, $item['channels'], true)) {
                    $specific = $item;
                }
                if ($defaultEntry === null && in_array('default', $item['channels'], true)) {
                    $defaultEntry = $item;
                }
            }
            $picked = $specific ?? $defaultEntry ?? $items[0];
            $result[] = $picked['entry'];
        }

        return array_values($result);
    }
}
