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 ILogger _logger; // ── Observable properties ─────────────────────────────────────────────── [ObservableProperty] private string _siteUrl = string.Empty; [ObservableProperty] private bool _includeInherited; [ObservableProperty] private bool _scanFolders = true; [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; } public RelayCommand OpenSitePickerCommand { get; } // ── Multi-site ────────────────────────────────────────────────────────── public ObservableCollection SelectedSites { get; } = new(); /// /// True when the user has manually selected sites via the site picker on this tab. /// Prevents global site changes from overwriting the user's local selection. /// private bool _hasLocalSiteOverride; // ── Dialog factory (set by View layer — keeps Window out of ViewModel) ── /// /// Factory function set by the View layer to open the SitePickerDialog. /// Returns the opened Window; ViewModel calls ShowDialog() on it. /// public Func? OpenSitePickerDialog { get; set; } // ── Current tenant profile (received via WeakReferenceMessenger) ──────── internal TenantProfile? _currentProfile; /// /// Public accessor for the current tenant profile — used by View layer dialog factory. /// public TenantProfile? CurrentProfile => _currentProfile; /// /// Label shown in the UI: "3 site(s) selected" or empty when none are selected. /// public string SitesSelectedLabel => SelectedSites.Count > 0 ? string.Format(Localization.TranslationSource.Instance["perm.sites.selected"], SelectedSites.Count) : string.Empty; // ── Constructors ──────────────────────────────────────────────────────── /// /// Full constructor — used by DI and production code. /// public PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, CsvExportService csvExportService, HtmlExportService htmlExportService, ILogger logger) : base(logger) { _permissionsService = permissionsService; _siteListService = siteListService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker); SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel)); } /// /// Test constructor — omits export services (not needed for unit tests). /// internal PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, ILogger logger) : base(logger) { _permissionsService = permissionsService; _siteListService = siteListService; _sessionManager = sessionManager; _csvExportService = null; _htmlExportService = null; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker); SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel)); } // ── FeatureViewModelBase implementation ───────────────────────────────── protected override void OnGlobalSitesChanged(IReadOnlyList sites) { if (_hasLocalSiteOverride) return; SelectedSites.Clear(); foreach (var site in sites) SelectedSites.Add(site); } 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 = SelectedSites.Count > 0 ? SelectedSites.Select(s => s.Url).ToList() : new List { SiteUrl }; var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (nonEmpty.Count == 0) { StatusMessage = "Enter a site URL or select sites."; return; } 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; _hasLocalSiteOverride = false; Results = new ObservableCollection(); SimplifiedResults = Array.Empty(); Summaries = Array.Empty(); OnPropertyChanged(nameof(ActiveItemsSource)); SiteUrl = string.Empty; SelectedSites.Clear(); OnPropertyChanged(nameof(SitesSelectedLabel)); OnPropertyChanged(nameof(CurrentProfile)); 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 { 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 { await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None); OpenFile(dialog.FileName); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); } } private void ExecuteOpenSitePicker() { if (OpenSitePickerDialog == null) return; var dialog = OpenSitePickerDialog.Invoke(); if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker) { _hasLocalSiteOverride = true; SelectedSites.Clear(); foreach (var site in picker.SelectedUrls) SelectedSites.Add(site); } } 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 } } }