using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; using System.Windows; using System.Windows.Data; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; namespace SharepointToolbox.ViewModels.Tabs; /// /// ViewModel for the User Access Audit tab. /// Orchestrates people-picker search, site selection, audit execution, /// result grouping/filtering, summary banner, and export commands. /// public partial class UserAccessAuditViewModel : FeatureViewModelBase { private readonly IUserAccessAuditService _auditService; private readonly IGraphUserSearchService _graphUserSearchService; private readonly ISessionManager _sessionManager; private readonly UserAccessCsvExportService? _csvExportService; private readonly UserAccessHtmlExportService? _htmlExportService; private readonly ILogger _logger; // ── People picker debounce ────────────────────────────────────────────── private CancellationTokenSource? _searchCts; // ── Observable properties ─────────────────────────────────────────────── /// Text typed in the people-picker search box. Changes trigger debounced Graph search. [ObservableProperty] private string _searchQuery = string.Empty; /// Autocomplete dropdown items from Graph search results. [ObservableProperty] private ObservableCollection _searchResults = new(); /// Users added for audit. [ObservableProperty] private ObservableCollection _selectedUsers = new(); /// Raw audit output entries. [ObservableProperty] private ObservableCollection _results = new(); /// Real-time text filter applied to ResultsView. [ObservableProperty] private string _filterText = string.Empty; /// When true, results are grouped by user; when false, grouped by site. [ObservableProperty] private bool _isGroupByUser = true; /// Include inherited permissions in scan. [ObservableProperty] private bool _includeInherited; /// Include folders in scan. [ObservableProperty] private bool _scanFolders = true; /// Include subsites in scan. [ObservableProperty] private bool _includeSubsites; /// True while a Graph user search is in progress. [ObservableProperty] private bool _isSearching; // ── Computed summary properties ───────────────────────────────────────── /// Total number of access entries in current results. public int TotalAccessCount => Results.Count; /// Number of distinct sites referenced in current results. public int SitesCount => Results.Select(r => r.SiteUrl).Distinct().Count(); /// Number of high-privilege access entries in current results. public int HighPrivilegeCount => Results.Count(r => r.IsHighPrivilege); /// Human-readable label for the people-picker selection, e.g. "2 user(s) selected". public string SelectedUsersLabel => SelectedUsers.Count > 0 ? $"{SelectedUsers.Count} user(s) selected" : string.Empty; // ── CollectionViewSource (grouping + filtering) ───────────────────────── /// ICollectionView over Results supporting grouping and text filtering. public ICollectionView ResultsView { get; } // ── Commands ──────────────────────────────────────────────────────────── public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } public RelayCommand OpenSitePickerCommand { get; } public RelayCommand AddUserCommand { get; } public RelayCommand RemoveUserCommand { get; } // ── Multi-site ────────────────────────────────────────────────────────── public ObservableCollection SelectedSites { get; } = new(); /// /// True when the user has manually selected sites via the site picker. /// Prevents global site changes from overwriting the local selection. /// private bool _hasLocalSiteOverride; // ── Dialog factory ────────────────────────────────────────────────────── /// Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel. public Func? OpenSitePickerDialog { get; set; } // ── Current tenant profile ────────────────────────────────────────────── 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 ? $"{SelectedSites.Count} site(s) selected" : string.Empty; // ── Constructors ──────────────────────────────────────────────────────── /// Full constructor — used by DI and production code. public UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, UserAccessCsvExportService csvExportService, UserAccessHtmlExportService htmlExportService, ILogger logger) : base(logger) { _auditService = auditService; _graphUserSearchService = graphUserSearchService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker); AddUserCommand = new RelayCommand(ExecuteAddUser); RemoveUserCommand = new RelayCommand(ExecuteRemoveUser); SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel)); SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel)); var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ApplyGrouping(); } /// Test constructor — omits export services (not needed for unit tests). internal UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, ILogger logger) : base(logger) { _auditService = auditService; _graphUserSearchService = graphUserSearchService; _sessionManager = sessionManager; _csvExportService = null; _htmlExportService = null; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker); AddUserCommand = new RelayCommand(ExecuteAddUser); RemoveUserCommand = new RelayCommand(ExecuteRemoveUser); SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel)); SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel)); var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ApplyGrouping(); } // ── FeatureViewModelBase implementation ───────────────────────────────── protected override void OnGlobalSitesChanged(IReadOnlyList sites) { if (_hasLocalSiteOverride) return; SelectedSites.Clear(); foreach (var site in sites) SelectedSites.Add(site); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { if (SelectedUsers.Count == 0) { StatusMessage = "Add at least one user to audit."; return; } var effectiveSites = SelectedSites.Count > 0 ? SelectedSites.ToList() : GlobalSites.ToList(); if (effectiveSites.Count == 0) { StatusMessage = "Select at least one site to scan."; return; } var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList(); var scanOptions = new ScanOptions( IncludeInherited: IncludeInherited, ScanFolders: ScanFolders, FolderDepth: 1, IncludeSubsites: IncludeSubsites); var entries = await _auditService.AuditUsersAsync( _sessionManager, userLogins, effectiveSites, scanOptions, progress, ct); // Update Results on the UI thread var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { await dispatcher.InvokeAsync(() => { Results = new ObservableCollection(entries); NotifySummaryProperties(); }); } else { Results = new ObservableCollection(entries); NotifySummaryProperties(); } ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } // ── Tenant switching ───────────────────────────────────────────────────── protected override void OnTenantSwitched(TenantProfile profile) { _currentProfile = profile; _hasLocalSiteOverride = false; Results = new ObservableCollection(); SelectedUsers.Clear(); SearchQuery = string.Empty; SearchResults.Clear(); SelectedSites.Clear(); FilterText = string.Empty; OnPropertyChanged(nameof(SitesSelectedLabel)); OnPropertyChanged(nameof(CurrentProfile)); NotifySummaryProperties(); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } // ── Observable property change handlers ───────────────────────────────── partial void OnSearchQueryChanged(string value) { // Cancel any in-flight search _searchCts?.Cancel(); _searchCts?.Dispose(); _searchCts = new CancellationTokenSource(); var ct = _searchCts.Token; _ = DebounceSearchAsync(value, ct); } partial void OnFilterTextChanged(string value) { ResultsView.Refresh(); } partial void OnIsGroupByUserChanged(bool value) { ApplyGrouping(); } partial void OnResultsChanged(ObservableCollection value) { // Rebind CollectionViewSource when the collection reference changes if (ResultsView is CollectionView cv && cv.SourceCollection != value) { // CollectionViewSource.View is already live-bound in constructor; // for a new collection reference we need to refresh grouping/filter ApplyGrouping(); ResultsView.Filter = FilterPredicate; ResultsView.Refresh(); } NotifySummaryProperties(); } // ── 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 user access audit to CSV", Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", DefaultExt = "csv", FileName = "user-access-audit" }; if (dialog.ShowDialog() != true) return; try { await _csvExportService.WriteSingleFileAsync(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 user access audit to HTML", Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*", DefaultExt = "html", FileName = "user-access-audit" }; 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 void ExecuteAddUser(GraphUserResult? user) { if (user == null) return; if (!SelectedUsers.Any(u => u.UserPrincipalName == user.UserPrincipalName)) { SelectedUsers.Add(user); } // Clear search after adding SearchQuery = string.Empty; SearchResults.Clear(); } private void ExecuteRemoveUser(GraphUserResult? user) { if (user == null) return; SelectedUsers.Remove(user); } private async Task DebounceSearchAsync(string query, CancellationToken ct) { try { await Task.Delay(300, ct); ct.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(query) || query.Length < 2) { SearchResults.Clear(); return; } IsSearching = true; try { var clientId = _currentProfile?.ClientId ?? string.Empty; var results = await _graphUserSearchService.SearchUsersAsync(clientId, query, 10, ct); ct.ThrowIfCancellationRequested(); var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { await dispatcher.InvokeAsync(() => { SearchResults.Clear(); foreach (var r in results) SearchResults.Add(r); }); } else { SearchResults.Clear(); foreach (var r in results) SearchResults.Add(r); } } finally { IsSearching = false; } } catch (OperationCanceledException) { // Keystroke cancelled this search — expected, ignore } catch (Exception ex) { _logger.LogError(ex, "People picker search failed for query '{Query}'.", query); IsSearching = false; } } private void ApplyGrouping() { ResultsView.GroupDescriptions.Clear(); if (IsGroupByUser) ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(UserAccessEntry.UserLogin))); else ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(UserAccessEntry.SiteUrl))); ResultsView.Filter = FilterPredicate; } private bool FilterPredicate(object obj) { if (string.IsNullOrWhiteSpace(FilterText)) return true; if (obj is not UserAccessEntry entry) return false; var filter = FilterText.Trim(); return entry.UserDisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase) || entry.UserLogin.Contains(filter, StringComparison.OrdinalIgnoreCase) || entry.SiteTitle.Contains(filter, StringComparison.OrdinalIgnoreCase) || entry.SiteUrl.Contains(filter, StringComparison.OrdinalIgnoreCase) || entry.ObjectTitle.Contains(filter, StringComparison.OrdinalIgnoreCase) || entry.PermissionLevel.Contains(filter, StringComparison.OrdinalIgnoreCase); } private void NotifySummaryProperties() { OnPropertyChanged(nameof(TotalAccessCount)); OnPropertyChanged(nameof(SitesCount)); OnPropertyChanged(nameof(HighPrivilegeCount)); } 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 } } }