using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Reflection;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using PSLauncher.App.Views;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.ReportTool;
using PSLauncher.Models;

namespace PSLauncher.App.ViewModels;

public sealed record LanguageOption(string Code, string Name)
{
    // Override pour que la SelectionBox d'un ComboBox affiche le libellé clair
    // (sinon WPF affiche la forme record "LanguageOption { Code = ..., Name = ... }").
    public override string ToString() => Name;
}

public sealed partial class SettingsViewModel : ObservableObject
{
    private readonly IConfigStore _configStore;
    private readonly LocalConfig _config;
    private readonly ILicenseService _licenseService;
    private readonly IDownloadStateStore _downloadStore;
    private readonly IDatabaseMigrationService _migrationService;
    private readonly IReportToolDeployer _reportDeployer;
    private readonly IDocToolDeployer _docDeployer;
    private readonly IApiToolDeployer _apiDeployer;
    private readonly ILanCacheServer _lanCacheServer;
    private readonly ILanDiscoveryService _lanDiscovery;
    private readonly IZipCacheStore _zipCacheStore;
    private readonly IInstallationRegistry _registry;
    private readonly HttpClient _http;
    private readonly ILogger<SettingsViewModel> _logger;

    public string LauncherVersion { get; }
    public string MachineId { get; }
    public string LogsDirectory { get; }
    public string CacheDirectory { get; }

    [ObservableProperty] private string _serverBaseUrl;
    [ObservableProperty] private string _installRoot;
    [ObservableProperty] private string _language;
    [ObservableProperty] private string _reportUrl;
    [ObservableProperty] private string _documentationUrl;
    [ObservableProperty] private int _parallelSegments;

    // ----- DB Migration settings -----
    [ObservableProperty] private string _dbHost;
    [ObservableProperty] private uint _dbPort;
    [ObservableProperty] private string _dbUser;
    [ObservableProperty] private string _dbPassword;
    [ObservableProperty] private string _dbName;
    [ObservableProperty] private bool _dbAutoApplyMigrations;
    [ObservableProperty] private string? _dbConnectionStatus;
    [ObservableProperty] private bool _isTestingDb;
    [ObservableProperty] private string? _migrationsReplayStatus;
    [ObservableProperty] private bool _isReplayingMigrations;

    // ----- Report Tool deploy settings -----
    [ObservableProperty] private string _reportHtdocsRoot;
    [ObservableProperty] private string _reportFolderName;
    [ObservableProperty] private bool _reportAutoDeploy;
    [ObservableProperty] private int _reportMaxBackups;
    [ObservableProperty] private string? _reportDeployStatus;
    [ObservableProperty] private bool _isDeployingReport;
    public System.Collections.ObjectModel.ObservableCollection<ReportBackupViewModel> ReportBackups { get; }
        = new();
    public bool HasReportBackups => ReportBackups.Count > 0;

    // ----- Doc Tool deploy settings (mirror of Report) -----
    [ObservableProperty] private string _docHtdocsRoot;
    [ObservableProperty] private string _docFolderName;
    [ObservableProperty] private bool _docAutoDeploy;
    [ObservableProperty] private int _docMaxBackups;
    [ObservableProperty] private string? _docDeployStatus;
    [ObservableProperty] private bool _isDeployingDoc;
    public System.Collections.ObjectModel.ObservableCollection<DocBackupViewModel> DocBackups { get; }
        = new();
    public bool HasDocBackups => DocBackups.Count > 0;

    // ----- API Tool deploy settings (mirror of Report/Doc) -----
    [ObservableProperty] private string _apiHtdocsRoot;
    [ObservableProperty] private string _apiFolderName;
    [ObservableProperty] private bool _apiAutoDeploy;
    [ObservableProperty] private int _apiMaxBackups;
    [ObservableProperty] private string? _apiDeployStatus;
    [ObservableProperty] private bool _isDeployingApi;
    public System.Collections.ObjectModel.ObservableCollection<ApiBackupViewModel> ApiBackups { get; }
        = new();
    public bool HasApiBackups => ApiBackups.Count > 0;

