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;
private readonly SettingsService? _settingsService;
private readonly IOwnershipElevationService? _ownershipService;
// ── 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,
SettingsService? settingsService = null,
IOwnershipElevationService? ownershipService = null)
: base(logger)
{
_permissionsService = permissionsService;
_siteListService = siteListService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_groupResolver = groupResolver;
_logger = logger;
_settingsService = settingsService;
_ownershipService = ownershipService;
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,
SettingsService? settingsService = null,
IOwnershipElevationService? ownershipService = null)
: base(logger)
{
_permissionsService = permissionsService;
_siteListService = siteListService;
_sessionManager = sessionManager;
_csvExportService = null;
_htmlExportService = null;
_brandingService = brandingService;
_logger = logger;
_settingsService = settingsService;
_ownershipService = ownershipService;
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);
// Read toggle once before the loop (avoids async in exception filter)
var autoOwnership = await IsAutoTakeOwnershipEnabled();
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
};
bool wasElevated = false;
IReadOnlyList siteEntries;
try
{
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
}
catch (Exception ex) when (IsAccessDenied(ex) && _ownershipService != null && autoOwnership)
{
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? url);
var adminProfile = new TenantProfile
{
TenantUrl = adminUrl,
ClientId = _currentProfile?.ClientId ?? string.Empty,
Name = _currentProfile?.Name ?? string.Empty
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
// Retry scan with fresh context
var retryCtx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
siteEntries = await _permissionsService.ScanSiteAsync(retryCtx, scanOptions, progress, ct);
wasElevated = true;
}
if (wasElevated)
allEntries.AddRange(siteEntries.Select(e => e with { WasAutoElevated = true }));
else
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();
}
// ── Auto-ownership helpers ───────────────────────────────────────────────
///
/// Derives the tenant admin URL from a standard tenant URL.
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
///
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
private static bool IsAccessDenied(Exception ex)
{
if (ex is Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) return true;
if (ex is System.Net.WebException webEx && webEx.Response is System.Net.HttpWebResponse resp
&& resp.StatusCode == System.Net.HttpStatusCode.Forbidden) return true;
return false;
}
private async Task IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
// ── 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
}
}
}