using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using PSLauncher.App.Views;
using Microsoft.Extensions.DependencyInjection;
using PSLauncher.App.Services;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Health;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates;
using PSLauncher.Models;

namespace PSLauncher.App.ViewModels;

/// <summary>
/// Onglets affichés dans la sidebar droite du launcher. Chacun correspond à un
/// bloc de contenu visible/caché dans la fenêtre principale.
/// </summary>
public enum LauncherPage { Library, Report, Documentation }

public sealed partial class MainViewModel : ObservableObject
{
    private readonly IInstallationRegistry _registry;
    private readonly IProcessLauncher _processLauncher;
    private readonly IConfigStore _configStore;
    private readonly LocalConfig _config;
    private readonly IManifestService _manifestService;
    private readonly IUpdateChecker _updateChecker;
    private readonly IDownloadManager _downloadManager;
    private readonly IZipInstaller _zipInstaller;
    private readonly ILicenseService _licenseService;
    private readonly ILauncherSelfUpdater _selfUpdater;
    private readonly IToastService _toastService;
    private readonly IDatabaseMigrationService _migrationService;
    private readonly IReportToolDeployer _reportDeployer;
    private readonly IDocToolDeployer _docDeployer;
    private readonly IApiToolDeployer _apiDeployer;
    private readonly ISteamVrSettingsDeployer _steamVrDeployer;
    private readonly IPeerSourceResolver _peerResolver;
    private readonly IZipCacheStore _zipCacheStore;
    private readonly ILanCacheServer _lanCacheServer;
    private readonly ILanDiscoveryService _lanDiscovery;
    private readonly ISystemHealthService _healthService;
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<MainViewModel> _logger;

    private LicenseValidationResponse? _license;

    /// <summary>
    /// Host du peer LAN utilisé pour le DL en cours. Null = DL depuis OVH.
    /// Sert à différencier visuellement la source dans le footer (📡 vs ⬇)
    /// pendant TOUT le DL, pas juste 1 s avant que le progress prenne le relais.
    /// </summary>
    private string? _currentPeerHost;

    private RemoteManifest? _lastManifest;
    private CancellationTokenSource? _activeDownloadCts;
    private VersionRowViewModel? _activeRow;
    /// <summary>
    /// Référence au Task de l'install/DL en cours, exposée aux commandes externes
    /// (ex. RestartFromZero) qui ont besoin d'attendre la fin réelle des écritures
    /// disque avant de supprimer le partial. Sinon les segments continuent à écrire
    /// pendant 1-2 sec après le Cancel() et recréent le fichier qu'on vient de delete.
    /// </summary>
    private Task? _activeInstallTask;

    /// <summary>
    /// Référence au process PROSERVE lancé par le launcher (la PLUS RÉCENTE).
    /// Sert de garde-fou contre les double-lancements : avant chaque
    /// <see cref="LaunchVersion"/>, on vérifie que ce champ est null ou que le
    /// process a terminé. Le hook Exited remet le champ à null pour autoriser
    /// le prochain lancement (manuel ou auto). Couvre uniquement les instances
    /// LANCÉES PAR NOUS — pour détecter une instance lancée hors launcher, on
    /// délègue à <see cref="IProcessLauncher.IsRunning(InstalledVersion)"/>.
    /// </summary>
    private System.Diagnostics.Process? _runningProserve;

    /// <summary>
    /// True quand un install vient d'auto-retry suite à un 404 + manifest refresh.
    /// Évite la boucle infinie : si l'install retry échoue ENCORE avec 404,
    /// on bascule sur le message "manifest serveur aussi obsolète" au lieu de
    /// retry indéfiniment. Reseté à false en début d'install et après le retry.
    /// </summary>
    private bool _install404RetryConsumed;

    public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();

    /// <summary>
    /// Indicateurs affichés dans le bandeau de santé sous la top bar : pings,
    /// processus requis (SteamVR, Vive Streaming, etc.). Liste construite depuis
    /// <see cref="HealthChecksConfig.Checks"/> au démarrage. Refresh périodique
    /// piloté par <see cref="StartHealthLoop"/>.
    /// </summary>
    public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
    public bool HasHealthIndicators => HealthIndicators.Count > 0;

    /// <summary>
    /// Cancelle les boucles de polling per-check actuellement en vol. Recréée à
    /// chaque appel à <see cref="StartHealthLoop"/> pour permettre un redémarrage
    /// propre après édition des checks dans Settings (hot-reload sans relancer
    /// l'app).
    /// </summary>
    private CancellationTokenSource? _healthLoopCts;

    /// <summary>
    /// Le bandeau santé n'a de sens que sur la page Library — Reports et
    /// Documentation sont des WebView pleine-page où afficher des pills
    /// de monitoring système n'apporte rien et coupe visuellement.
    /// </summary>
    public bool ShowHealthBanner => HasHealthIndicators && IsLibrary;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
    [NotifyPropertyChangedFor(nameof(HasOtherVersions))]
    [NotifyPropertyChangedFor(nameof(EmptyHintVisibility))]
    private VersionRowViewModel? _featuredVersion;

