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
}
}
}