using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; using SkiaSharp; namespace SharepointToolbox.ViewModels.Tabs; public partial class StorageViewModel : FeatureViewModelBase { private readonly IStorageService _storageService; private readonly ISessionManager _sessionManager; private readonly StorageCsvExportService _csvExportService; private readonly StorageHtmlExportService _htmlExportService; private readonly IBrandingService? _brandingService; private readonly ILogger _logger; private TenantProfile? _currentProfile; [ObservableProperty] private bool _perLibrary = true; [ObservableProperty] private bool _includeSubsites; [ObservableProperty] private int _folderDepth; [ObservableProperty] private bool _isDonutChart = true; private ObservableCollection _fileTypeMetrics = new(); public ObservableCollection FileTypeMetrics { get => _fileTypeMetrics; private set { _fileTypeMetrics = value; OnPropertyChanged(); UpdateChartSeries(); } } public bool HasChartData => FileTypeMetrics.Count > 0; private IEnumerable _pieChartSeries = Enumerable.Empty(); public IEnumerable PieChartSeries { get => _pieChartSeries; private set { _pieChartSeries = value; OnPropertyChanged(); } } private IEnumerable _barChartSeries = Enumerable.Empty(); public IEnumerable BarChartSeries { get => _barChartSeries; private set { _barChartSeries = value; OnPropertyChanged(); } } private Axis[] _barXAxes = Array.Empty(); public Axis[] BarXAxes { get => _barXAxes; private set { _barXAxes = value; OnPropertyChanged(); } } private Axis[] _barYAxes = Array.Empty(); public Axis[] BarYAxes { get => _barYAxes; private set { _barYAxes = value; OnPropertyChanged(); } } public bool IsMaxDepth { get => FolderDepth >= 999; set { if (value) FolderDepth = 999; else if (FolderDepth >= 999) FolderDepth = 0; OnPropertyChanged(); } } private ObservableCollection _results = new(); public ObservableCollection Results { get => _results; private set { _results = value; OnPropertyChanged(); NotifySummaryProperties(); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } } // ── Summary properties (computed from root-level library nodes) ───────── /// Sum of TotalSizeBytes across root-level library nodes. public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes); /// Sum of VersionSizeBytes across root-level library nodes. public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes); /// Sum of TotalFileCount across root-level library nodes. public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount); public bool HasResults => Results.Count > 0; private void NotifySummaryProperties() { OnPropertyChanged(nameof(SummaryTotalSize)); OnPropertyChanged(nameof(SummaryVersionSize)); OnPropertyChanged(nameof(SummaryFileCount)); OnPropertyChanged(nameof(HasResults)); } public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } public TenantProfile? CurrentProfile => _currentProfile; public StorageViewModel( IStorageService storageService, ISessionManager sessionManager, StorageCsvExportService csvExportService, StorageHtmlExportService htmlExportService, IBrandingService brandingService, ILogger logger) : base(logger) { _storageService = storageService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _brandingService = brandingService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } /// Test constructor — omits export services. internal StorageViewModel( IStorageService storageService, ISessionManager sessionManager, ILogger logger, IBrandingService? brandingService = null) : base(logger) { _storageService = storageService; _sessionManager = sessionManager; _csvExportService = null!; _htmlExportService = null!; _brandingService = brandingService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { if (_currentProfile == null) { StatusMessage = "No tenant selected. Please connect to a tenant first."; return; } var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) { StatusMessage = "Select at least one site from the toolbar."; return; } var nonEmpty = urls; var options = new StorageScanOptions( PerLibrary: PerLibrary, IncludeSubsites: IncludeSubsites, FolderDepth: FolderDepth); var allNodes = new List(); var allTypeMetrics = new List(); int i = 0; foreach (var url in nonEmpty) { ct.ThrowIfCancellationRequested(); i++; progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}...")); var siteProfile = new TenantProfile { TenantUrl = url.TrimEnd('/'), ClientId = _currentProfile.ClientId, Name = _currentProfile.Name }; var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct); var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct); // Backfill any libraries where StorageMetrics returned zeros await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct); allNodes.AddRange(nodes); progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}...")); var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct); allTypeMetrics.AddRange(typeMetrics); } // Flatten tree for DataGrid display var flat = new List(); foreach (var node in allNodes) FlattenNode(node, 0, flat); // Merge file-type metrics across sites (same extension -> sum) var mergedMetrics = allTypeMetrics .GroupBy(m => m.Extension, StringComparer.OrdinalIgnoreCase) .Select(g => new FileTypeMetric(g.Key, g.Sum(m => m.TotalSizeBytes), g.Sum(m => m.FileCount))) .OrderByDescending(m => m.TotalSizeBytes) .ToList(); if (Application.Current?.Dispatcher is { } dispatcher) { await dispatcher.InvokeAsync(() => { Results = new ObservableCollection(flat); FileTypeMetrics = new ObservableCollection(mergedMetrics); }); } else { Results = new ObservableCollection(flat); FileTypeMetrics = new ObservableCollection(mergedMetrics); } } protected override void OnTenantSwitched(TenantProfile profile) { _currentProfile = profile; Results = new ObservableCollection(); FileTypeMetrics = new ObservableCollection(); OnPropertyChanged(nameof(CurrentProfile)); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; internal Task TestRunOperationAsync(CancellationToken ct, IProgress progress) => RunOperationAsync(ct, progress); private bool CanExport() => Results.Count > 0; private async Task ExportCsvAsync() { if (Results.Count == 0) return; var dialog = new SaveFileDialog { Title = "Export storage metrics to CSV", Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", DefaultExt = "csv", FileName = "storage_metrics" }; if (dialog.ShowDialog() != true) return; try { await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None); OpenFile(dialog.FileName); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); } } private async Task ExportHtmlAsync() { if (Results.Count == 0) return; var dialog = new SaveFileDialog { Title = "Export storage metrics to HTML", Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*", DefaultExt = "html", FileName = "storage_metrics" }; if (dialog.ShowDialog() != true) return; try { ReportBranding? branding = null; if (_brandingService is not null) { var mspLogo = await _brandingService.GetMspLogoAsync(); var clientLogo = _currentProfile?.ClientLogo; branding = new ReportBranding(mspLogo, clientLogo); } await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding); OpenFile(dialog.FileName); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); } } partial void OnIsDonutChartChanged(bool value) { UpdateChartSeries(); } private void UpdateChartSeries() { var metrics = FileTypeMetrics.ToList(); OnPropertyChanged(nameof(HasChartData)); if (metrics.Count == 0) { PieChartSeries = Enumerable.Empty(); BarChartSeries = Enumerable.Empty(); BarXAxes = Array.Empty(); BarYAxes = Array.Empty(); return; } // Take top 10 by size, aggregate the rest as "Other" var top = metrics.Take(10).ToList(); long otherSize = metrics.Skip(10).Sum(m => m.TotalSizeBytes); int otherCount = metrics.Skip(10).Sum(m => m.FileCount); var chartItems = new List(top); if (otherSize > 0) chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount)); // Pie/Donut: one PieSeries per slice (LiveCharts2 requires this for per-slice colors). // Tooltip only shows the hovered slice because each series has exactly one value. double innerRadius = IsDonutChart ? 50 : 0; var pieList = new List(); foreach (var m in chartItems) { pieList.Add(new PieSeries { Values = new[] { (double)m.TotalSizeBytes }, Name = m.DisplayLabel, InnerRadius = innerRadius, HoverPushout = 8, MaxRadialColumnWidth = 60, DataLabelsFormatter = _ => m.DisplayLabel, ToolTipLabelFormatter = _ => $"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)", IsVisibleAtLegend = true, }); } PieChartSeries = pieList; // Bar chart series BarChartSeries = new ISeries[] { new ColumnSeries { Values = chartItems.Select(m => m.TotalSizeBytes).ToArray(), Name = "Size", DataLabelsFormatter = point => { int idx = (int)point.Index; return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : ""; } } }; BarXAxes = new Axis[] { new Axis { Labels = chartItems.Select(m => m.DisplayLabel).ToArray(), LabelsRotation = -45 } }; BarYAxes = new Axis[] { new Axis { Labeler = value => FormatBytes((long)value) } }; } private static string FormatBytes(long bytes) { if (bytes < 1024) return $"{bytes} B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB"; return $"{bytes / (1024.0 * 1024 * 1024):F2} GB"; } private static void FlattenNode(StorageNode node, int level, List result) { node.IndentLevel = level; result.Add(node); foreach (var child in node.Children) FlattenNode(child, level + 1, result); } private static void OpenFile(string filePath) { try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); } catch { /* ignore -- file may open but this is best-effort */ } } }