    public bool HasFeaturedVersion => FeaturedVersion is not null;
    public bool HasOtherVersions => OtherVersions.Count > 0;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(FooterText))]
    [NotifyPropertyChangedFor(nameof(FooterVisibility))]
    private string? _statusMessage;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(FooterText))]
    [NotifyPropertyChangedFor(nameof(FooterVisibility))]
    [NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
    private bool _isBusy;

    /// <summary>
    /// Flag dédié au téléchargement en cours, distinct de <see cref="IsBusy"/> qui
    /// couvre AUSSI Check updates / Install / Uninstall / Self-update. Sert à scoper
    /// la visibilité du bouton "Annuler" du footer aux seuls DL — sinon il apparaît
    /// 1 s pendant un Check updates et décale tout le layout.
    /// </summary>
    [ObservableProperty]
    private bool _isDownloadActive;

    [ObservableProperty] private double _progressPercent;

    /// <summary>
    /// Onglet actuellement actif dans la sidebar gauche. La UI montre le bon bloc
    /// via les converters IsLibrary / IsReport / IsDocumentation. Default = Library
    /// pour que le launcher s'ouvre directement sur l'écran principal.
    /// On notifie aussi FooterVisibility/FooterText : le footer (barre de DL,
    /// status d'install) appartient à la page Library uniquement — il n'a aucun
    /// sens visuel sur Report/Documentation qui sont des WebView pleine page.
    /// </summary>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsLibrary))]
    [NotifyPropertyChangedFor(nameof(IsReport))]
    [NotifyPropertyChangedFor(nameof(IsDocumentation))]
    [NotifyPropertyChangedFor(nameof(FooterVisibility))]
    [NotifyPropertyChangedFor(nameof(FooterText))]
    [NotifyPropertyChangedFor(nameof(ShowHealthBanner))]
    private LauncherPage _currentPage = LauncherPage.Library;

    public bool IsLibrary       => CurrentPage == LauncherPage.Library;
    public bool IsReport        => CurrentPage == LauncherPage.Report;
    public bool IsDocumentation => CurrentPage == LauncherPage.Documentation;

    /// <summary>URL résolue pour le panneau Reports. WebView2.Source attend un Uri.</summary>
    public Uri? ReportUri => TryParseUri(_config.ReportUrl);
    /// <summary>URL résolue pour le panneau Documentation. Null si non configurée.</summary>
    public Uri? DocumentationUri => TryParseUri(_config.DocumentationUrl);
    public bool HasDocumentationUrl => DocumentationUri is not null;

    private static Uri? TryParseUri(string? s)
        => !string.IsNullOrWhiteSpace(s) && Uri.TryCreate(s, UriKind.Absolute, out var u) ? u : null;

    [RelayCommand] private void NavigateLibrary()       => CurrentPage = LauncherPage.Library;
    [RelayCommand] private void NavigateReport()        => CurrentPage = LauncherPage.Report;
    [RelayCommand] private void NavigateDocumentation() => CurrentPage = LauncherPage.Documentation;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(FooterText))]
    [NotifyPropertyChangedFor(nameof(FooterVisibility))]
    private string? _progressDetail;

    /// <summary>
    /// True quand un téléchargement est actif (à utiliser pour le prompt « quitter ?» de
    /// la fenêtre principale). Distinct de <see cref="IsBusy"/> qui couvre aussi
    /// l'extraction et la vérification.
    /// </summary>
    public bool HasActiveDownload => _activeDownloadCts is not null
        && !_activeDownloadCts.IsCancellationRequested
        && _activeRow is not null
        && _activeRow.State == VersionRowState.Downloading;

    /// <summary>Version actuellement en cours de DL, ou null.</summary>
    public string? ActiveDownloadVersion => _activeRow?.Version;

    public string LicenseSummary
    {
        get
        {
            if (_license is null) return Strings.LicenseSummaryNone;
            if (_license.Status == "valid")
            {
                return Strings.LicenseSummaryValid(_license.OwnerName ?? "—",
                    Strings.FormatDate(_license.DownloadEntitlementUntil));
            }
            if (_license.Status == "expired")
                return Strings.LicenseSummaryExpired(Strings.FormatDate(_license.DownloadEntitlementUntil));
            if (_license.Status == "revoked")
                return Strings.LicenseSummaryRevoked;
            return _license.Status;
        }
    }

    public string LicenseIcon
    {
        get
        {
            if (_license is null) return "🔒";
            return _license.Status switch
            {
                "valid" when IsLicenseExpiringSoon() => "⏰",
                "valid"                              => "✓",
                "expired"                            => "⚠",
                "revoked"                            => "🚫",
                _                                    => "❓",
            };
        }
    }

    /// <summary>
    /// Catégorie utilisée par la UI pour piloter la couleur du badge license.
    /// </summary>
    public string LicenseSeverity
    {
        get
        {
            if (_license is null) return "Error";
            return _license.Status switch
            {
                "valid" when IsLicenseExpiringSoon() => "Warning",
                "valid"                              => "Ok",
                _                                    => "Error",
            };
        }
    }

    private bool IsLicenseExpiringSoon()
    {
        if (_license?.DownloadEntitlementUntil is not { } until) return false;
        return (until - DateTime.UtcNow).TotalDays < 30;
    }

    private void NotifyLicenseChanged()
    {
        OnPropertyChanged(nameof(LicenseSummary));
        OnPropertyChanged(nameof(LicenseIcon));
        OnPropertyChanged(nameof(LicenseSeverity));
    }

    public string EmptyHint => Strings.EmptyHint(_config.InstallRoot);

    public Visibility EmptyHintVisibility =>
        FeaturedVersion is null && OtherVersions.Count == 0
            ? Visibility.Visible : Visibility.Collapsed;

    /// <summary>
    /// Footer visible UNIQUEMENT sur la page Library : c'est elle qui héberge
    /// les téléchargements et installs, donc le seul contexte où la barre
    /// de progression a du sens. Sur Report/Documentation (WebView pleine page),
    /// le footer disparaît même si IsBusy ou StatusMessage non vide — l'opération
    /// continue en arrière-plan, le user retrouve son état en revenant sur Library.
    /// </summary>
    public Visibility FooterVisibility =>
        IsLibrary && (IsBusy || !string.IsNullOrEmpty(StatusMessage))
            ? Visibility.Visible : Visibility.Collapsed;

    public string FooterText =>
        !string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
        StatusMessage ?? string.Empty;

    /// <summary>
    /// Version courte du launcher au format "v0.24.0", affichée dans la sidebar.
    /// Lit l'AssemblyVersion (Major.Minor.Build) — le 4e champ (Revision) toujours .0
    /// est tronqué pour rester lisible.
    /// </summary>
    public string AppVersion
    {
        get
        {
            var v = Assembly.GetExecutingAssembly().GetName().Version;
            return v is null ? "v?" : $"v{v.Major}.{v.Minor}.{v.Build}";
        }
    }

    /// <summary>
    /// IPv4 locale du PC affichée dans la sidebar pour le diagnostic terrain
    /// (le support peut demander "quelle IP a ce poste ?" sans naviguer dans
    /// les paramètres Windows). On filtre sur l'interface qui a une default
    /// gateway IPv4 — élimine loopback, Hyper-V virtual switch, VPN inactifs,
    /// et autres interfaces sans route. "—" si aucune connectivité réseau.
    /// </summary>
    public string LocalIpAddress
    {
        get
        {
            try
            {
                var ip = NetworkInterface.GetAllNetworkInterfaces()
                    .Where(n => n.OperationalStatus == OperationalStatus.Up
                             && n.NetworkInterfaceType != NetworkInterfaceType.Loopback
                             && n.GetIPProperties().GatewayAddresses
                                  .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
                    .SelectMany(n => n.GetIPProperties().UnicastAddresses)
                    .Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
                    .Select(a => a.Address.ToString())
                    .FirstOrDefault();
                return ip ?? "—";
            }
            catch
            {
                return "—";
            }
        }
    }

    /// <summary>
    /// Statut du serveur LAN affiché dans la sidebar info :
    /// "ON" si activé+actif, "KO" si activé mais le bind a échoué (port pris,
    /// pas d'IP LAN…), "—" si désactivé. Refresh toutes les 10 s par timer.
    /// </summary>
    public string LanServerStatus
    {
        get
        {
            if (!_config.LanCache.ServerEnabled) return "—";
            return _lanCacheServer.IsRunning ? "ON" : "KO";
        }
    }

    /// <summary>"ON" / "—" selon le flag client. Pas de notion d'erreur côté client (probe à la demande).</summary>
    public string LanClientStatus => _config.LanCache.ClientEnabled ? "ON" : "—";

    /// <summary>Nombre de peers découverts auto via UDP (TTL 60 s côté LanDiscoveryService).</summary>
    public int LanPeerCount => _lanDiscovery.ListDiscovered().Count;

    private System.Windows.Threading.DispatcherTimer? _lanInfoRefreshTimer;

    public MainViewModel(
        IInstallationRegistry registry,
        IProcessLauncher processLauncher,
        IConfigStore configStore,
        LocalConfig config,
        IManifestService manifestService,
        IUpdateChecker updateChecker,
        IDownloadManager downloadManager,
        IZipInstaller zipInstaller,
        ILicenseService licenseService,
        ILauncherSelfUpdater selfUpdater,
        IToastService toastService,
        IDatabaseMigrationService migrationService,
        IReportToolDeployer reportDeployer,
        IDocToolDeployer docDeployer,
        IApiToolDeployer apiDeployer,
        ISteamVrSettingsDeployer steamVrDeployer,
        IPeerSourceResolver peerResolver,
        IZipCacheStore zipCacheStore,
        ILanCacheServer lanCacheServer,
        ILanDiscoveryService lanDiscovery,
        ISystemHealthService healthService,
        IServiceProvider serviceProvider,
        ILogger<MainViewModel> logger)
    {
        _registry = registry;
        _processLauncher = processLauncher;
        _configStore = configStore;
        _config = config;
        _manifestService = manifestService;
        _updateChecker = updateChecker;
        _downloadManager = downloadManager;
        _zipInstaller = zipInstaller;
        _licenseService = licenseService;
        _selfUpdater = selfUpdater;
        _toastService = toastService;
        _migrationService = migrationService;
        _reportDeployer = reportDeployer;
        _docDeployer = docDeployer;
        _apiDeployer = apiDeployer;
        _steamVrDeployer = steamVrDeployer;
        _peerResolver = peerResolver;
        _zipCacheStore = zipCacheStore;
        _lanCacheServer = lanCacheServer;
        _lanDiscovery = lanDiscovery;
        _healthService = healthService;
        _serviceProvider = serviceProvider;
        _logger = logger;

        // Charge la license depuis le cache (pas d'appel réseau au démarrage,
        // ça reste rapide ; un refresh proactif arrive dès qu'on clique « Vérifier les MAJ »)
        _license = _licenseService.GetCached();

        RebuildList();
        InitHealthIndicators();

        // Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
        _ = Task.Run(async () =>
        {
            try
            {
                await Task.Delay(500); // laisse l'UI se stabiliser
                await Application.Current.Dispatcher.InvokeAsync(async () =>
                {
                    await CheckForUpdatesAsync();
                });
            }
            catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); }
        });

        // Boucle périodique des health checks. Ne tourne que si la config en
        // contient au moins un. Premier passage immédiat puis re-check tous les
        // RefreshIntervalSeconds.
        StartHealthLoop();

        // Rafraîchit le bloc info LAN de la sidebar toutes les 10 s. Le serveur
        // peut transitionner ON↔KO sans interaction user (port pris…), et le
        // peer count change au rythme des beacons UDP (15 s × TTL 60 s).
        // DispatcherTimer.Tick s'exécute sur le UI thread → OnPropertyChanged
        // peut updater les bindings WPF directement.
        _lanInfoRefreshTimer = new System.Windows.Threading.DispatcherTimer(
            TimeSpan.FromSeconds(10),
            System.Windows.Threading.DispatcherPriority.Background,
            (_, _) =>
            {
                OnPropertyChanged(nameof(LanServerStatus));
                OnPropertyChanged(nameof(LanClientStatus));
                OnPropertyChanged(nameof(LanPeerCount));
            },
            Application.Current.Dispatcher);
        _lanInfoRefreshTimer.Start();

        // Mode auto : si une version est désignée + feature activée, on la lance
        // automatiquement après que la UI soit prête (Dispatcher.BeginInvoke différé
        // pour que MainWindow ait fini son OnLoaded). Le grace period via popup modal
        // sera affiché par-dessus la fenêtre normalement.
        Application.Current.Dispatcher.BeginInvoke(
            new Action(TryAutoLaunchAtStartup),
            System.Windows.Threading.DispatcherPriority.Background);
    }

    /// <summary>
    /// Construit la collection observable des indicateurs depuis la config et
    /// notifie HasHealthIndicators pour que la UI cache le bandeau si la liste
    /// est vide. Idempotent — appelée au startup uniquement, pas à chaque refresh.
    /// </summary>
    private void InitHealthIndicators()
    {
        HealthIndicators.Clear();
        foreach (var entry in _config.HealthChecks.Checks)
            HealthIndicators.Add(new HealthIndicatorViewModel(entry));
        OnPropertyChanged(nameof(HasHealthIndicators));
        OnPropertyChanged(nameof(ShowHealthBanner));
    }

    /// <summary>
    /// Lance une boucle indépendante par check, chacune cadencée sur son propre
    /// <see cref="HealthCheckEntry.RefreshIntervalMs"/>. Un Process check rapide
    /// (~1 ms) peut tourner toutes les 1-2 s sans impact CPU, alors qu'un Ping
    /// (qui coûte un round-trip réseau) tourne typiquement à 5-10 s. Premier
    /// passage immédiat puis re-check selon l'intervalle.
    ///
    /// Cancelle la boucle précédente si elle existe (cas du hot-reload depuis
    /// Settings → édition des checks).
    /// </summary>
    private void StartHealthLoop()
    {
        // Stop l'ancienne boucle si on est en hot-reload (édition Settings)
        try { _healthLoopCts?.Cancel(); } catch { /* déjà disposée */ }
        _healthLoopCts?.Dispose();
        _healthLoopCts = null;

        if (HealthIndicators.Count == 0) return;

        _healthLoopCts = new CancellationTokenSource();
        var ct = _healthLoopCts.Token;

        foreach (var vm in HealthIndicators.ToList())
        {
            var localVm = vm;
            // Floor de 500 ms pour éviter qu'un user mette 0/1 ms par erreur dans le json
            // et sature un coeur CPU à pleins gaz.
            var intervalMs = Math.Max(500, localVm.Entry.RefreshIntervalMs);
            _ = Task.Run(async () =>
            {
                while (!ct.IsCancellationRequested)
                {
                    try
                    {
                        var result = await _healthService.RunCheckAsync(localVm.Entry, ct).ConfigureAwait(false);
                        if (ct.IsCancellationRequested) break;
                        await Application.Current.Dispatcher.InvokeAsync(() => localVm.Apply(result));
                    }
                    catch (OperationCanceledException) { break; }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Health check {Name} threw", localVm.Entry.Name);
                    }
                    try { await Task.Delay(intervalMs, ct).ConfigureAwait(false); }
                    catch { break; }
                }
            }, ct);
        }
    }

    /// <summary>
    /// Recompose la liste affichée à partir du scan local + du dernier manifest distant.
    /// La version « courante » (FeaturedVersion) est la plus haute installée ; à défaut,
    /// la plus haute disponible côté serveur. Toutes les autres vont dans OtherVersions.
    /// </summary>
    private void RebuildList()
    {
        // Si un DL/verify est en cours, on capture la row active pour transférer
        // son état (Downloading/Verifying + progression) sur la nouvelle instance.
        // Sans ça, l'UI repasse sur le nouveau row qui est en AvailableIdle avec
        // ResumableBytes lu depuis state.json → boutons "Reprendre" + "Annuler"
        // affichés alors que le DL continue derrière (footer qui avance).
        var oldActive = _activeRow;
        var preserveActive = oldActive is not null
            && oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;

        var installed = _registry.Scan().ToDictionary(v => v.Version);

        // Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
        // les versions taggées isBeta=true. Les installations locales déjà
        // présentes ne sont pas filtrées (un client qui avait installé une
        // bêta puis perd l'accès continue de la voir et de pouvoir la lancer
        // — on n'efface pas son disque).
        var canSeeBetas = _license?.CanSeeBetas ?? false;
        var rawRemote = _lastManifest?.Versions ?? new List<VersionManifest>();
        var remote = canSeeBetas
            ? rawRemote
            : rawRemote.Where(v => !v.IsBeta).ToList();
        var remoteByVer = remote.ToDictionary(v => v.Version);

        var allVersions = installed.Keys.Union(remoteByVer.Keys)
            .OrderByDescending(v => SemVer.Parse(v))
            .ToList();

        var rows = new List<VersionRowViewModel>();
        foreach (var ver in allVersions)
        {
            VersionRowViewModel row;
            if (installed.TryGetValue(ver, out var inst))
            {
                row = VersionRowViewModel.ForInstalled(inst, remoteByVer.GetValueOrDefault(ver));
            }
            else
            {
                row = VersionRowViewModel.ForRemote(remoteByVer[ver]);
            }
            WireRowHandlers(row);
            rows.Add(row);
        }

        // Marque les rows distantes qui ont un DL en pause (partial + state.json présents)
        // et applique le filtre license
        foreach (var r in rows.Where(r => r.IsRemoteOnly))
        {
            var st = _downloadManager.GetResumableState(r.Version);
            if (st is not null) r.ResumableBytes = st.DownloadedBytes;

            // License : la version est-elle téléchargeable selon notre entitlement ?
            r.LicenseAllowsDownload = r.Remote is not null
                && _licenseService.CanDownloadVersion(_license, r.Remote);
        }

        // Si un DL était en vol, propage son état sur la nouvelle instance avant
        // que les bindings UI ne s'attachent : State Downloading/Verifying +
        // progression copiés, ResumableBytes forcé à 0 pour cacher les boutons
        // "Reprendre" / "Annuler" tant que le DL tourne. _activeRow est repointé
        // sur la nouvelle instance pour que les progress callbacks (résolus à
        // l'exécution via _activeRow, pas via une capture closure) ciblent le
        // bon objet et que la barre de progress de la row avance live.
        if (preserveActive && oldActive is not null)
        {
            var matching = rows.FirstOrDefault(r => r.Version == oldActive.Version);
            if (matching is not null)
            {
                matching.State = oldActive.State;
                matching.ProgressPercent = oldActive.ProgressPercent;
                matching.ProgressDetail = oldActive.ProgressDetail;
                matching.ResumableBytes = 0;
                _activeRow = matching;
            }
        }

        // Featured : plus haute installée, sinon plus haute distante
        var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
        FeaturedVersion = featured;

        OtherVersions.Clear();
        foreach (var r in rows)
        {
            if (!ReferenceEquals(r, featured))
                OtherVersions.Add(r);
        }

        OnPropertyChanged(nameof(EmptyHint));
        OnPropertyChanged(nameof(EmptyHintVisibility));
        OnPropertyChanged(nameof(HasFeaturedVersion));
        OnPropertyChanged(nameof(HasOtherVersions));
    }

    private void WireRowHandlers(VersionRowViewModel row)
    {
        row.LaunchHandler = LaunchVersion;
        // Wrap InstallVersionAsync via SafeInstallAsync : sans ça, une exception
        // levée AVANT le premier await (ex. ThemedMessageBox.Show dans le check
        // "IsBusy") tomberait dans le vide (handler sync void) et la UI resterait
        // figée sans message — exactement le symptôme "le 2e PC ne démarre pas".
        row.InstallHandler = r => _activeInstallTask = SafeInstallAsync(r);
        row.UninstallHandler = r => _ = UninstallVersionAsync(r);
        row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
        row.OpenFolderHandler = OpenVersionFolder;
        row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
        row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r);
        row.ToggleAutoModeHandler = ToggleAutoModeForRow;

        // Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
        row.ShowAutoButton = _config.AutoMode.FeatureEnabled && row.IsInstalled;
        row.IsAutoModeSelected = string.Equals(
            _config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
    }

    /// <summary>
    /// Click sur le bouton AUTO d'une row. Toggle l'état sur cette row uniquement,
    /// désélectionne toutes les autres (unicité), persiste la config. Si on toggle
    /// OFF la version actuellement sélectionnée, SelectedVersion devient null
    /// (= mode auto inactif mais feature toujours activée pour permettre une nouvelle
    /// sélection plus tard).
    /// </summary>
    private void ToggleAutoModeForRow(VersionRowViewModel row)
    {
        // Trace fichier (en plus du Serilog principal) pour pouvoir diagnostiquer le
        // path d'invocation depuis le bouton AUTO sans dépendre du logger DI.
        try
        {
            var dir = System.IO.Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                "PSLauncher", "logs");
            System.IO.Directory.CreateDirectory(dir);
            System.IO.File.AppendAllText(
                System.IO.Path.Combine(dir, "auto-mode.log"),
                $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z  ToggleAutoModeForRow v{row.Version} (FeatureEnabled={_config.AutoMode.FeatureEnabled}, IsInstalled={row.IsInstalled}, current SelectedVersion={_config.AutoMode.SelectedVersion}){Environment.NewLine}");
        }
        catch { }

        if (!_config.AutoMode.FeatureEnabled)
        {
            _logger.LogInformation("Auto-mode toggle ignored : FeatureEnabled=false");
            return;
        }
        if (!row.IsInstalled)
        {
            _logger.LogInformation("Auto-mode toggle ignored : v{Version} pas installée", row.Version);
            return;
        }

        var newSelected = !row.IsAutoModeSelected;

        // Désélectionne toutes les autres
        if (FeaturedVersion is not null)
            FeaturedVersion.IsAutoModeSelected = false;
        foreach (var r in OtherVersions)
            r.IsAutoModeSelected = false;

        // Sélectionne celle-ci (ou laisse désélectionnée si on a toggle OFF)
        row.IsAutoModeSelected = newSelected;
        _config.AutoMode.SelectedVersion = newSelected ? row.Version : null;
        _configStore.Save(_config);
        _logger.LogInformation("Auto-mode selection: {Version}",
            newSelected ? row.Version : "(none)");

        // Si on vient d'activer le mode auto sur cette version, déclenche immédiatement
        // le countdown + lancement. Différé via Dispatcher.BeginInvoke pour que le
        // bouton ait le temps de repeindre en orange AVANT que la modal apparaisse.
        if (newSelected)
        {
            Application.Current.Dispatcher.BeginInvoke(
                new Action(() => StartAutoLaunchCountdown(row)),
                System.Windows.Threading.DispatcherPriority.Background);
        }
    }

    /// <summary>
    /// Error boundary autour de <see cref="InstallVersionAsync"/>. Garantit qu'aucune
    /// exception ne disparaît silencieusement (cas du fire-and-forget pré-0.24.6 où
    /// le launcher pouvait rester figé sur un click sans aucun feedback).
    /// </summary>
    private async Task SafeInstallAsync(VersionRowViewModel row)
    {
        try
        {
            await InstallVersionAsync(row);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "InstallVersionAsync unhandled exception (row={Version})", row.Version);
            StatusMessage = Strings.StatusError(ex.Message);
            try
            {
                ThemedMessageBox.Show(
                    Strings.MsgInstallFailed(ex.Message),
                    Strings.MsgBoxError,
                    MessageBoxButton.OK, MessageBoxImage.Error);
            }
            catch { /* dialog peut échouer si on est en train de quitter */ }
        }
    }

    [RelayCommand(CanExecute = nameof(CanCheckUpdates))]
    private async Task CheckForUpdatesAsync()
    {
        IsBusy = true;
        StatusMessage = Strings.StatusCheckingUpdates;
        try
        {
            // Re-validation de la license en parallèle du fetch manifest. Permet
            // au client de refléter sans délai les changements admin (modification
            // de date, révocation, etc.) sans attendre l'expiration du cache offline
            // de 100 jours. Si l'utilisateur est offline ou si l'API ne répond pas,
            // on garde le cache silencieusement (pas de crash).
            await RefreshLicenseFromServerAsync(CancellationToken.None);

            var result = await _updateChecker.CheckAsync(CancellationToken.None);
            if (result.Error is not null)
            {
                StatusMessage = Strings.StatusError(result.Error);
                return;
            }

            _lastManifest = result.Manifest;
            RebuildList();

            // Détection MAJ du launcher lui-même (présent en `manifest.launcher`).
            // Lancée en arrière-plan pour ne pas bloquer l'affichage des résultats produit.
            if (result.Manifest?.Launcher is { } launcher && _selfUpdater.IsNewerThanCurrent(launcher))
            {
                _ = Application.Current.Dispatcher.BeginInvoke(() => PromptLauncherUpdate(launcher));
            }

            // Suffixe d'origine du manifest pour que l'utilisateur sache si la
            // liste des versions vient d'OVH (canonique, à jour) ou d'un peer/cache
            // local (potentiellement plus ancien).
            var sourceSuffix = _manifestService.LastSource switch
            {
                ManifestSource.Ovh => "  ·  " + Strings.ManifestSourceOvh,
                ManifestSource.Peer => "  ·  " + Strings.ManifestSourcePeer,
                ManifestSource.DiskCache => "  ·  " + Strings.ManifestSourceDiskCache,
                _ => string.Empty,
            };

            if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
                StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version) + sourceSuffix;
            else if (result.LatestRemote is not null)
                StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version) + sourceSuffix;
            else
                StatusMessage = Strings.StatusNoRemote + sourceSuffix;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Update check failed");
            StatusMessage = Strings.StatusError(ex.Message);
        }
        finally
        {
            IsBusy = false;
        }
    }
    private bool CanCheckUpdates() => !IsBusy;

    /// <summary>
    /// Tente une re-validation online de la license. Met à jour le cache local
    /// avec les valeurs fraîches du serveur (date d'expiration, statut révoqué, etc.).
    /// Silencieux en cas d'échec réseau pour ne pas casser le check des MAJ ; le
    /// cache offline reste utilisable.
    /// </summary>
    private async Task RefreshLicenseFromServerAsync(CancellationToken ct)
    {
        var key = _licenseService.GetDecryptedKey();
        if (string.IsNullOrEmpty(key)) return;
        try
        {
            var resp = await _licenseService.ValidateAsync(key, ct);
            _licenseService.SaveCached(key, resp);
            _license = _licenseService.GetCached();
            NotifyLicenseChanged();
            _logger.LogInformation("License revalidated from server (status={Status}, until={Until})",
                resp.Status, resp.DownloadEntitlementUntil);
        }
        catch (Exception ex)
        {
            _logger.LogInformation(ex, "License revalidation failed (offline or server error). Keeping cached state.");
        }
    }

    private async void PromptLauncherUpdate(LauncherInfo launcher)
    {
        try
        {
            var dialog = new Views.LauncherUpdateDialog(launcher, _selfUpdater.GetCurrentVersion())
            {
                Owner = Application.Current.MainWindow
            };
            dialog.ShowDialog();
            if (!dialog.UpdateRequested) return;

            IsBusy = true;
            StatusMessage = Strings.StatusDownloadingLauncher;
            var progress = new Progress<DownloadProgress>(p =>
            {
                if (p.TotalBytes > 0) ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
                ProgressDetail = Strings.ProgressLauncherDl(launcher.Version, FormatSize(p.BytesDownloaded), FormatSize(p.TotalBytes));
            });

            await _selfUpdater.DownloadAndApplyAsync(launcher, progress, CancellationToken.None);
            StatusMessage = Strings.StatusLauncherUpdating;
            await Task.Delay(800); // laisse le temps au toast / message de s'afficher
            Application.Current.Shutdown();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Self-update failed");
            ThemedMessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
                Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
            StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
            IsBusy = false;
        }
    }

    [RelayCommand]
    private void OpenSettings()
    {
        var vm = _serviceProvider.GetRequiredService<SettingsViewModel>();
        var dialog = new Views.SettingsDialog(vm) { Owner = Application.Current.MainWindow };
        if (dialog.ShowDialog() == true)
        {
            // Au retour, l'URL serveur ou l'installRoot ont pu changer → refresh
            _license = _licenseService.GetCached();
            NotifyLicenseChanged();
            RebuildList();

            // Hot-reload du bandeau de santé : la liste des checks (et leurs intervals)
            // a pu être éditée. On reconstruit les indicateurs et on relance la boucle
            // per-check sans avoir à fermer/rouvrir le launcher.
            InitHealthIndicators();
            StartHealthLoop();
            OnPropertyChanged(nameof(ReportUri));
            OnPropertyChanged(nameof(DocumentationUri));
            OnPropertyChanged(nameof(HasDocumentationUrl));
        }
    }

    /// <summary>
    /// Clic sur le badge license dans la top bar.
    /// - License active (valid/expired/revoked) → ouvre LicenseDetailsDialog (détails + désactiver)
    /// - Sinon → ouvre OnboardingDialog (saisie de clé)
    /// Si l'utilisateur désactive depuis les détails, on enchaîne sur l'onboarding pour
    /// faciliter le re-keying éventuel — sinon on revient simplement à l'état "Aucune license".
    /// </summary>
    [RelayCommand]
    private async Task OpenLicenseAsync()
    {
        var current = _licenseService.GetCached();

        if (current is not null)
        {
            var details = new Views.LicenseDetailsDialog(_licenseService, current)
            {
                Owner = Application.Current.MainWindow
            };
            details.ShowDialog();
            if (!details.DeactivateRequested)
            {
                // Pas de changement
                await Task.CompletedTask;
                return;
            }
            // Désactivation : on a déjà vidé le cache, on rafraîchit la UI puis on reboucle
            _license = null;
            NotifyLicenseChanged();
            RebuildList();
        }

        // Aucune license → propose l'onboarding
        var onboard = new Views.OnboardingDialog(_licenseService) { Owner = Application.Current.MainWindow };
        onboard.ShowDialog();
        if (onboard.LicenseActivated)
        {
            _license = _licenseService.GetCached();
            NotifyLicenseChanged();
            RebuildList();
        }
        await Task.CompletedTask;
    }

    [RelayCommand]
    private void OpenInstallRoot()
    {
        if (!Directory.Exists(_config.InstallRoot)) return;
        Process.Start(new ProcessStartInfo
        {
            FileName = _config.InstallRoot,
            UseShellExecute = true,
            Verb = "open"
        });
    }

    [RelayCommand]
    private void CancelDownload()
    {
        if (_activeDownloadCts is null || _activeRow is null) return;
        var confirm = ThemedMessageBox.Show(
            Strings.MsgCancelDownloadConfirm(_activeRow.Version),
            Strings.MsgBoxCancelDl,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;
        _activeDownloadCts.Cancel();
    }

    /// <summary>
    /// Discard total : annule + supprime le partial + state.json.
    /// Permet de repartir de zéro depuis le bouton « ↻ Reprendre » contextuel.
    /// </summary>
    private async Task RestartFromZeroAsync(VersionRowViewModel row)
    {
        // Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
        var st = _downloadManager.GetResumableState(row.Version);
        var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
        var confirm = ThemedMessageBox.Show(
            Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
            Strings.MsgBoxRestartDl,
            MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        // Cancel le DL actif si c'est cette row, puis attend la propagation complète
        // (les 16 segments doivent chacun stopper leurs écritures et fermer leur
        // FileStream sinon DiscardResumableState échouera/sera réécrasé).
        if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
        {
            _activeDownloadCts.Cancel();
            // Attend que InstallVersionAsync termine son catch OperationCanceledException
            // — y compris le flush + state save + libération des FileStream segment.
            // Timeout de sécurité 10s au cas où une connexion HTTPS bloque sur close.
            if (_activeInstallTask is { } running && !running.IsCompleted)
            {
                StatusMessage = "Annulation en cours…";
                try
                {
                    var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
                    if (!ReferenceEquals(awaitTask, running))
                        _logger.LogWarning("Install task did not finish cancelling within 10s, proceeding anyway");
                }
                catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during restart"); }
            }
        }

        _downloadManager.DiscardResumableState(row.Version);
        row.ResumableBytes = 0;
        row.State = VersionRowState.AvailableIdle;
        row.ProgressDetail = null;
        row.ProgressPercent = 0;
        StatusMessage = null;
        ProgressDetail = null;
        ProgressPercent = 0;
    }

    /// <summary>
    /// Purge TOUT pour cette version (state.json + .partial + .zip cache + sidecar
    /// SHA), afin de forcer un re-DL complet au prochain Install. Utile quand le
    /// SHA-256 a changé côté serveur et que la source précédente (OVH CDN, peer
    /// LAN) était obsolète — le cache local du launcher pourrait perpétuer le
    /// problème en servant aux peers du LAN un ZIP avec sidecar incohérent.
    ///
    /// Cas d'usage typique : opérateur backoffice qui a rebuild un release (ex.
    /// v1.5.4) avec nouveau contenu + nouveau SHA, et un PC client qui n'arrive
    /// pas à re-DL parce que sa source est en cache stale.
    /// </summary>
    private async Task ForceFreshDownloadAsync(VersionRowViewModel row)
    {
        var confirm = ThemedMessageBox.Show(
            Strings.MsgForceFreshConfirm(row.Version),
            Strings.MsgBoxForceFresh,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        // Si un DL est actif sur cette même row, on le cancel proprement avant
        // de purger (même pattern que RestartFromZeroAsync).
        if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
        {
            _activeDownloadCts.Cancel();
            if (_activeInstallTask is { } running && !running.IsCompleted)
            {
                StatusMessage = "Annulation en cours…";
                try
                {
                    var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
                    if (!ReferenceEquals(awaitTask, running))
                        _logger.LogWarning("Install task did not finish cancelling within 10s, proceeding with purge anyway");
                }
                catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during force-fresh"); }
            }
        }

        // 1. Purge state.json + .partial (ces deux sont DownloadStateStore-only)
        _downloadManager.DiscardResumableState(row.Version);

        // 2. Purge le cache LAN (.zip + .sha256 sidecar). Best-effort : si un
        // autre process garde un handle (rare : LanCacheServer en cours de
        // serve), on log warning et on continue.
        try
        {
            await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Could not fully invalidate cache for v{Version} during force-fresh", row.Version);
        }

        // 3. Reset UI state pour que la row reprenne "Installer" propre.
        row.ResumableBytes = 0;
        row.State = VersionRowState.AvailableIdle;
        row.ProgressDetail = null;
        row.ProgressPercent = 0;
        StatusMessage = null;
        ProgressDetail = null;
        ProgressPercent = 0;
        _logger.LogInformation("Force-fresh download purge completed for v{Version}", row.Version);
    }

    // ------------ Per-row actions ------------

    private void LaunchVersion(VersionRowViewModel row)
    {
        if (row.Installed is null) return;

        // ---- Garde-fou anti double-lancement ----
        // Deux niveaux de vérif pour couvrir tous les cas :
        //   1. _runningProserve : process LANCÉ PAR NOUS encore vivant ⇒ silently
        //      refuse (cas typique : exit event glitché, ou double-clic du bouton
        //      LANCER, ou auto-mode qui re-déclenche pendant qu'on tourne déjà).
        //   2. _processLauncher.IsRunning(row.Installed) : scan système pour
        //      détecter une instance lancée HORS launcher (ex. opérateur a
        //      double-cliqué le .exe dans l'explorateur).
        // En cas de match, on log + popup. La popup n'apparaît PAS pour les
        // déclenchements auto-mode (silently skip) pour ne pas spammer.
        if (IsAnyProserveAlreadyRunning(row.Installed, out var detail))
        {
            _logger.LogWarning(
                "Refusing to launch PROSERVE v{Version} : an instance is already running ({Detail})",
                row.Version, detail);
            // Distinction manuel vs auto-mode : pour un clic LANCER manuel,
            // l'opérateur doit savoir pourquoi rien ne se passe ⇒ popup.
            // Pour un auto-trigger (countdown finish, exit retrigger…), silently
            // skip suffit — pas d'interaction utilisateur en cours.
            if (!_isAutoLaunchInFlight)
            {
                ThemedMessageBox.Show(
                    Strings.MsgAlreadyRunningBody,
                    Strings.MsgAlreadyRunningTitle,
                    MessageBoxButton.OK, MessageBoxImage.Warning);
            }
            return;
        }

        try
        {
            // Si on lance la version désignée comme "auto", on passe les CLI args
            // configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100).
            // Pour les lancements manuels d'autres versions, args = null (comportement standard).
            var isAutoVersion = _config.AutoMode.FeatureEnabled
                                && !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
                                && string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
            string[]? cliArgs = isAutoVersion ? BuildAutoModeCliArgs(_config.AutoMode.Args) : null;

            var proc = _processLauncher.Launch(row.Installed, cliArgs);
            _runningProserve = proc;
            _config.LastLaunchedVersion = row.Version;
            _configStore.Save(_config);

            // Réduit la fenêtre du launcher pour ne pas gêner Proserve qui démarre
            if (Application.Current.MainWindow is { } mw)
                mw.WindowState = WindowState.Minimized;

            // Quand Proserve se termine, on remet la fenêtre du launcher au premier plan.
            // EnableRaisingEvents est requis pour recevoir l'event Exited.
            try
            {
                proc.EnableRaisingEvents = true;
                proc.Exited += (_, _) =>
                {
                    _logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
                    Application.Current?.Dispatcher.Invoke(() =>
                    {
                        // Libère le slot AVANT de potentiellement relancer en mode auto
                        // (sinon le garde-fou ci-dessus refuserait le nouveau launch).
                        // Compare la référence pour éviter de nuller un process plus récent
                        // dans un scénario rare où le user a relancé manuellement entre temps.
                        if (ReferenceEquals(_runningProserve, proc))
                            _runningProserve = null;

                        if (Application.Current.MainWindow is { } main)
                        {
                            if (main.WindowState == WindowState.Minimized)
                                main.WindowState = WindowState.Normal;
                            main.Activate();
                            // Pop temporaire pour amener la fenêtre au premier plan
                            main.Topmost = true;
                            main.Topmost = false;
                        }
                        // Si la version qui vient de quitter est la version auto, on
                        // déclenche le countdown de relance. L'opérateur peut annuler
                        // (désactive le mode auto sur cette session) ou laisser le timer
                        // expirer pour relancer.
                        if (isAutoVersion)
                            StartAutoLaunchCountdown(row);
                    });
                };
            }
            catch (Exception ex)
            {
                // ShellExecute peut renvoyer un Process sans accès aux events — non bloquant.
                // Conséquence : _runningProserve ne sera jamais nullé via Exited et resterait
                // bloquant. On clear immédiatement pour ne pas verrouiller les futurs launches
                // (au pire on perd la protection sur cette instance, mais IsRunning() scanne
                // le système et catchera quand même les doublons).
                _logger.LogDebug(ex, "Could not hook Exited on launched process");
                _runningProserve = null;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Launch failed");
            _runningProserve = null;
            ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
                Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
        }
    }

    /// <summary>
    /// True quand un lancement auto-mode est en cours de déclenchement (countdown
    /// en cours OU appel <see cref="LaunchVersion"/> issu du dialog). Sert à
    /// rendre le garde-fou silencieux pour les triggers auto (sinon une popup
    /// "déjà lancé" apparaîtrait à chaque tick d'un exit-event glitché en mode
    /// auto). Toggled par <see cref="StartAutoLaunchCountdown"/>.
    /// </summary>
    private bool _isAutoLaunchInFlight;

    /// <summary>
    /// True si une instance PROSERVE tourne déjà — soit lancée par nous
    /// (<see cref="_runningProserve"/> encore vivant), soit lancée hors launcher
    /// et détectée via <see cref="IProcessLauncher.IsRunning"/>. Le paramètre
    /// <paramref name="detail"/> est rempli avec le motif de blocage pour les logs.
    /// </summary>
    private bool IsAnyProserveAlreadyRunning(InstalledVersion target, out string detail)
    {
        // 1) Notre process tracking
        if (_runningProserve is { } p)
        {
            try
            {
                if (!p.HasExited)
                {
                    detail = $"_runningProserve PID={p.Id} still alive";
                    return true;
                }
                // Process a terminé mais Exited n'a pas (encore) fired ⇒ on
                // libère le slot et continue à vérifier le système.
                _runningProserve = null;
            }
            catch (InvalidOperationException)
            {
                // Process déjà disposé → considère terminé
                _runningProserve = null;
            }
            catch (Exception ex)
            {
                // Sandboxing ou perte d'accès au process : on ne peut pas savoir.
                // Mieux vaut être permissif (laisser passer) que bloquer à tort.
                _logger.LogDebug(ex, "Could not poll _runningProserve.HasExited, treating as exited");
                _runningProserve = null;
            }
        }

        // 2) Scan système pour les instances externes (ou nôtres dont on a perdu
        // le handle, ex. après un crash launcher partiel).
        try
        {
            if (_processLauncher.IsRunning(target))
            {
                detail = $"system scan detected running {Path.GetFileName(target.ExecutablePath)}";
                return true;
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "IsRunning() scan failed, treating as not running");
        }

        detail = string.Empty;
        return false;
    }

    /// <summary>
    /// Convertit la liste <see cref="AutoModeConfig.Args"/> en string[] format CLI
    /// compatible Unreal Engine (FParse::Param et FParse::Value).
    ///
    /// L'opérateur saisit la liste de paires {Key, Value} sans avoir à se soucier
    /// de la syntaxe : il tape <c>autoconnect</c> (sans tiret) et <c>login</c> /
    /// <c>Jerome</c>. Le launcher se charge d'ajouter les <c>-</c>, <c>=</c> et
    /// le quotage des valeurs avec espaces.
    ///
    /// Règles de formatage produites :
    ///   • Value vide       → <c>-{key}</c>            (flag : FParse::Param)
    ///   • Value sans espace → <c>-{key}={value}</c>    (FParse::Value classique)
    ///   • Value avec espace → <c>-{key}="value spaced"</c> (quotage Win32)
    ///
    /// Défensif : on strip un éventuel <c>-</c> en tête de Key et un <c>=</c> en
    /// tête de Value au cas où l'opérateur les a saisis dans l'UI ("au cas où" =
    /// retour utilisateur : certains tapent <c>-login</c> ou <c>=Jerome</c> par
    /// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
    /// Les clés vides sont skippées silencieusement.
    /// </summary>
    /// <summary>
    /// Construit la liste finale des processus à terminer avant le merge SteamVR.
    /// Source principale : les health checks de type "Process" (= les programmes
    /// que l'opérateur surveille sur le bandeau de santé = ceux qu'il sait
    /// présents sur sa machine VR). On y ajoute systématiquement les processus
    /// "guardian" (VIVE Business Streaming, RR*, etc.) qui relancent SteamVR
    /// même si l'opérateur ne les a pas dans ses health checks, puis on ordonne
    /// pour que les guardians soient tués EN PREMIER.
    /// </summary>
    private List<string> BuildSteamVrProcessKillList()
    {
        // 1) Processus configurés dans les health checks (case-insensitive).
        var fromHealth = _config.HealthChecks.Checks
            .Where(c => string.Equals(c.Kind, "Process", StringComparison.OrdinalIgnoreCase))
            .Select(c => (c.Target ?? string.Empty).Trim())
            .Where(t => !string.IsNullOrEmpty(t))
            .Select(StripExeSuffix);

        // 2) Fallback / défense : la liste config (qui contient VBS + SteamVR
        // canoniques par défaut). Si l'opérateur n'a rien configuré en health
        // checks, on aura quand même de quoi tuer ce qui faut.
        var fromConfig = _config.SteamVr.ProcessesToKill
            .Select(t => (t ?? string.Empty).Trim())
            .Where(t => !string.IsNullOrEmpty(t))
            .Select(StripExeSuffix);

        // 3) Dédup case-insensitive en préservant l'ORDRE D'INSERTION : on
        // insère d'abord les "guardians" connus (VBS, RR*, Htc*), puis les
        // health checks, puis le reste de la config. Ainsi VBS est garanti
        // tué en premier (sinon il relance SteamVR avant qu'on touche au fichier).
        var guardians = new[]
        {
            "VIVE Business Streaming",
            "RRConsole",
            "RRServer",
            "HtcConnectionUtility",
        };

        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var result = new List<string>();
        foreach (var name in guardians.Concat(fromHealth).Concat(fromConfig))
        {
            if (seen.Add(name)) result.Add(name);
        }
        return result;
    }

    private static string StripExeSuffix(string name)
    {
        if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
            return name[..^4].Trim();
        return name;
    }

    private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
    {
        var list = new List<string>();
        foreach (var a in args)
        {
            // Sanitize Key : trim whitespace + leading dashes éventuels.
            var key = (a.Key ?? string.Empty).Trim().TrimStart('-').Trim();
            if (string.IsNullOrEmpty(key)) continue;

            // Sanitize Value : trim whitespace + un éventuel `=` en tête.
            var val = (a.Value ?? string.Empty).Trim().TrimStart('=').Trim();

            if (string.IsNullOrEmpty(val))
            {
                list.Add($"-{key}");
            }
            else if (val.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0)
            {
                // Quote + escape les " internes (rare mais possible pour un mdp).
                var escaped = val.Replace("\"", "\\\"");
                list.Add($"-{key}=\"{escaped}\"");
            }
            else
            {
                list.Add($"-{key}={val}");
            }
        }
        return list.ToArray();
    }

    /// <summary>
    /// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
    ///  1. Clic sur bouton AUTO → désigne la version + lance après countdown
    ///  2. Startup du launcher → si une version était désignée, idem countdown + launch
    ///  3. Exit de PROSERVE en mode auto → countdown + relance
    ///
    /// Comportement :
    ///  - Affiche un popup modal avec compte-à-rebours (3-5 s configurables)
    ///  - Countdown expire → <see cref="LaunchVersion"/> avec args CLI mode auto
    ///  - Annuler → désactive le mode auto (<c>SelectedVersion=null</c>, bouton AUTO
    ///    repasse en gris sur la row CURRENT, persisté dans config.json)
    /// </summary>
    private void StartAutoLaunchCountdown(VersionRowViewModel row)
    {
        if (row.Installed is null) return;

        // Garde-fou tôt : si PROSERVE est déjà lancé, ne pas afficher de countdown.
        // Sinon le countdown défile → LaunchVersion → refus silencieux → confusing.
        // On log simplement et on désactive le mode auto pour cette session pour
        // que l'opérateur reprenne explicitement la main (sinon un exit-event
        // glitché pourrait re-trigger ce path en boucle).
        if (IsAnyProserveAlreadyRunning(row.Installed, out var why))
        {
            _logger.LogInformation(
                "Auto-launch countdown skipped for v{Version} : already running ({Why})",
                row.Version, why);
            return;
        }

        var grace = Math.Clamp(_config.AutoMode.GracePeriodSeconds, 1, 60);

        // Callback santé : non-null SEULEMENT si l'option WaitForHealthChecks
        // est activée ET qu'il y a au moins un indicateur configuré. Sinon le
        // dialog saute la phase santé et démarre direct le countdown.
        Func<IReadOnlyList<string>>? healthSnapshot = null;
        if (_config.AutoMode.WaitForHealthChecks && HealthIndicators.Count > 0)
        {
            healthSnapshot = () => HealthIndicators
                .Where(h => h.Severity != Core.Health.HealthSeverity.Ok)
                .Select(h => h.Name)
                .ToList();
        }

        _isAutoLaunchInFlight = true;
        try
        {
            var dialog = new Views.AutoRelaunchDialog(row.Version, grace, healthSnapshot)
            {
                Owner = Application.Current.MainWindow,
            };
            var result = dialog.ShowDialog();
            if (result == true)
            {
                _logger.LogInformation("Auto-launching PROSERVE v{Version}", row.Version);
                LaunchVersion(row);
            }
            else
            {
                // Cancel : désactive le mode auto sur cette version. L'opérateur peut
                // reprendre la main et re-cliquer plus tard pour réactiver.
                _logger.LogInformation("Auto-launch cancelled by user for v{Version}", row.Version);
                _config.AutoMode.SelectedVersion = null;
                _configStore.Save(_config);

                // Trouve la row CURRENT (RebuildList a pu remplacer l'instance entre temps
                // — auquel cas `row` est orpheline et le bouton de la UI ne se mettrait
                // pas à jour). Pour le visuel, on désélectionne TOUTES les rows par sûreté.
                if (FeaturedVersion is not null)
                    FeaturedVersion.IsAutoModeSelected = false;
                foreach (var r in OtherVersions)
                    r.IsAutoModeSelected = false;
            }
        }
        finally
        {
            _isAutoLaunchInFlight = false;
        }
    }

    /// <summary>
    /// Au démarrage du launcher, si le mode auto est configuré avec une version
    /// sélectionnée ET que cette version est bien installée, on déclenche le
    /// countdown + lancement (avec possibilité d'annuler via Cancel sur la modal).
    /// Appelé depuis le constructeur après RebuildList().
    /// </summary>
    private void TryAutoLaunchAtStartup()
    {
        if (!_config.AutoMode.FeatureEnabled) return;
        var target = _config.AutoMode.SelectedVersion;
        if (string.IsNullOrEmpty(target)) return;

        var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
                  ?? OtherVersions.FirstOrDefault(r => r.Version == target);
        if (row is null || !row.IsInstalled)
        {
            _logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
            return;
        }

        _logger.LogInformation("Auto-launching v{Version} at startup (with countdown)", target);
        StartAutoLaunchCountdown(row);
    }

    private async Task InstallVersionAsync(VersionRowViewModel row)
    {
        if (row.Remote is null) return;
        if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }

        IsBusy = true;
        IsDownloadActive = true;
        _activeRow = row;
        _activeDownloadCts = new CancellationTokenSource();
        var ct = _activeDownloadCts.Token;

        // Note : on NE reset PAS _install404RetryConsumed ici. Le retry après
        // 404 réinvoke SafeInstallAsync(freshRow) qui re-entre dans cette
        // méthode — si on resetait, on aurait potentiellement une boucle
        // infinie 404→retry→404→retry. Le flag est seulement remis à false
        // dans le `finally` du HAUT (= install qui n'est PAS un retry) ou
        // après un install réussi.

        try
        {
            // Si reprise d'un DL interrompu, on saute la popup de release notes :
            // l'utilisateur a déjà confirmé sa décision la première fois.
            var isResume = row.HasResumableDownload;

            if (!isResume)
            {
                // 1) Récupère release notes (best effort).
                // Skip si on a fetché le manifest depuis un peer ou cache (= probablement
                // offline) : l'URL release notes pointe sur OVH et le fetch va timeout
                // bêtement. Mieux vaut ouvrir le dialog tout de suite avec "indisponible".
                string notes = Strings.ReleaseNotesNone;
                var manifestFromOvh = _manifestService.LastSource == ManifestSource.Ovh;
                if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl) && manifestFromOvh)
                {
                    try
                    {
                        notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
                    }
                    catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = Strings.ReleaseNotesUnavailable; }
                }
                else if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
                {
                    _logger.LogInformation("Skipping release notes fetch (manifest source = {Source}, OVH probably unreachable)", _manifestService.LastSource);
                    notes = Strings.ReleaseNotesUnavailable;
                }

                // 2) Dialog de confirmation avec release notes
                var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow };
                dialog.ShowDialog();
                if (!dialog.DownloadRequested) return;
            }

            // 3) Download — résolution d'URL en deux temps :
            //    A) Cache LAN : probe les peers (auto-découverts + manuels) ; si un
            //       d'eux a la version (taille + SHA-256 manifest match), DL depuis lui.
            //       Économise la bande passante OVH dans les setups multi-PCs.
            //    B) Sinon, fallback sur l'URL HMAC-signée serveur, puis sur l'URL
            //       publique du manifest si le endpoint /download-url n'est pas configuré.
            row.State = VersionRowState.Downloading;
            var peerSrc = await _peerResolver.TryResolveAsync(
                row.Version, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256, ct);
            Uri url;
            if (peerSrc is not null)
            {
                url = peerSrc.ZipUrl;
                _currentPeerHost = peerSrc.PeerHost;
                StatusMessage = Strings.StatusDownloadingFromPeer(peerSrc.PeerHost, row.Version);
            }
            else
            {
                _currentPeerHost = null;
                var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
                var urlString = signed ?? row.Remote.Download.Url;
                url = new Uri(urlString);

                // ★ Détection serveur-side bug : si l'URL signée pointe vers un
                // nom de fichier DIFFÉRENT de celui du manifest, on a un endpoint
                // /download-url cassé (construit l'URL depuis un template hardcodé
                // au lieu de lire le manifest). Continuer le DL est garanti de
                // donner 404 → on remonte une erreur claire AVANT de DL 14 Go.
                // Bug rapporté côté Asterion : DownloadUrl.php avant fix utilisait
                // `proserve-{version}.zip` au lieu de basename(manifest.download.url).
                if (signed is not null)
                {
                    var signedFilename = Path.GetFileName(url.AbsolutePath);
                    var manifestFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
                    if (!string.Equals(signedFilename, manifestFilename, StringComparison.Ordinal))
                    {
                        _logger.LogError(
                            "Server-side bug : /download-url returned filename « {Signed} » but manifest references « {Manifest} ». Aborting DL — fix DownloadUrl.php server-side.",
                            signedFilename, manifestFilename);
                        throw new InvalidOperationException(
                            $"Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. " +
                            $"Manifest : « {manifestFilename} ». Signé : « {signedFilename} ». " +
                            $"→ DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé.");
                    }
                }
            }
            // Diagnostic : log l'URL utilisée pour ce DL. Permet de comparer
            // avec l'URL après un éventuel refresh manifest pour détecter le
            // cas "le manifest serveur est aussi obsolète" (les deux URLs sont
            // identiques alors qu'on attendait un changement).
            _logger.LogInformation(
                "Install v{Version}: download URL resolved to {Url} (source: {Source})",
                row.Version, url, _currentPeerHost ?? "OVH");
            // Callback de refresh d'URL : appelé par DownloadManager quand un segment
            // reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
            // jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
            // (le TTL serveur HMAC est de 6 h, suffit pour la plupart des DL mais pas pour
            // les très lentes connexions ADSL).
            async Task<Uri?> RefreshUrlAsync(CancellationToken c)
            {
                if (peerSrc is not null) return peerSrc.ZipUrl;
                var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
                return fresh is null ? null : new Uri(fresh);
            }
            var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
            {
                HashAlgorithm = row.Remote.Download.HashAlgorithm,
                RefreshUrlAsync = RefreshUrlAsync,
            };
            // Status « Préparation… » immédiat : entre le clic et la première réponse
            // HEAD du serveur + pré-allocation du fichier 14 Go, il peut s'écouler
            // 5-30 s silencieuses. On rassure l'utilisateur tout de suite ; le message
            // sera remplacé automatiquement par « ⬇ v1.4.6 : … » dès le premier byte.
            StatusMessage = Strings.StatusPreparingDownload(row.Version);
            ProgressDetail = null;
            ProgressPercent = 0;
            row.ProgressDetail = null;
            row.ProgressPercent = 0;
            // Les progress callbacks ciblent _activeRow (résolution dynamique) plutôt que
            // de capturer le local `row` par closure — sinon, si un RebuildList intervient
            // en cours de DL (cf. OpenSettings/OpenLicense), l'instance row capturée serait
            // orpheline et la barre per-row n'avancerait plus côté UI.
            var dlProgress = new Progress<DownloadProgress>(p =>
            {
                if (_activeRow is { } target) UpdateDlProgress(target, p);
            });
            // Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
            // On bascule la row active sur Verifying à la première remontée, ce qui change
            // le badge de "⬇ Téléchargement…" vers "🔍 Vérification…" — les deux phases
            // sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
            var hashProgress = new Progress<double>(pct =>
            {
                var target = _activeRow;
                if (target is null) return;
                if (target.State == VersionRowState.Downloading)
                    target.State = VersionRowState.Verifying;
                var percent = (int)Math.Round(pct * 100.0);
                ProgressPercent = percent;
                target.ProgressPercent = percent;
                ProgressDetail = Strings.ProgressVerifying(target.Version, percent);
                target.ProgressDetail = ProgressDetail;
            });
            // NB: pas de StatusMessage = "Téléchargement…" ici — on garde « Préparation… »
            // visible pendant la phase HEAD/SetLength, puis le footer passe directement
            // au format progress « ⬇ v1.4.6 : X / 14 Go • Y Mo/s » dès le premier rapport.
            var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, hashProgress, ct);

            // Promote au cache LAN IMMÉDIATEMENT après le DL (avant l'extract local).
            // Pourquoi avant : sans ça, le sidecar SHA n'existe qu'après l'install complet
            // (extract + redist + deploy ~ 20-60 s), pendant lesquelles les autres PCs
            // qui probe /v1/has/{version} reçoivent 404 et tombent sur OVH inutilement.
            // En promotant ici, dès que notre DL OVH est fini, on devient seed LAN pour
            // les autres pendant que notre propre install local continue en parallèle.
            try
            {
                await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
                var cachedNow = _zipCacheStore.ListCached().Select(c => c.Version).ToList();
                _logger.LogInformation("LAN cache after DL (pre-install) : {Count} version(s) [{Versions}]",
                    cachedNow.Count, string.Join(", ", cachedNow));
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "ZIP cache promote failed (non-critical, LAN serving will lag until next install)");
            }

            // 4) Install
            row.State = VersionRowState.Installing;
            StatusMessage = Strings.StatusInstallingVersion(row.Version);
            var folderName = row.Remote.GetInstallFolderName();
            var target = Path.Combine(_config.InstallRoot, folderName);
            var installProgress = new Progress<InstallProgress>(ip =>
            {
                if (ip.BytesTotal <= 0) return;
                var pct = (double)ip.BytesDone / ip.BytesTotal * 100.0;
                ProgressPercent = pct;
                row.ProgressPercent = pct;
                ProgressDetail = Strings.ProgressExtraction(row.Version, ip.EntriesDone, ip.EntriesTotal, FormatSize(ip.BytesDone), FormatSize(ip.BytesTotal));
                row.ProgressDetail = ProgressDetail;
            });
            await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);

            // Persiste le nom du .exe à lancer dans le dossier d'install fraîchement
            // extrait. InstallationRegistry.Scan() le lira pour résoudre l'exe sans
            // avoir besoin du manifest en mémoire (cas démarrage offline / pré-check).
            // Le nom vient du manifest serveur (champ "executable" par version),
            // configurable dans le backoffice.
            try
            {
                if (!string.IsNullOrWhiteSpace(row.Remote.Executable))
                    await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, ct);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to write install metadata (will fall back to glob detection)");
            }

            // Installation des redists Unreal (VC_redist, UEPrereqSetup, etc.) si la
            // version contient un dossier `_redist/`. Run silent + UAC élévation par
            // installer. Track via metadata pour ne pas re-runner à chaque réinstall.
            // Si un installer fail (already-installed retournent souvent un exit code
            // non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
            // bloquée par un redist mineur. Wrappé en try/catch : si une exception
            // non gérée fuit (Process.Start qui throw bizarre, UAC denied catastrophique,
            // …), on ne doit SURTOUT PAS skip le PromoteAsync ci-dessous, sinon les
            // peers LAN ne verraient pas la version.
            try
            {
                await InstallRedistsAsync(target, row.Version, ct);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Redists install threw an unhandled exception (continuing to cache promote)");
            }

            // Prune les vieilles versions selon MaxCachedVersions (le Promote a déjà
            // eu lieu juste après le DL, voir bloc plus haut). On prune ici post-install
            // pour que les versions encore installées soient protégées (la version qu'on
            // vient d'installer compte comme "encore installée").
            try
            {
                await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
            }

            // Merge SteamVR settings : si _steamvr/steamvr.vrsettings est bundled
            // dans le ZIP, tue SteamVR + Vive Business Streaming (qui le relance
            // sinon) et fusionne les blocs racine dans le fichier de l'utilisateur.
            // Best-effort : si SteamVR n'est pas installé ou si le merge échoue,
            // on log mais on ne bloque pas l'install PROSERVE — le jeu peut tourner
            // sans, juste sans le mapping tracker custom.
            try
            {
                // ÉTAPE 1 — DRY-RUN : on commence par déterminer si un vrai
                // merge changerait QUOI QUE CE SOIT. Aucun processus tué à ce
                // stade, aucun fichier touché. Bénéfice : sur un PC où la conf
                // SteamVR est déjà conforme à la release (cas typique d'une
                // ré-install ou d'un PC mis-à-jour récemment), on saute TOUT
                // le processus de fermeture sans déranger l'opérateur.
                var check = await _steamVrDeployer.CheckMergeNeededAsync(target, ct);
                switch (check.Outcome)
                {
                    case SteamVrMergeCheckOutcome.NoSourceFile:
                        _logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
                        goto AfterSteamVrMerge;
                    case SteamVrMergeCheckOutcome.DisabledByConfig:
                        _logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
                        goto AfterSteamVrMerge;
                    case SteamVrMergeCheckOutcome.SteamNotFound:
                        _logger.LogInformation(
                            "SteamVR merge skipped for v{Version}: Steam not found on this machine",
                            row.Version);
                        goto AfterSteamVrMerge;
                    case SteamVrMergeCheckOutcome.UpToDate:
                        // ★ Cas optimal : la conf est déjà alignée → on ne ferme
                        // PAS SteamVR/VBS, on ne montre PAS de popup, on log juste.
                        _logger.LogInformation(
                            "SteamVR settings already up-to-date for v{Version} (target: {Path}) — skipping kill+merge",
                            row.Version, check.TargetPath);
                        goto AfterSteamVrMerge;
                    case SteamVrMergeCheckOutcome.InvalidJson:
                        ThemedMessageBox.Show(
                            Strings.MsgSteamVrMergeFailed(check.ErrorMessage ?? "?"),
                            Strings.MsgBoxSteamVrMergeFailed,
                            MessageBoxButton.OK, MessageBoxImage.Warning);
                        goto AfterSteamVrMerge;
                    case SteamVrMergeCheckOutcome.Needed:
                        // On continue vers le pop-up + kill + merge.
                        break;
                }

                // ÉTAPE 2 — Construit la liste des processus à tuer. Source :
                // les health checks de type "Process" configurés par l'opérateur.
                // C'est lui qui sait quelle VR runtime tourne sur la machine ;
                // on lui fait confiance plutôt que de hardcoder une liste obsolète.
                //
                // Sécurité : on s'assure que VIVE Business Streaming et SteamVR
                // sont DANS la liste (sinon le merge échouera car SteamVR garde
                // son fichier ouvert). On dédup case-insensitive et on ordonne
                // VBS en tête (autres "guardians" qui relancent SteamVR aussi).
                var killList = BuildSteamVrProcessKillList();

                // ÉTAPE 3 — Pré-flight : popup d'avertissement avec liste des
                // programmes à fermer + nombre de blocs qui changeront.
                if (killList.Count > 0)
                {
                    var listPreview = string.Join("\n  • ", killList.Take(10));
                    if (killList.Count > 10) listPreview += $"\n  • … (+{killList.Count - 10})";
                    var ok = ThemedMessageBox.Show(
                        Strings.MsgSteamVrPreFlightBody(listPreview, check.RootKeysWouldChange),
                        Strings.MsgSteamVrPreFlightTitle,
                        MessageBoxButton.OKCancel, MessageBoxImage.Information,
                        MessageBoxResult.OK);
                    if (ok != MessageBoxResult.OK)
                    {
                        // Annulé par l'opérateur : on skip le merge mais on
                        // continue l'install (les blocs SteamVR ne seront pas
                        // mis à jour ; l'opérateur le sait).
                        _logger.LogInformation(
                            "User cancelled SteamVR merge phase, skipping merge but continuing install");
                        goto AfterSteamVrMerge;
                    }
                }

                StatusMessage = Strings.StatusDeployingSteamVr(row.Version);
                ProgressDetail = null;
                ProgressPercent = 0;
                var svResult = await _steamVrDeployer.MergeAsync(target, killList, ct);
                switch (svResult.Status)
                {
                    case SteamVrMergeStatus.Merged:
                        _logger.LogInformation(
                            "SteamVR merge for v{Version}: replaced={Replaced}, added={Added} (target: {Path})",
                            row.Version, svResult.KeysReplaced, svResult.KeysAdded, svResult.TargetPath);
                        break;
                    case SteamVrMergeStatus.SkippedNoSourceFile:
                        _logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
                        break;
                    case SteamVrMergeStatus.SkippedDisabledByConfig:
                        _logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
                        break;
                    case SteamVrMergeStatus.SteamNotFound:
                        // Pas de popup — beaucoup de machines de dev n'ont pas Steam,
                        // pas la peine de spammer l'opérateur.
                        _logger.LogInformation(
                            "SteamVR merge skipped for v{Version}: Steam not found on this machine",
                            row.Version);
                        break;
                    case SteamVrMergeStatus.InvalidJson:
                    case SteamVrMergeStatus.Failed:
                        // Popup quand-même : c'est probablement un bug à investiguer
                        // (ZIP corrompu, fichier SteamVR endommagé manuellement, etc.).
                        ThemedMessageBox.Show(
                            Strings.MsgSteamVrMergeFailed(svResult.ErrorMessage ?? "?"),
                            Strings.MsgBoxSteamVrMergeFailed,
                            MessageBoxButton.OK, MessageBoxImage.Warning);
                        break;
                }
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
            catch (Exception ex)
            {
                _logger.LogError(ex, "SteamVR merge threw an unhandled exception (continuing install)");
            }
            AfterSteamVrMerge:

            // 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
            //    a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
            if (_config.Database.AutoApplyMigrations)
            {
                StatusMessage = Strings.StatusApplyingMigrations(row.Version);
                ProgressDetail = null;
                ProgressPercent = 0;
                var migProgress = new Progress<MigrationProgress>(mp =>
                {
                    if (mp.Total > 0)
                    {
                        var pct = (double)mp.Done / mp.Total * 100.0;
                        ProgressPercent = pct;
                        row.ProgressPercent = pct;
                    }
                    var label = string.IsNullOrEmpty(mp.CurrentFilename)
                        ? Strings.StatusApplyingMigrations(row.Version)
                        : Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename);
                    ProgressDetail = label;
                    row.ProgressDetail = label;
                });
                var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct);
                if (!migResult.AllSucceeded)
                {
                    // L'install a réussi côté fichiers ; on garde la version installée mais on
                    // notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance
                    // de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau.
                    _logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage);
                    ThemedMessageBox.Show(
                        Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"),
                        Strings.MsgBoxMigrationFailed,
                        MessageBoxButton.OK, MessageBoxImage.Warning);
                }
                else if (migResult.AppliedCount > 0)
                {
                    _logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version);
                }
            }

            // 6) Déploiement de l'outil Report (page web XAMPP).
            //    Cherche _report/ dans le ZIP extrait et copie vers htdocs en
            //    atomic rename. Skip silencieux si _report/ absent du ZIP.
            if (_config.ReportTool.AutoDeploy)
            {
                StatusMessage = Strings.StatusDeployingReport(row.Version);
                ProgressDetail = null;
                ProgressPercent = 0;
                var rdProgress = new Progress<DeployProgress>(dp =>
                {
                    if (dp.FilesTotal > 0)
                    {
                        var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
                        ProgressPercent = pct;
                        row.ProgressPercent = pct;
                    }
                    var label = Strings.ProgressReportDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
                    ProgressDetail = label;
                    row.ProgressDetail = label;
                });
                var rdResult = await _reportDeployer.DeployAsync(target, rdProgress, ct);
                switch (rdResult.Status)
                {
                    case DeployStatus.Deployed:
                        _logger.LogInformation("Report tool deployed ({N} files) for v{Version}", rdResult.FilesDeployed, row.Version);
                        break;
                    case DeployStatus.SkippedNoSourceFolder:
                        _logger.LogInformation("No _report/ in v{Version} ZIP, report tool unchanged", row.Version);
                        break;
                    case DeployStatus.XamppNotFound:
                        ThemedMessageBox.Show(
                            Strings.MsgReportXamppNotFound(_config.ReportTool.HtdocsRoot),
                            Strings.MsgBoxReportDeployFailed,
                            MessageBoxButton.OK, MessageBoxImage.Warning);
                        break;
                    case DeployStatus.Failed:
                        ThemedMessageBox.Show(
                            Strings.MsgReportDeployFailed(rdResult.ErrorMessage ?? "?"),
                            Strings.MsgBoxReportDeployFailed,
                            MessageBoxButton.OK, MessageBoxImage.Warning);
                        break;
                }
            }

            // 7) Déploiement de l'outil Documentation (parallèle à Report).
            //    Cherche _doc/ dans le ZIP extrait et copie vers htdocs en
            //    atomic rename. Skip silencieux si _doc/ absent du ZIP.
            if (_config.DocTool.AutoDeploy)
            {
                StatusMessage = Strings.StatusDeployingDoc(row.Version);
                ProgressDetail = null;
                ProgressPercent = 0;
                var ddProgress = new Progress<DeployProgress>(dp =>
                {
                    if (dp.FilesTotal > 0)
                    {
                        var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
                        ProgressPercent = pct;
                        row.ProgressPercent = pct;
                    }
                    var label = Strings.ProgressDocDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
                    ProgressDetail = label;
                    row.ProgressDetail = label;
                });
                var ddResult = await _docDeployer.DeployAsync(target, ddProgress, ct);
                switch (ddResult.Status)
                {
                    case DeployStatus.Deployed:
                        _logger.LogInformation("Doc tool deployed ({N} files) for v{Version}", ddResult.FilesDeployed, row.Version);
                        break;
                    case DeployStatus.SkippedNoSourceFolder:
                        _logger.LogInformation("No _doc/ in v{Version} ZIP, doc tool unchanged", row.Version);
                        break;
                    case DeployStatus.XamppNotFound:
                        // Le warning Report a déjà été affiché si nécessaire ; on reste silencieux ici
                        // (même cause = même symptôme, inutile de faire 2 popups identiques).
                        _logger.LogWarning("Doc tool deploy : XAMPP htdocs not found at {Root}", _config.DocTool.HtdocsRoot);
                        break;
                    case DeployStatus.Failed:
                        ThemedMessageBox.Show(
                            Strings.MsgDocDeployFailed(ddResult.ErrorMessage ?? "?"),
                            Strings.MsgBoxDocDeployFailed,
                            MessageBoxButton.OK, MessageBoxImage.Warning);
                        break;
                }
            }

            // 8) Déploiement de l'API PHP de stats (parallèle à Report/Doc).
            //    Cherche _api/ dans le ZIP extrait et copie vers htdocs en
            //    atomic rename. Skip silencieux si _api/ absent du ZIP.
            if (_config.ApiTool.AutoDeploy)
            {
                StatusMessage = Strings.StatusDeployingApi(row.Version);
                ProgressDetail = null;
                ProgressPercent = 0;
                var apProgress = new Progress<DeployProgress>(dp =>
                {
                    if (dp.FilesTotal > 0)
                    {
                        var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
                        ProgressPercent = pct;
                        row.ProgressPercent = pct;
                    }
                    var label = Strings.ProgressApiDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
                    ProgressDetail = label;
                    row.ProgressDetail = label;
                });
                var apResult = await _apiDeployer.DeployAsync(target, apProgress, ct);
                switch (apResult.Status)
                {
                    case DeployStatus.Deployed:
                        _logger.LogInformation("Api tool deployed ({N} files) for v{Version}", apResult.FilesDeployed, row.Version);
                        break;
                    case DeployStatus.SkippedNoSourceFolder:
                        _logger.LogInformation("No _api/ in v{Version} ZIP, api tool unchanged", row.Version);
                        break;
                    case DeployStatus.XamppNotFound:
                        // Idem Doc : warning XAMPP déjà éventuellement affiché par Report,
                        // on log silencieusement pour ne pas multiplier les popups identiques.
                        _logger.LogWarning("Api tool deploy : XAMPP htdocs not found at {Root}", _config.ApiTool.HtdocsRoot);
                        break;
                    case DeployStatus.Failed:
                        ThemedMessageBox.Show(
                            Strings.MsgApiDeployFailed(apResult.ErrorMessage ?? "?"),
                            Strings.MsgBoxApiDeployFailed,
                            MessageBoxButton.OK, MessageBoxImage.Warning);
                        break;
                }
            }

            // Cleanup explicite de l'ancien row instance AVANT le RebuildList.
            // Sans ça :
            //   1. `row.State` reste à `Installing` (jamais resété depuis le step 4
            //      ligne ~1260) → si un binding survit sur cette instance le temps
            //      que WPF rafraîchisse OtherVersions.Clear()+Add(), l'utilisateur
            //      voit brièvement (ou durablement, cf. bug rapporté) une row
            //      « en cours d'installation » à côté du toast "✓ installée".
            //   2. `_activeRow` pointe encore sur cette OLD instance, ce qui
            //      maintient `HasActiveDownload` truthy le temps que `finally`
            //      tourne — pas grave en pratique mais pas propre.
            // Le RebuildList va de toute façon créer une NOUVELLE instance
            // `ForInstalled(...)` pour cette version puisque le scan disque la
            // détecte maintenant. Cet edit purge l'ancienne instance avant que
            // les bindings puissent s'y raccrocher.
            row.State = VersionRowState.InstalledIdle;
            row.ProgressPercent = 0;
            row.ProgressDetail = null;
            _activeRow = null;

            StatusMessage = Strings.StatusInstalledSuccess(row.Version);
            ProgressDetail = null;
            ProgressPercent = 0;
            // Reset le flag retry — un futur install (même version ou autre)
            // a droit à sa propre tentative de retry sur 404.
            _install404RetryConsumed = false;
            RebuildList();
            _toastService.ShowSuccess(
                Strings.ToastInstallSuccessTitle,
                Strings.ToastInstallSuccessBody(row.Version));
        }
        catch (OperationCanceledException)
        {
            StatusMessage = Strings.StatusDownloadCancelled;
            row.State = VersionRowState.AvailableIdle;
            row.ProgressDetail = null;
            row.ProgressPercent = 0;
            // Rafraîchit le pourcentage de reprise depuis le state.json sauvegardé
            // au moment du cancel. Sinon le bouton « Reprendre (X%) » garderait la
            // valeur d'avant le DL (souvent 0% ou l'ancien %).
            RefreshResumableBytes(row);
        }
        catch (InvalidDataException shaEx) when (shaEx.Message.Contains("Checksum SHA-256", StringComparison.OrdinalIgnoreCase))
        {
            // Cas spécifique : SHA-256 mismatch après DL. Causes typiques :
            //   • OVH (CDN) sert un ZIP obsolète parce que le cache n'a pas
            //     encore expiré (TTL 24h-72h) suite à un re-build serveur
            //   • Un peer LAN sert un ZIP dont le sidecar est corrompu/menteur
            //   • Le manifest a été modifié manuellement sans re-build du ZIP
            //
            // Actions automatiques :
            //   1. Purge le cache local pour cette version (le ZIP que NOUS
            //      avions cached avant cette tentative pourrait être obsolète
            //      lui aussi ; mieux vaut tout vider pour ne pas servir du
            //      contenu pourri aux autres peers du LAN)
            //   2. Affiche un message d'erreur SPÉCIFIQUE avec la source du
            //      DL (peer LAN ou OVH) pour aider l'opérateur à diagnostiquer
            //   3. Suggère le menu "↻ Forcer re-téléchargement" pour réessayer
            //      après que le cache CDN OVH ait expiré
            _logger.LogError(shaEx,
                "SHA-256 verify failed for v{Version} (source: {Source})",
                row.Version, _currentPeerHost ?? "OVH");
            row.State = VersionRowState.AvailableIdle;
            row.ProgressDetail = null;
            row.ProgressPercent = 0;
            try
            {
                await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
            }
            catch (Exception purgeEx)
            {
                _logger.LogWarning(purgeEx, "Could not purge LAN cache for v{Version} after SHA mismatch", row.Version);
            }
            var sourceLabel = _currentPeerHost is null
                ? Strings.SourceOvh
                : Strings.SourcePeer(_currentPeerHost);
            var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel);
            ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch,
                MessageBoxButton.OK, MessageBoxImage.Warning);
            StatusMessage = Strings.StatusShaMismatch(row.Version);
            _toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), Strings.MsgBoxShaMismatch);
            RefreshResumableBytes(row);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Install failed for {Version}", row.Version);
            row.State = VersionRowState.AvailableIdle;
            row.ProgressDetail = null;
            row.ProgressPercent = 0;

            // Cas spécifique HTTP 404 : le fichier source a été renommé/supprimé
            // sur le serveur (typiquement après un re-upload pour buster le cache
            // CDN OVH). Le manifest local référence l'ancienne URL → la DL fail
            // en 404.
            //
            // Stratégie en 2 temps :
            //   1. Premier 404 → auto-refresh du manifest + AUTO-RETRY install
            //      transparent (l'opérateur ne voit rien d'autre qu'un message
            //      "rafraîchissement…" puis la nouvelle DL qui démarre).
            //   2. Deuxième 404 après retry → le manifest serveur ELLE-MÊME
            //      est obsolète (le rename a été fait au filesystem sans MAJ
            //      du manifest serveur). Message clair pour que l'opérateur
            //      sache que c'est un problème côté serveur, pas client.
            bool is404 = ex.Message.Contains("HTTP 404", StringComparison.OrdinalIgnoreCase)
                      || ex.Message.Contains("404 on segment", StringComparison.OrdinalIgnoreCase);
            if (is404 && !_install404RetryConsumed)
            {
                // PREMIER 404 : on tente le refresh + retry une fois.
                _install404RetryConsumed = true;
                _logger.LogInformation(
                    "404 detected for v{Version} (first attempt) — auto-refreshing manifest then retrying install",
                    row.Version);

                // Capture l'URL actuelle pour diagnostic (avant refresh).
                var urlBefore = row.Remote?.Download.Url;

                bool refreshOk = false;
                try
                {
                    await CheckForUpdatesAsync();
                    refreshOk = true;
                }
                catch (Exception refreshEx)
                {
                    _logger.LogWarning(refreshEx, "Auto-refresh manifest after 404 failed");
                }

                // Récupère la row fraîche depuis le rebuild (l'instance peut
                // avoir changé) pour relire la nouvelle URL du manifest.
                var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
                            ?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
                var urlAfter = freshRow?.Remote?.Download.Url;
                _logger.LogInformation(
                    "Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
                    row.Version, urlBefore, urlAfter);

                if (refreshOk && freshRow is not null && freshRow.Remote is not null
                    && !string.Equals(urlBefore, urlAfter, StringComparison.Ordinal))
                {
                    // L'URL a changé → le manifest s'est bien rafraîchi avec une
                    // nouvelle URL pointant vers le fichier renommé. Auto-retry
                    // l'install. On déclenche via la même mécanique que le clic
                    // Install (SafeInstallAsync) pour bénéficier du try/catch
                    // global et de la mécanique standard.
                    StatusMessage = Strings.StatusAutoRetryAfter404(row.Version);
                    // ⚠ on libère les flags du finally en avance pour que le
                    // retry puisse re-prendre _activeRow / IsBusy. Le finally
                    // s'exécutera quand même mais idempotent (déjà null).
                    _activeDownloadCts?.Dispose();
                    _activeDownloadCts = null;
                    _activeRow = null;
                    _activeInstallTask = null;
                    _currentPeerHost = null;
                    IsBusy = false;
                    IsDownloadActive = false;
                    // Fire-and-forget : Polly et SafeInstallAsync gèrent les exceptions.
                    _activeInstallTask = SafeInstallAsync(freshRow);
                    return;
                }

                // URL identique après refresh OU refresh échoué → tomber dans
                // le message "manifest serveur stale" (cas 2).
                _logger.LogWarning(
                    "Manifest refresh did not produce a new URL for v{Version} — server-side manifest is also stale",
                    row.Version);
                ThemedMessageBox.Show(
                    Strings.MsgServerSideStaleBody(row.Version),
                    Strings.MsgBoxStaleManifest,
                    MessageBoxButton.OK, MessageBoxImage.Warning);
                StatusMessage = Strings.StatusManifestRefreshed;
            }
            else if (is404)
            {
                // DEUXIÈME 404 dans la même session install (déjà retried).
                // On reset le flag pour que la prochaine action user manuelle
                // (autre clic Install) reparte avec une tentative de retry fraîche.
                _install404RetryConsumed = false;
                ThemedMessageBox.Show(
                    Strings.MsgServerSideStaleBody(row.Version),
                    Strings.MsgBoxStaleManifest,
                    MessageBoxButton.OK, MessageBoxImage.Warning);
                StatusMessage = Strings.StatusManifestRefreshed;
            }
            else
            {
                ThemedMessageBox.Show(
                    Strings.MsgInstallFailed(ex.Message),
                    Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
                StatusMessage = Strings.StatusError(ex.Message);
            }
            _toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
            // Idem : si une erreur réseau interrompt le DL en cours de route, le
            // partial est plus gros qu'avant — il faut refléter le nouveau %.
            RefreshResumableBytes(row);
        }
        finally
        {
            _activeDownloadCts?.Dispose();
            _activeDownloadCts = null;
            _activeRow = null;
            _activeInstallTask = null;
            _currentPeerHost = null;
            IsBusy = false;
            IsDownloadActive = false;
        }
    }

    /// <summary>
    /// Si <c>_redist/</c> existe dans le dossier d'install, lance tous les .exe
    /// dedans en silent + UAC élevé pour installer les redists Unreal (VC_redist,
    /// UEPrereqSetup, etc.). Skip si déjà fait (tracé dans .proserve-meta.json).
    /// Best-effort : on log mais on ne fail pas l'install si un redist plante
    /// (typiquement les "already installed" retournent un exit code non-zero).
    /// </summary>
    private async Task InstallRedistsAsync(string installDir, string version, CancellationToken ct)
    {
        var redistDir = Path.Combine(installDir, "_redist");
        if (!Directory.Exists(redistDir))
        {
            _logger.LogDebug("No _redist/ in v{Version}, skipping redists install", version);
            return;
        }
        if (_registry.IsRedistInstalled(installDir))
        {
            _logger.LogInformation("Redists already installed for v{Version} (per metadata), skipping", version);
            return;
        }

        var exes = Directory.GetFiles(redistDir, "*.exe", SearchOption.TopDirectoryOnly);
        if (exes.Length == 0)
        {
            _logger.LogDebug("_redist/ exists but contains no .exe, skipping");
            return;
        }
        Array.Sort(exes, StringComparer.OrdinalIgnoreCase);

        StatusMessage = Strings.StatusInstallingRedists(version);
        ProgressDetail = null;
        for (int i = 0; i < exes.Length; i++)
        {
            ct.ThrowIfCancellationRequested();
            var exe = exes[i];
            var name = Path.GetFileName(exe);
            ProgressDetail = Strings.ProgressRedistInstalling(i + 1, exes.Length, name);
            _logger.LogInformation("Running redist {Idx}/{Total} : {Exe}", i + 1, exes.Length, name);
            try
            {
                // Silent flags qui couvrent VC_redist (/install /quiet /norestart) et
                // UEPrereqSetup (/install /quiet /norestart). MSI std accepte aussi.
                // UseShellExecute=true + Verb=runas → UAC popup pour élévation admin.
                var psi = new System.Diagnostics.ProcessStartInfo
                {
                    FileName = exe,
                    Arguments = "/install /quiet /norestart",
                    UseShellExecute = true,
                    Verb = "runas",
                    CreateNoWindow = true,
                };
                using var proc = System.Diagnostics.Process.Start(psi);
                if (proc is null) continue;
                await proc.WaitForExitAsync(ct).ConfigureAwait(false);
                _logger.LogInformation("Redist {Name} exit code {Code}", name, proc.ExitCode);
                // Codes typiques :
                //  0    = succès
                //  3010 = succès mais reboot requis (on ne fait pas, le user gérera)
                //  1638 = "already installed, newer version" — OK, on continue
                //  1602/1603 = user cancel UAC ou install error — log mais continue
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Redist {Name} install failed (continuing)", name);
            }
        }

        try
        {
            await _registry.MarkRedistInstalledAsync(installDir, ct).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to mark redist installed in metadata");
        }
    }

    private async Task UninstallVersionAsync(VersionRowViewModel row)
    {
        if (row.Installed is null) return;

        var sizeStr = FormatSize(row.SizeBytes);
        var confirm = ThemedMessageBox.Show(
            Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr),
            Strings.MsgBoxConfirm,
            MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);

        if (confirm != MessageBoxResult.Yes) return;

        if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }

        IsBusy = true;
        row.State = VersionRowState.Uninstalling;
        StatusMessage = Strings.StatusUninstallingVersion(row.Version);
        try
        {
            await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
            StatusMessage = Strings.StatusUninstalledVersion(row.Version);
            RebuildList();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
            row.State = VersionRowState.InstalledIdle;
            ThemedMessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
                MessageBoxButton.OK, MessageBoxImage.Error);
            StatusMessage = Strings.StatusError(ex.Message);
        }
        finally
        {
            IsBusy = false;
        }
    }

    private async Task ShowReleaseNotesAsync(VersionRowViewModel row)
    {
        var url = row.Remote?.ReleaseNotesUrl;
        if (string.IsNullOrEmpty(url))
        {
            ThemedMessageBox.Show(Strings.MsgNoReleaseNotes,
                Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information);
            return;
        }
        string notes;
        try { notes = await _manifestService.FetchReleaseNotesAsync(url, CancellationToken.None); }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to fetch release notes");
            ThemedMessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message),
                Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning);
            return;
        }

        var dlg = new ReleaseNotesViewerDialog(row.Version, notes) { Owner = Application.Current.MainWindow };
        dlg.ShowDialog();
    }

    private void OpenVersionFolder(VersionRowViewModel row)
    {
        if (string.IsNullOrEmpty(row.FolderPath) || !Directory.Exists(row.FolderPath)) return;
        Process.Start(new ProcessStartInfo
        {
            FileName = row.FolderPath,
            UseShellExecute = true,
            Verb = "open"
        });
    }

    /// <summary>
    /// Re-lit le state.json depuis disque pour cette version et met à jour
    /// <see cref="VersionRowViewModel.ResumableBytes"/>. À appeler après un cancel ou
    /// une exception en milieu de DL pour que le bouton « Reprendre (X%) » reflète
    /// le pourcentage réellement atteint et pas la valeur d'avant le DL.
    /// </summary>
    private void RefreshResumableBytes(VersionRowViewModel row)
    {
        try
        {
            var st = _downloadManager.GetResumableState(row.Version);
            row.ResumableBytes = st?.DownloadedBytes ?? 0;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to refresh resumable bytes for {Version}", row.Version);
        }
    }

    private void UpdateDlProgress(VersionRowViewModel row, DownloadProgress p)
    {
        if (p.TotalBytes > 0)
        {
            var pct = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
            ProgressPercent = pct;
            row.ProgressPercent = pct;
        }

        var sb = new System.Text.StringBuilder();
        // Préfixe différencié pour rendre la source du DL visible en permanence
        // dans le footer : 📡 LAN (avec IP du peer) vs ⬇ Internet (OVH).
        if (_currentPeerHost is not null)
            sb.Append("📡 LAN ").Append(_currentPeerHost).Append(" • v").Append(row.Version).Append(" : ");
        else
            sb.Append("⬇ v").Append(row.Version).Append(" : ");
        sb.Append(FormatSize(p.BytesDownloaded));
        if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
        if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
        if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
        var s = sb.ToString();
        ProgressDetail = s;
        row.ProgressDetail = s;
    }

    private static string FormatSize(long bytes) => Strings.FormatSize(bytes);

    private static string FormatEta(TimeSpan eta)
    {
        if (eta.TotalHours >= 1) return $"{(int)eta.TotalHours}h{eta.Minutes:D2}";
        if (eta.TotalMinutes >= 1) return $"{(int)eta.TotalMinutes}m{eta.Seconds:D2}";
        return $"{(int)eta.TotalSeconds}s";
    }
}
