using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Messages; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Services.Export; namespace SharepointToolbox.ViewModels.Tabs; /// /// ViewModel for the Permissions tab. /// Orchestrates permission scanning across one or multiple SharePoint sites /// and exports results to CSV or HTML. /// public partial class PermissionsViewModel : FeatureViewModelBase { private readonly IPermissionsService _permissionsService; private readonly ISiteListService _siteListService; private readonly ISessionManager _sessionManager; private readonly CsvExportService? _csvExportService; private readonly HtmlExportService? _htmlExportService; private readonly IBrandingService? _brandingService; private readonly ISharePointGroupResolver? _groupResolver; private readonly ILogger _logger; // ── Observable properties ─────────────────────────────────────────────── [ObservableProperty] private bool _includeInherited; [ObservableProperty] private bool _scanFolders = true; /// Placeholder for the Export Options toggle — no-op in PermissionsViewModel (reserved for future use). [ObservableProperty] private bool _mergePermissions; [ObservableProperty] private bool _includeSubsites; [ObservableProperty] private int _folderDepth = 1; /// /// When true, sets FolderDepth to 999 (scan all levels). /// public bool IsMaxDepth { get => FolderDepth >= 999; set { if (value) FolderDepth = 999; else if (FolderDepth >= 999) FolderDepth = 1; OnPropertyChanged(); } } [ObservableProperty] private ObservableCollection _results = new(); /// /// When true, displays simplified plain-language labels instead of raw SharePoint role names. /// Toggling does not re-run the scan. /// [ObservableProperty] private bool _isSimplifiedMode; /// /// When true, shows individual item-level rows (detailed view). /// When false, shows only summary rows grouped by risk level (simple view). /// Only meaningful when IsSimplifiedMode is true. /// [ObservableProperty] private bool _isDetailView = true; /// /// Simplified wrappers computed from Results. Rebuilt when Results changes. /// private IReadOnlyList _simplifiedResults = Array.Empty(); public IReadOnlyList SimplifiedResults { get => _simplifiedResults; private set => SetProperty(ref _simplifiedResults, value); } /// /// Summary counts grouped by risk level. Rebuilt when SimplifiedResults changes. /// private IReadOnlyList _summaries = Array.Empty(); public IReadOnlyList Summaries { get => _summaries; private set => SetProperty(ref _summaries, value); } /// /// The collection the DataGrid actually binds to. Returns: /// - Results (raw) when simplified mode is OFF /// - SimplifiedResults when simplified mode is ON and detail view is ON /// - (View handles summary display separately via Summaries property) /// public object ActiveItemsSource => IsSimplifiedMode ? (object)SimplifiedResults : Results; partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth)); // ── Commands ──────────────────────────────────────────────────────────── public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } // ── Current tenant profile (received via WeakReferenceMessenger) ──────── internal TenantProfile? _currentProfile; // ── Constructors ──────────────────────────────────────────────────────── /// /// Full constructor — used by DI and production code. /// public PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, CsvExportService csvExportService, HtmlExportService htmlExportService, IBrandingService brandingService, ILogger logger, ISharePointGroupResolver? groupResolver = null) : base(logger) { _permissionsService = permissionsService; _siteListService = siteListService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _brandingService = brandingService; _groupResolver = groupResolver; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } /// /// Test constructor — omits export services (not needed for unit tests). /// internal PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, ILogger logger, IBrandingService? brandingService = null) : base(logger) { _permissionsService = permissionsService; _siteListService = siteListService; _sessionManager = sessionManager; _csvExportService = null; _htmlExportService = null; _brandingService = brandingService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } // ── FeatureViewModelBase implementation ───────────────────────────────── partial void OnIsSimplifiedModeChanged(bool value) { if (value && Results.Count > 0) RebuildSimplifiedData(); OnPropertyChanged(nameof(ActiveItemsSource)); } partial void OnIsDetailViewChanged(bool value) { OnPropertyChanged(nameof(ActiveItemsSource)); } /// /// Recomputes SimplifiedResults and Summaries from the current Results collection. /// Called when Results changes or when simplified mode is toggled on. /// private void RebuildSimplifiedData() { SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results); Summaries = PermissionSummaryBuilder.Build(SimplifiedResults); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { 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 allEntries = new List(); var scanOptions = new ScanOptions( IncludeInherited: IncludeInherited, ScanFolders: ScanFolders, FolderDepth: FolderDepth, IncludeSubsites: IncludeSubsites); int i = 0; foreach (var url in nonEmpty) { ct.ThrowIfCancellationRequested(); progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}...")); var profile = new TenantProfile { TenantUrl = url, ClientId = _currentProfile?.ClientId ?? string.Empty, Name = _currentProfile?.Name ?? string.Empty }; var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct); allEntries.AddRange(siteEntries); i++; } // Update Results on the UI thread (no-op if no Dispatcher in tests) var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { await dispatcher.InvokeAsync(() => { Results = new ObservableCollection(allEntries); if (IsSimplifiedMode) RebuildSimplifiedData(); OnPropertyChanged(nameof(ActiveItemsSource)); }); } else { Results = new ObservableCollection(allEntries); if (IsSimplifiedMode) RebuildSimplifiedData(); OnPropertyChanged(nameof(ActiveItemsSource)); } ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } // ── Tenant switching ───────────────────────────────────────────────────── protected override void OnTenantSwitched(TenantProfile profile) { _currentProfile = profile; Results = new ObservableCollection(); SimplifiedResults = Array.Empty(); Summaries = Array.Empty(); OnPropertyChanged(nameof(ActiveItemsSource)); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } // ── Internal helpers ───────────────────────────────────────────────────── /// Sets the current tenant profile (for test injection). internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; /// Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo). internal Task TestRunOperationAsync(CancellationToken ct, IProgress progress) => RunOperationAsync(ct, progress); // ── Command implementations ─────────────────────────────────────────────── private bool CanExport() => Results.Count > 0; private async Task ExportCsvAsync() { if (_csvExportService == null || Results.Count == 0) return; var dialog = new SaveFileDialog { Title = "Export permissions to CSV", Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", DefaultExt = "csv", FileName = "permissions" }; if (dialog.ShowDialog() != true) return; try { if (IsSimplifiedMode && SimplifiedResults.Count > 0) await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None); else await _csvExportService.WriteAsync(Results, 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 (_htmlExportService == null || Results.Count == 0) return; var dialog = new SaveFileDialog { Title = "Export permissions to HTML", Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*", DefaultExt = "html", FileName = "permissions" }; 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); } IReadOnlyDictionary>? groupMembers = null; if (_groupResolver != null && Results.Count > 0) { var groupNames = Results .Where(r => r.PrincipalType == "SharePointGroup") .SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries)) .Select(n => n.Trim()) .Where(n => n.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (groupNames.Count > 0 && _currentProfile != null) { try { var ctx = await _sessionManager.GetOrCreateContextAsync( _currentProfile, CancellationToken.None); groupMembers = await _groupResolver.ResolveGroupsAsync( ctx, _currentProfile.ClientId, groupNames, CancellationToken.None); } catch (Exception ex) { _logger.LogWarning(ex, "Group resolution failed — exporting without member expansion."); } } } if (IsSimplifiedMode && SimplifiedResults.Count > 0) await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers); else await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers); OpenFile(dialog.FileName); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); } } private static void OpenFile(string filePath) { try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); } catch { // Non-critical: file was written successfully, just can't auto-open } } }