    // ----- SteamVR settings merge -----
    [ObservableProperty] private bool _steamVrAutoMerge;
    [ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
    /// <summary>Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe).</summary>
    [ObservableProperty] private string _steamVrProcessesToKill = string.Empty;
    /// <summary>
    /// Path canonique attendu si Steam est installé à l'emplacement par défaut.
    /// Affiché sous le TextBox d'override pour que l'opérateur sache à quoi ressemble
    /// la résolution standard et n'ait pas à le deviner / chercher dans la doc.
    /// </summary>
    public string SteamVrCanonicalPath =>
        PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath;

    // ----- Cache LAN P2P -----
    [ObservableProperty] private bool _lanServerEnabled;
    [ObservableProperty] private bool _lanClientEnabled;
    [ObservableProperty] private int _lanServerPort;
    [ObservableProperty] private int _lanDiscoveryPort;
    [ObservableProperty] private int _lanMaxCachedVersions;
    /// <summary>Multi-line text bindé sur un TextBox : 1 URL par ligne.</summary>
    [ObservableProperty] private string _lanManualPeers = string.Empty;
    /// <summary>Statut serveur affiché (rafraîchi à l'ouverture des Settings et toutes les 5 s).</summary>
    [ObservableProperty] private string _lanServerStatus = string.Empty;
    /// <summary>Liste read-only des peers découverts via UDP, formaté pour l'UI.</summary>
    public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
    public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;

    // ----- Mode auto -----
    [ObservableProperty] private bool _autoModeFeatureEnabled;
    [ObservableProperty] private int _autoModeGraceSeconds;
    [ObservableProperty] private string _autoModeSelectedVersion = string.Empty;
    /// <summary>Si true → countdown attend que tous les health checks soient OK avant de démarrer.</summary>
    [ObservableProperty] private bool _autoModeWaitForHealthChecks;
    /// <summary>Args édités dans la UI. Persiste vers <c>_config.AutoMode.Args</c> au Save.</summary>
    public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> AutoModeArgs { get; } = new();

    // ----- Health checks (bandeau de santé système) -----
    /// <summary>
    /// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
    /// pointe vers une copie modifiable de l'entrée (l'original dans
    /// <c>_config.HealthChecks.Checks</c> n'est écrasé qu'à <see cref="Save"/>),
    /// pour que Cancel ne laisse pas de modifs partielles dans la config.
    /// </summary>
    public System.Collections.ObjectModel.ObservableCollection<HealthCheckRowViewModel> HealthChecks { get; }
        = new();
    public bool HasHealthChecks => HealthChecks.Count > 0;

    public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
        PSLauncher.Core.Localization.Strings.Available
            .Select(t => new LanguageOption(t.Code, t.Name))
            .ToList();
    [ObservableProperty] private string? _connectionStatus;
    [ObservableProperty] private bool _isTestingConnection;
    [ObservableProperty] private string _cacheSizeDisplay = "—";

    public string LicenseInfo
    {
        get
        {
            var l = _licenseService.GetCached();
            if (l is null) return Strings.LicenseSummaryNone;
            if (l.Status == "valid")
                return Strings.LicenseSummaryValid(l.OwnerName ?? "—",
                    Strings.FormatDate(l.DownloadEntitlementUntil));
            if (l.Status == "expired")
                return Strings.LicenseSummaryExpired(Strings.FormatDate(l.DownloadEntitlementUntil));
            return $"{l.OwnerName} • {l.Status}";
        }
    }

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

    public string LicenseSeverity
    {
        get
        {
            var l = _licenseService.GetCached();
            if (l is null) return "Error";
            return l.Status switch
            {
                "valid" when IsExpiringSoon(l) => "Warning",
                "valid"                         => "Ok",
                _                                => "Error",
            };
        }
    }

    private static bool IsExpiringSoon(LicenseValidationResponse l)
    {
        if (l.DownloadEntitlementUntil is not { } until) return false;
        return (until - DateTime.UtcNow).TotalDays < 30;
    }

    public SettingsViewModel(
        IConfigStore configStore,
        LocalConfig config,
        ILicenseService licenseService,
        IDownloadStateStore downloadStore,
        IDatabaseMigrationService migrationService,
        IReportToolDeployer reportDeployer,
        IDocToolDeployer docDeployer,
        IApiToolDeployer apiDeployer,
        ILanCacheServer lanCacheServer,
        ILanDiscoveryService lanDiscovery,
        IZipCacheStore zipCacheStore,
        IInstallationRegistry registry,
        HttpClient http,
        ILogger<SettingsViewModel> logger)
    {
        _configStore = configStore;
        _config = config;
        _licenseService = licenseService;
        _downloadStore = downloadStore;
        _migrationService = migrationService;
        _reportDeployer = reportDeployer;
        _docDeployer = docDeployer;
        _apiDeployer = apiDeployer;
        _lanCacheServer = lanCacheServer;
        _lanDiscovery = lanDiscovery;
        _zipCacheStore = zipCacheStore;
        _registry = registry;
        _http = http;
        _logger = logger;

        _serverBaseUrl = config.ServerBaseUrl;
        _installRoot = config.InstallRoot;
        _language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
        _reportUrl = config.ReportUrl;
        _documentationUrl = config.DocumentationUrl;
        _parallelSegments = config.ParallelDownloadSegments;

        _dbHost = config.Database.Host;
        _dbPort = config.Database.Port;
        _dbUser = config.Database.User;
        _dbPassword = DatabasePasswordProtector.Unprotect(config.Database.EncryptedPassword) ?? string.Empty;
        _dbName = config.Database.Database;
        _dbAutoApplyMigrations = config.Database.AutoApplyMigrations;

        _reportHtdocsRoot = config.ReportTool.HtdocsRoot;
        _reportFolderName = config.ReportTool.FolderName;
        _reportAutoDeploy = config.ReportTool.AutoDeploy;
        _reportMaxBackups = config.ReportTool.MaxBackups;

        _docHtdocsRoot = config.DocTool.HtdocsRoot;
        _docFolderName = config.DocTool.FolderName;
        _docAutoDeploy = config.DocTool.AutoDeploy;
        _docMaxBackups = config.DocTool.MaxBackups;

        _apiHtdocsRoot = config.ApiTool.HtdocsRoot;
        _apiFolderName = config.ApiTool.FolderName;
        _apiAutoDeploy = config.ApiTool.AutoDeploy;
        _apiMaxBackups = config.ApiTool.MaxBackups;

        _steamVrAutoMerge = config.SteamVr.AutoMerge;
        _steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
        _steamVrProcessesToKill = string.Join(
            Environment.NewLine,
            config.SteamVr.ProcessesToKill ?? new List<string>());

        _autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
        _autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
        _autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
        _autoModeWaitForHealthChecks = config.AutoMode.WaitForHealthChecks;
        foreach (var a in config.AutoMode.Args ?? new List<AutoModeArg>())
            AutoModeArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveAutoModeArg));

