using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
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.DocTool;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
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 IServiceProvider _serviceProvider;
    private readonly ILogger<MainViewModel> _logger;

    private LicenseValidationResponse? _license;

    private RemoteManifest? _lastManifest;
    private CancellationTokenSource? _activeDownloadCts;
    private VersionRowViewModel? _activeRow;

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

    [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;

    [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))]
    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;

    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,
        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;
        _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();

        // 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"); }
        });
    }

    /// <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()
    {
        var installed = _registry.Scan().ToDictionary(v => v.Version);
        var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
        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);
        }

        // 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;
        row.InstallHandler = r => _ = InstallVersionAsync(r);
        row.UninstallHandler = r => _ = UninstallVersionAsync(r);
        row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
        row.OpenFolderHandler = OpenVersionFolder;
        row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
    }

    [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));
            }

            if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
                StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version);
            else if (result.LatestRemote is not null)
                StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version);
            else
                StatusMessage = Strings.StatusNoRemote;
        }
        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();
        }
    }

    /// <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
        if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
        {
            _activeDownloadCts.Cancel();
            // Attend la fin de la tâche en cours pour libérer les handles fichier
            try { await Task.Delay(200); } catch { }
        }

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

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

    private void LaunchVersion(VersionRowViewModel row)
    {
        if (row.Installed is null) return;
        try
        {
            var proc = _processLauncher.Launch(row.Installed);
            _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(() =>
                    {
                        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;
                        }
                    });
                };
            }
            catch (Exception ex)
            {
                // ShellExecute peut renvoyer un Process sans accès aux events — non bloquant
                _logger.LogDebug(ex, "Could not hook Exited on launched process");
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Launch failed");
            ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
                Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
        }
    }

    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;
        _activeRow = row;
        _activeDownloadCts = new CancellationTokenSource();
        var ct = _activeDownloadCts.Token;

        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)
                string notes = Strings.ReleaseNotesNone;
                if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
                {
                    try
                    {
                        notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
                    }
                    catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); 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 — tente d'abord d'obtenir une URL HMAC-signée par le serveur
            //    (endpoint /api/download-url/{version}). Fallback transparent sur l'URL
            //    publique du manifest si le serveur n'a pas le endpoint configuré.
            row.State = VersionRowState.Downloading;
            var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
            var urlString = signed ?? row.Remote.Download.Url;
            var url = new Uri(urlString);
            // Callback de refresh d'URL : appelé par DownloadManager quand un segment
            // reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur
            // connexion lente (>= 6 h, le TTL serveur) de continuer transparemment.
            // Si la license a été révoquée entre-temps, le callback renvoie null et le
            // DL fail proprement.
            async Task<Uri?> RefreshUrlAsync(CancellationToken c)
            {
                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;
            var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
            // Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
            // On bascule le StatusMessage à ce moment-là pour que l'utilisateur sache ce qui se passe.
            var hashProgress = new Progress<double>(pct =>
            {
                var percent = (int)Math.Round(pct * 100.0);
                ProgressPercent = percent;
                row.ProgressPercent = percent;
                ProgressDetail = Strings.ProgressVerifying(row.Version, percent);
                row.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);

            // 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);

            try { File.Delete(zipPath); } catch { /* non critique */ }

            // 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;
                }
            }

            StatusMessage = Strings.StatusInstalledSuccess(row.Version);
            ProgressDetail = null;
            ProgressPercent = 0;
            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 (Exception ex)
        {
            _logger.LogError(ex, "Install failed for {Version}", row.Version);
            row.State = VersionRowState.AvailableIdle;
            row.ProgressDetail = null;
            row.ProgressPercent = 0;
            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;
            IsBusy = false;
        }
    }

    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();
        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";
    }
}