        _lanServerEnabled = config.LanCache.ServerEnabled;
        _lanClientEnabled = config.LanCache.ClientEnabled;
        _lanServerPort = config.LanCache.ServerPort;
        _lanDiscoveryPort = config.LanCache.DiscoveryPort;
        _lanMaxCachedVersions = config.LanCache.MaxCachedVersions;
        _lanManualPeers = string.Join(Environment.NewLine, config.LanCache.ManualPeerUrls ?? new List<string>());
        RefreshLanStatus();

        // Snapshot éditable des health checks. On clone chaque entry pour ne pas
        // muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
        foreach (var entry in config.HealthChecks.Checks)
            HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));

        // Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
        _ = LoadReportBackupsAsync();
        _ = LoadDocBackupsAsync();
        _ = LoadApiBackupsAsync();

        LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
        MachineId = licenseService.GetMachineId();
        LogsDirectory = App.LogsDirectory;
        CacheDirectory = downloadStore.GetDownloadsDirectory();
        RefreshCacheSize();
    }

    [RelayCommand]
    private async Task TestConnectionAsync()
    {
        ConnectionStatus = "Test en cours…";
        IsTestingConnection = true;
        try
        {
            var url = ServerBaseUrl.TrimEnd('/') + "/health";
            using var resp = await _http.GetAsync(url);
            if (resp.IsSuccessStatusCode)
            {
                var body = await resp.Content.ReadAsStringAsync();
                ConnectionStatus = $"✓ OK ({(int)resp.StatusCode}) — {body.Substring(0, Math.Min(80, body.Length))}";
            }
            else
            {
                ConnectionStatus = $"✗ HTTP {(int)resp.StatusCode}";
            }
        }
        catch (Exception ex)
        {
            ConnectionStatus = $"✗ Erreur : {ex.Message}";
        }
        finally
        {
            IsTestingConnection = false;
        }
    }

    [RelayCommand]
    private void BrowseInstallRoot()
    {
        var dlg = new Microsoft.Win32.OpenFolderDialog
        {
            Title = Strings.SettingsInstallRoot,
            InitialDirectory = Directory.Exists(InstallRoot) ? InstallRoot : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        };
        if (dlg.ShowDialog() == true)
        {
            InstallRoot = dlg.FolderName;
        }
    }

    [RelayCommand]
    private void OpenLogsFolder()
    {
        if (Directory.Exists(LogsDirectory))
            Process.Start(new ProcessStartInfo { FileName = LogsDirectory, UseShellExecute = true, Verb = "open" });
    }

    [RelayCommand]
    private void OpenCacheFolder()
    {
        if (Directory.Exists(CacheDirectory))
            Process.Start(new ProcessStartInfo { FileName = CacheDirectory, UseShellExecute = true, Verb = "open" });
    }

    [RelayCommand]
    private void ClearCache()
    {
        var confirm = ThemedMessageBox.Show(
            Strings.MsgClearCacheConfirm,
            Strings.MsgBoxConfirm,
            MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        try
        {
            foreach (var f in Directory.EnumerateFiles(CacheDirectory))
            {
                try { File.Delete(f); } catch (Exception ex) { _logger.LogWarning(ex, "Cannot delete {F}", f); }
            }
            RefreshCacheSize();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "ClearCache failed");
            ThemedMessageBox.Show(ex.Message, Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
        }
    }

    [RelayCommand]
    private void CopyMachineId()
    {
        try { Clipboard.SetText(MachineId); }
        catch (Exception ex) { _logger.LogWarning(ex, "Clipboard failed"); }
    }

    [RelayCommand]
    private void Save()
    {
        var languageChanged = !string.Equals(
            (_config.Language ?? "auto").ToLowerInvariant(),
            (Language ?? "auto").ToLowerInvariant(),
            StringComparison.Ordinal);

        // Détecte les changements LAN qui nécessitent un restart du launcher.
        // Les hosted services (LanCacheServer, LanDiscoveryService) lisent leur
        // config UNE FOIS au démarrage de l'app — toggler ces flags après coup
        // ne réveille pas le service mort. Le port aussi : changer pendant que
        // le serveur tourne ne re-bind pas. ManualPeerUrls et MaxCachedVersions
        // sont lus à chaque appel donc OK sans restart.
        var lanRestartNeeded =
            _config.LanCache.ServerEnabled != LanServerEnabled
            || _config.LanCache.ClientEnabled != LanClientEnabled
            || (LanServerEnabled && _config.LanCache.ServerPort != LanServerPort)
            || ((LanServerEnabled || LanClientEnabled) && _config.LanCache.DiscoveryPort != LanDiscoveryPort);

        _config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
        _config.InstallRoot = InstallRoot;
        _config.Language = Language ?? "auto";
        _config.ReportUrl = ReportUrl?.Trim() ?? "";
        _config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
        _config.ParallelDownloadSegments = Math.Clamp(ParallelSegments, 1, 32);

        // DB settings
        _config.Database.Host = DbHost.Trim();
        _config.Database.Port = DbPort > 0 ? DbPort : 3306;
        _config.Database.User = DbUser.Trim();
        _config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
            ? null
            : DatabasePasswordProtector.Protect(DbPassword);
        _config.Database.Database = DbName.Trim();
        _config.Database.AutoApplyMigrations = DbAutoApplyMigrations;

        _config.ReportTool.HtdocsRoot = ReportHtdocsRoot.Trim();
        _config.ReportTool.FolderName = ReportFolderName.Trim();
        _config.ReportTool.AutoDeploy = ReportAutoDeploy;
        _config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);

        _config.DocTool.HtdocsRoot = DocHtdocsRoot.Trim();
        _config.DocTool.FolderName = DocFolderName.Trim();
        _config.DocTool.AutoDeploy = DocAutoDeploy;
        _config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups);

        _config.ApiTool.HtdocsRoot = ApiHtdocsRoot.Trim();
        _config.ApiTool.FolderName = ApiFolderName.Trim();
        _config.ApiTool.AutoDeploy = ApiAutoDeploy;
        _config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);

        _config.SteamVr.AutoMerge = SteamVrAutoMerge;
        _config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride)
            ? null : SteamVrSettingsPathOverride.Trim();
        _config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty)
            .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
            .Select(l =>
            {
                var name = l.Trim();
                // Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape.
                if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
                    name = name[..^4].Trim();
                return name;
            })
            .Where(l => l.Length > 0)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

        _config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
        _config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
        _config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
            ? null : AutoModeSelectedVersion.Trim();
        _config.AutoMode.WaitForHealthChecks = AutoModeWaitForHealthChecks;
        _config.AutoMode.Args = AutoModeArgs
            .Select(r => new AutoModeArg
            {
                Key = (r.Key ?? string.Empty).Trim(),
                Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
            })
            .Where(a => !string.IsNullOrEmpty(a.Key))
            .ToList();

        _config.LanCache.ServerEnabled = LanServerEnabled;
        _config.LanCache.ClientEnabled = LanClientEnabled;
        _config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
        _config.LanCache.DiscoveryPort = LanDiscoveryPort > 0 && LanDiscoveryPort <= 65535 ? LanDiscoveryPort : 47624;
        _config.LanCache.MaxCachedVersions = Math.Max(0, LanMaxCachedVersions);
        _config.LanCache.ManualPeerUrls = (LanManualPeers ?? string.Empty)
            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
            .Select(l => l.Trim())
            .Where(l => l.Length > 0)
            .ToList();

        // Health checks : on remplace intégralement la liste persistée par
        // les copies éditées. Les originaux restent en mémoire de toute façon
        // (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
        _config.HealthChecks.Checks = HealthChecks.Select(r => r.Entry).ToList();

        _configStore.Save(_config);
        _logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);

        // Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance.
        void PromptRestart(string message, string boxTitle, string logContext)
        {
            var ok = ThemedMessageBox.Show(
                message, boxTitle,
                MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
            if (ok != MessageBoxResult.OK) return;
            try
            {
                var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
                if (string.IsNullOrEmpty(exe)) return;
                var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
                Process.Start(new ProcessStartInfo
                {
                    FileName = "cmd.exe",
                    Arguments = cmd,
                    UseShellExecute = false,
                    CreateNoWindow = true,
                });
                Application.Current.Shutdown();
            }
            catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); }
        }

        if (languageChanged)
        {
            // Le launcher doit être relancé pour que les bindings x:Static reflètent
            // la nouvelle langue. (Si LAN a aussi changé, le même restart le couvre.)
            PromptRestart(Strings.MsgLanguageRestart, Strings.MsgBoxLanguageChange, "language change");
        }
        else if (lanRestartNeeded)
        {
            // Les hosted services LAN lisent leur config au démarrage uniquement.
            // Sans restart, toggler ServerEnabled / ClientEnabled ne réveille pas
            // le service mort.
            PromptRestart(Strings.MsgLanCacheRestart, Strings.MsgBoxLanCacheChange, "LAN cache toggle");
        }
    }

    [RelayCommand]
    private void DeactivateLicense()
    {
        var confirm = ThemedMessageBox.Show(
            Strings.MsgDeactivateLicenseConfirm,
            Strings.MsgBoxConfirm,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;
        _licenseService.Clear();
        OnPropertyChanged(nameof(LicenseInfo));
    }

    private void RefreshCacheSize()
    {
        try
        {
            long total = 0;
            if (Directory.Exists(CacheDirectory))
            {
                foreach (var f in Directory.EnumerateFiles(CacheDirectory))
                    total += new FileInfo(f).Length;
            }
            CacheSizeDisplay = FormatSize(total);
        }
        catch { CacheSizeDisplay = "—"; }
    }

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

    /// <summary>
    /// Re-déploie l'outil Report depuis la dernière version PROSERVE installée
    /// vers htdocs. Utile si le déploiement post-install avait échoué (XAMPP
    /// déplacé, htdocs absent), ou pour resync après changement du chemin htdocs
    /// dans Settings.
    /// </summary>
    [RelayCommand]
    private async Task RedeployReportAsync()
    {
        var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
        if (installed is null)
        {
            ReportDeployStatus = "Aucune version installée — installe d'abord une version qui contient _report/.";
            return;
        }
        IsDeployingReport = true;
        ReportDeployStatus = $"Déploiement depuis v{installed.Version}…";
        try
        {
            // Persiste les éventuels changements de chemin avant le deploy
            Save();
            var progress = new Progress<DeployProgress>(dp =>
            {
                if (dp.FilesTotal > 0)
                    ReportDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
            });
            var result = await _reportDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
            ReportDeployStatus = result.Status switch
            {
                DeployStatus.Deployed              => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
                DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _report/ — rien à déployer",
                DeployStatus.XamppNotFound         => $"✗ {result.ErrorMessage}",
                DeployStatus.Failed                => $"✗ {result.ErrorMessage}",
                _                                  => "?",
            };
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Redeploy report failed");
            ReportDeployStatus = $"✗ {ex.Message}";
        }
        finally
        {
            IsDeployingReport = false;
        }
    }

    // ===================== DB MIGRATIONS =====================

    /// <summary>
    /// Test la connexion à la DB MySQL avec les paramètres en cours de saisie.
    /// Persiste temporairement les valeurs sur _config.Database avant le test
    /// pour que le service les voie via son configProvider, puis remet (et le
    /// Save() final écrasera quand l'utilisateur cliquera Enregistrer).
    /// </summary>
    [RelayCommand]
    private async Task TestDbAsync()
    {
        DbConnectionStatus = Strings.SettingsTest + "…";
        IsTestingDb = true;
        // On applique temporairement les valeurs saisies
        var oldHost = _config.Database.Host;
        var oldPort = _config.Database.Port;
        var oldUser = _config.Database.User;
        var oldPwd  = _config.Database.EncryptedPassword;
        var oldDb   = _config.Database.Database;
        try
        {
            _config.Database.Host = DbHost.Trim();
            _config.Database.Port = DbPort > 0 ? DbPort : 3306;
            _config.Database.User = DbUser.Trim();
            _config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
                ? null
                : DatabasePasswordProtector.Protect(DbPassword);
            _config.Database.Database = DbName.Trim();

            var err = await _migrationService.TestConnectionAsync(CancellationToken.None);
            DbConnectionStatus = err is null ? "✓ OK" : $"✗ {err}";
        }
        catch (Exception ex) { DbConnectionStatus = $"✗ {ex.Message}"; }
        finally
        {
            // Restaure : seul Save() doit écrire pour de bon
            _config.Database.Host = oldHost;
            _config.Database.Port = oldPort;
            _config.Database.User = oldUser;
            _config.Database.EncryptedPassword = oldPwd;
            _config.Database.Database = oldDb;
            IsTestingDb = false;
        }
    }

    /// <summary>
    /// Re-joue les migrations bundled dans le _migrations/ de la version actuellement
    /// installée la plus récente. Utile si l'auto-apply post-install avait échoué (ex.
    /// XAMPP éteint à ce moment-là), ou pour rejouer manuellement après ajout d'une
    /// nouvelle migration côté dev.
    /// </summary>
    [RelayCommand]
    private async Task ReplayMigrationsAsync()
    {
        var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
        if (installed is null)
        {
            MigrationsReplayStatus = "Aucune version installée — installe d'abord une version avec _migrations/.";
            return;
        }
        IsReplayingMigrations = true;
        MigrationsReplayStatus = $"Réapplication des migrations pour v{installed.Version}…";
        try
        {
            var progress = new Progress<MigrationProgress>(mp =>
            {
                if (mp.Total > 0)
                    MigrationsReplayStatus = $"{mp.Done + 1}/{mp.Total} : {mp.CurrentFilename}";
            });
            // Il faut persister les éventuelles modifs DB avant le test (le service lit la config courante)
            Save();
            var result = await _migrationService.ApplyMigrationsAsync(
                installed.FolderPath, progress, CancellationToken.None);
            if (result.AllSucceeded)
            {
                MigrationsReplayStatus = result.AppliedCount > 0
                    ? $"✓ {result.AppliedCount} migration(s) appliquée(s), {result.SkippedCount} déjà en place."
                    : $"✓ Tout est à jour ({result.SkippedCount} déjà appliquée(s))";
            }
            else
            {
                MigrationsReplayStatus = $"✗ Échec : {result.FailureMessage}";
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Replay migrations failed");
            MigrationsReplayStatus = $"✗ Erreur : {ex.Message}";
        }
        finally
        {
            IsReplayingMigrations = false;
        }
    }

    // ===================== REPORT TOOL BACKUPS =====================

    private async Task LoadReportBackupsAsync()
    {
        try
        {
            var list = await _reportDeployer.ListBackupsAsync(CancellationToken.None);
            ReportBackups.Clear();
            foreach (var b in list)
                ReportBackups.Add(new ReportBackupViewModel(b, RevertReportToBackupAsync));
            OnPropertyChanged(nameof(HasReportBackups));
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Could not list report backups");
        }
    }

    /// <summary>Appelé par chaque ligne du tableau de backups via son own command.</summary>
    private async Task RevertReportToBackupAsync(BackupInfo backup)
    {
        var localDate = backup.TimestampUtc.ToLocalTime();
        var dateLabel = Strings.FormatLongDate(localDate);
        var confirm = ThemedMessageBox.Show(
            Strings.MsgRevertReportConfirm(dateLabel),
            Strings.MsgBoxRevertReport,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        IsDeployingReport = true;
        ReportDeployStatus = $"Revert vers {dateLabel}…";
        try
        {
            var result = await _reportDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
            ReportDeployStatus = result.Status == DeployStatus.Deployed
                ? Strings.MsgRevertReportSuccess(dateLabel)
                : $"✗ {result.ErrorMessage}";
            await LoadReportBackupsAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Revert report failed");
            ReportDeployStatus = $"✗ {ex.Message}";
        }
        finally
        {
            IsDeployingReport = false;
        }
    }

    // ===================== DOC TOOL BACKUPS (mirror of Report) =====================

    private async Task LoadDocBackupsAsync()
    {
        try
        {
            var list = await _docDeployer.ListBackupsAsync(CancellationToken.None);
            DocBackups.Clear();
            foreach (var b in list)
                DocBackups.Add(new DocBackupViewModel(b, RevertDocToBackupAsync));
            OnPropertyChanged(nameof(HasDocBackups));
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Could not list doc backups");
        }
    }

    private async Task RevertDocToBackupAsync(BackupInfo backup)
    {
        var localDate = backup.TimestampUtc.ToLocalTime();
        var dateLabel = Strings.FormatLongDate(localDate);
        var confirm = ThemedMessageBox.Show(
            Strings.MsgRevertReportConfirm(dateLabel),
            Strings.MsgBoxRevertReport,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        IsDeployingDoc = true;
        DocDeployStatus = $"Revert vers {dateLabel}…";
        try
        {
            var result = await _docDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
            DocDeployStatus = result.Status == DeployStatus.Deployed
                ? Strings.MsgRevertReportSuccess(dateLabel)
                : $"✗ {result.ErrorMessage}";
            await LoadDocBackupsAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Revert doc failed");
            DocDeployStatus = $"✗ {ex.Message}";
        }
        finally
        {
            IsDeployingDoc = false;
        }
    }

    // ===================== HEALTH CHECKS =====================

    [RelayCommand]
    private void AddHealthCheck()
    {
        var entry = new HealthCheckEntry
        {
            Name = "",
            Icon = "🔌",
            Kind = "Process",
            Target = "",
            RefreshIntervalMs = 5000,
        };
        var dlg = new HealthCheckEditorDialog(entry, isNew: true)
        {
            Owner = Application.Current.MainWindow
        };
        if (dlg.ShowDialog() == true && dlg.Saved)
        {
            HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));
            OnPropertyChanged(nameof(HasHealthChecks));
            // Le nouveau dernier item peut maintenant monter ; l'avant-dernier
            // peut maintenant descendre. Refresh global.
            RefreshHealthMoveCanExecute();
        }
    }

    private void DeleteHealthCheck(HealthCheckRowViewModel row)
    {
        var confirm = ThemedMessageBox.Show(
            Strings.SettingsHealthDeleteConfirm(string.IsNullOrEmpty(row.Name) ? row.Kind : row.Name),
            Strings.MsgBoxConfirm,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;
        HealthChecks.Remove(row);
        OnPropertyChanged(nameof(HasHealthChecks));
        RefreshHealthMoveCanExecute();
    }

    /// <summary>
    /// Réordonnancement d'un check via les boutons ▲/▼ de la liste. delta = ±1.
    /// La persistance vers <c>_config.HealthChecks.Checks</c> se fait au Save() —
    /// d'ici là, on ne mute que la collection observable. ObservableCollection.Move
    /// préserve les sélections WPF et déplace l'item sans le re-créer.
    /// </summary>
    private void MoveHealthCheck(HealthCheckRowViewModel row, int delta)
    {
        var idx = HealthChecks.IndexOf(row);
        var newIdx = idx + delta;
        if (idx < 0 || newIdx < 0 || newIdx >= HealthChecks.Count) return;
        HealthChecks.Move(idx, newIdx);
        RefreshHealthMoveCanExecute();
    }

    private bool CanMoveHealthCheck(HealthCheckRowViewModel row, int delta)
    {
        var idx = HealthChecks.IndexOf(row);
        var newIdx = idx + delta;
        return idx >= 0 && newIdx >= 0 && newIdx < HealthChecks.Count;
    }

    /// <summary>
    /// Re-publie CanExecute sur les commandes ▲/▼ de toutes les lignes : après
    /// un déplacement (ou un add/delete), les boundaries changent et les boutons
    /// doivent griser/ungrise sur les premiers/derniers items.
    /// </summary>
    private void RefreshHealthMoveCanExecute()
    {
        foreach (var r in HealthChecks)
        {
            r.MoveUpCommand.NotifyCanExecuteChanged();
            r.MoveDownCommand.NotifyCanExecuteChanged();
        }
    }

    private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new()
    {
        Name = src.Name,
        Icon = src.Icon,
        Kind = src.Kind,
        Target = src.Target,
        RefreshIntervalMs = src.RefreshIntervalMs,
        PingWarnMs = src.PingWarnMs,
        PingErrorMs = src.PingErrorMs,
        PingTimeoutMs = src.PingTimeoutMs,
    };

    [RelayCommand]
    private async Task RedeployDocAsync()
    {
        var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
        if (installed is null)
        {
            DocDeployStatus = "Aucune version installée — installe d'abord une version qui contient _doc/.";
            return;
        }
        IsDeployingDoc = true;
        DocDeployStatus = $"Déploiement depuis v{installed.Version}…";
        try
        {
            Save();
            var progress = new Progress<DeployProgress>(dp =>
            {
                if (dp.FilesTotal > 0)
                    DocDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
            });
            var result = await _docDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
            DocDeployStatus = result.Status switch
            {
                DeployStatus.Deployed              => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
                DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _doc/ — rien à déployer",
                DeployStatus.XamppNotFound         => $"✗ {result.ErrorMessage}",
                DeployStatus.Failed                => $"✗ {result.ErrorMessage}",
                _                                  => "?",
            };
            await LoadDocBackupsAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Redeploy doc failed");
            DocDeployStatus = $"✗ {ex.Message}";
        }
        finally
        {
            IsDeployingDoc = false;
        }
    }

    // ===================== API TOOL BACKUPS (mirror of Report) =====================

    private async Task LoadApiBackupsAsync()
    {
        try
        {
            var list = await _apiDeployer.ListBackupsAsync(CancellationToken.None);
            ApiBackups.Clear();
            foreach (var b in list)
                ApiBackups.Add(new ApiBackupViewModel(b, RevertApiToBackupAsync));
            OnPropertyChanged(nameof(HasApiBackups));
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Could not list api backups");
        }
    }

    private async Task RevertApiToBackupAsync(BackupInfo backup)
    {
        var localDate = backup.TimestampUtc.ToLocalTime();
        var dateLabel = Strings.FormatLongDate(localDate);
        var confirm = ThemedMessageBox.Show(
            Strings.MsgRevertReportConfirm(dateLabel),
            Strings.MsgBoxRevertReport,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        IsDeployingApi = true;
        ApiDeployStatus = $"Revert vers {dateLabel}…";
        try
        {
            var result = await _apiDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
            ApiDeployStatus = result.Status == DeployStatus.Deployed
                ? Strings.MsgRevertReportSuccess(dateLabel)
                : $"✗ {result.ErrorMessage}";
            await LoadApiBackupsAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Revert api failed");
            ApiDeployStatus = $"✗ {ex.Message}";
        }
        finally
        {
            IsDeployingApi = false;
        }
    }

    // ===================== CACHE LAN P2P =====================

    /// <summary>
    /// Rafraîchit les indicateurs LAN (statut serveur, liste des peers découverts).
    /// Appelé à l'init + via la commande "Actualiser" dans la UI.
    /// </summary>
    [RelayCommand]
    private void RefreshLanStatus()
    {
        // Statut serveur
        if (_lanCacheServer.IsRunning && _lanCacheServer.BoundEndpoints.Count > 0)
        {
            var versions = _zipCacheStore.ListCached().Count;
            LanServerStatus = Strings.SettingsLanCacheServerStatus(
                string.Join(", ", _lanCacheServer.BoundEndpoints), versions);
        }
        else
        {
            LanServerStatus = Strings.SettingsLanCacheServerOff;
        }

        // Liste des peers découverts (formaté pour l'affichage)
        var peers = _lanDiscovery.ListDiscovered();
        LanDiscoveredPeers.Clear();
        foreach (var p in peers)
        {
            var versions = p.Versions.Count > 0 ? string.Join(", ", p.Versions) : "—";
            LanDiscoveredPeers.Add($"http://{p.Host}:{p.Port}  •  versions: [{versions}]");
        }
        OnPropertyChanged(nameof(HasLanDiscoveredPeers));
    }

    [RelayCommand]
    private async Task RedeployApiAsync()
    {
        var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
        if (installed is null)
        {
            ApiDeployStatus = "Aucune version installée — installe d'abord une version qui contient _api/.";
            return;
        }
        IsDeployingApi = true;
        ApiDeployStatus = $"Déploiement depuis v{installed.Version}…";
        try
        {
            Save();
            var progress = new Progress<DeployProgress>(dp =>
            {
                if (dp.FilesTotal > 0)
                    ApiDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
            });
            var result = await _apiDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
            ApiDeployStatus = result.Status switch
            {
                DeployStatus.Deployed              => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
                DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _api/ — rien à déployer",
                DeployStatus.XamppNotFound         => $"✗ {result.ErrorMessage}",
                DeployStatus.Failed                => $"✗ {result.ErrorMessage}",
                _                                  => "?",
            };
            await LoadApiBackupsAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Redeploy api failed");
            ApiDeployStatus = $"✗ {ex.Message}";
        }
        finally
        {
            IsDeployingApi = false;
        }
    }

    // ===================== AUTO MODE ARGS =====================

    [RelayCommand]
    private void AddAutoModeArg()
    {
        AutoModeArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveAutoModeArg));
    }

    private void RemoveAutoModeArg(AutoModeArgRowViewModel row)
    {
        AutoModeArgs.Remove(row);
    }
}

/// <summary>
/// Ligne éditable d'un argument CLI passé à PROSERVE en mode auto. Clé requise,
/// valeur optionnelle (un flag seul comme <c>-autoconnect</c>). Persiste vers
/// <c>_config.AutoMode.Args</c> au Save.
/// </summary>
public sealed partial class AutoModeArgRowViewModel : ObservableObject
{
    [ObservableProperty] private string _key;
    [ObservableProperty] private string? _value;

    private readonly Action<AutoModeArgRowViewModel> _onRemove;

    public AutoModeArgRowViewModel(string key, string? value, Action<AutoModeArgRowViewModel> onRemove)
    {
        _key = key ?? string.Empty;
        _value = value;
        _onRemove = onRemove;
    }

    [RelayCommand]
    private void Remove() => _onRemove(this);
}

/// <summary>
/// Ligne du tableau "Backups disponibles" dans Settings → Avancés → Outil Report.
/// Chaque instance porte sa propre commande Revert paramétrée pour ce backup.
/// </summary>
public sealed partial class ReportBackupViewModel : ObservableObject
{
    public BackupInfo Info { get; }
    public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
    public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
    public string FolderName => Info.FolderName;

    private readonly Func<BackupInfo, Task> _revertHandler;
    public ReportBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
    {
        Info = info;
        _revertHandler = revertHandler;
    }

    [RelayCommand]
    private Task Revert() => _revertHandler(Info);
}

/// <summary>
/// Ligne du tableau "Bandeau de santé système" dans Settings → Avancés. Pointe
/// vers une copie éditable de l'entrée (clonée à l'ouverture des Settings) ;
/// la persistence vers <c>config.HealthChecks.Checks</c> n'a lieu qu'au Save.
/// </summary>
public sealed partial class HealthCheckRowViewModel : ObservableObject
{
    public HealthCheckEntry Entry { get; private set; }

    /// <summary>Re-publié à chaque édition pour rafraîchir le label dans la liste.</summary>
    [ObservableProperty]
    private string _displayName;

    [ObservableProperty]
    private string _displayIcon;

    [ObservableProperty]
    private string _displayDetail;

    public string Name => Entry.Name;
    public string Kind => Entry.Kind;

    private readonly Action<HealthCheckRowViewModel> _onDelete;
    private readonly Action<HealthCheckRowViewModel, int> _onMove;
    private readonly Func<HealthCheckRowViewModel, int, bool> _canMove;

    public HealthCheckRowViewModel(
        HealthCheckEntry entry,
        Action<HealthCheckRowViewModel> onDelete,
        Action<HealthCheckRowViewModel, int> onMove,
        Func<HealthCheckRowViewModel, int, bool> canMove)
    {
        Entry = entry;
        _onDelete = onDelete;
        _onMove = onMove;
        _canMove = canMove;
        _displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name;
        _displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
        _displayDetail = ComputeDetail(entry);
    }

    private static string ComputeDetail(HealthCheckEntry e)
    {
        var kind = e.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true ? "Ping" : "Process";
        var target = string.IsNullOrEmpty(e.Target) ? "—" : e.Target;
        return $"{kind} · {target} · {e.RefreshIntervalMs} ms";
    }

    [RelayCommand]
    private void Edit()
    {
        var dlg = new PSLauncher.App.Views.HealthCheckEditorDialog(Entry)
        {
            Owner = Application.Current.MainWindow
        };
        if (dlg.ShowDialog() == true && dlg.Saved)
        {
            // Entry a été muté in-place par le dialog. Refresh des bindings d'affichage.
            DisplayName = string.IsNullOrEmpty(Entry.Name) ? "(sans nom)" : Entry.Name;
            DisplayIcon = string.IsNullOrEmpty(Entry.Icon) ? "🔌" : Entry.Icon;
            DisplayDetail = ComputeDetail(Entry);
        }
    }

    [RelayCommand]
    private void Delete() => _onDelete(this);

    [RelayCommand(CanExecute = nameof(CanMoveUp))]
    private void MoveUp() => _onMove(this, -1);
    private bool CanMoveUp() => _canMove(this, -1);

    [RelayCommand(CanExecute = nameof(CanMoveDown))]
    private void MoveDown() => _onMove(this, +1);
    private bool CanMoveDown() => _canMove(this, +1);
}

/// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>
public sealed partial class DocBackupViewModel : ObservableObject
{
    public BackupInfo Info { get; }
    public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
    public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
    public string FolderName => Info.FolderName;

    private readonly Func<BackupInfo, Task> _revertHandler;
    public DocBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
    {
        Info = info;
        _revertHandler = revertHandler;
    }

    [RelayCommand]
    private Task Revert() => _revertHandler(Info);
}

/// <summary>Variante api — même structure que <see cref="ReportBackupViewModel"/>.</summary>
public sealed partial class ApiBackupViewModel : ObservableObject
{
    public BackupInfo Info { get; }
    public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
    public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
    public string FolderName => Info.FolderName;

    private readonly Func<BackupInfo, Task> _revertHandler;
    public ApiBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
    {
        Info = info;
        _revertHandler = revertHandler;
    }

    [RelayCommand]
    private Task Revert() => _revertHandler(Info);
}
