---
phase: 13-user-directory-viewmodel
plan: 02
type: execute
wave: 2
depends_on: [13-01]
files_modified:
  - SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
  - SharepointToolbox/App.xaml.cs
  - SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
autonomous: true
requirements:
  - UDIR-01
  - UDIR-02
  - UDIR-03
  - UDIR-04

must_haves:
  truths:
    - "UserAccessAuditViewModel exposes an IsBrowseMode bool toggle property that switches between Search and Browse modes"
    - "When IsBrowseMode is false (default), all existing people-picker behavior works identically (no regression)"
    - "LoadDirectoryCommand calls IGraphUserDirectoryService.GetUsersAsync with includeGuests=true, reports progress via DirectoryLoadStatus, supports cancellation via CancelDirectoryLoadCommand"
    - "DirectoryUsers (ObservableCollection<GraphDirectoryUser>) is populated after load completes"
    - "DirectoryUsersView (ICollectionView) wraps DirectoryUsers with filtering by IncludeGuests toggle and DirectoryFilterText, and default SortDescription on DisplayName"
    - "IncludeGuests toggle filters DirectoryUsersView in-memory by UserType without issuing a new Graph request"
    - "DirectoryFilterText filters by DisplayName, UserPrincipalName, Department, and JobTitle"
    - "Each user row in DirectoryUsersView exposes DisplayName, UserPrincipalName, Department, and JobTitle (via GraphDirectoryUser properties)"
    - "IsLoadingDirectory is true while directory load is in progress, false otherwise"
    - "CancelDirectoryLoadCommand cancels the in-flight directory load and sets IsLoadingDirectory to false"
    - "OnTenantSwitched clears directory state (DirectoryUsers, DirectoryFilterText, IsBrowseMode)"
    - "IGraphUserDirectoryService is injected via constructor and registered in DI"
  artifacts:
    - path: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
      provides: "Directory browse mode with paginated load, progress, cancellation, filtering, sorting"
      contains: "IsBrowseMode"
    - path: "SharepointToolbox/App.xaml.cs"
      provides: "DI wiring for IGraphUserDirectoryService into UserAccessAuditViewModel"
      contains: "IGraphUserDirectoryService"
    - path: "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs"
      provides: "Comprehensive tests for directory browse mode"
      min_lines: 100
  key_links:
    - from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
      to: "SharepointToolbox/Services/IGraphUserDirectoryService.cs"
      via: "constructor injection"
      pattern: "IGraphUserDirectoryService"
    - from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
      to: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs"
      via: "collection element type"
      pattern: "ObservableCollection<GraphDirectoryUser>"
---

<objective>
Add directory browse mode to UserAccessAuditViewModel with paginated load, progress, cancellation, member/guest filtering, text search, and sorting — all fully testable without the View.

Purpose: Implements SC1-SC4 for Phase 13. Administrators get a toggle between the existing people-picker search and a new directory browse mode that loads all tenant users, supports member/guest filtering, and displays Department/JobTitle columns.

Output: Updated ViewModel with directory browse mode, DI registration, and comprehensive test coverage.
</objective>

<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/13-user-directory-viewmodel/13-RESEARCH.md

<interfaces>
<!-- IGraphUserDirectoryService (after 13-01) -->
From SharepointToolbox/Services/IGraphUserDirectoryService.cs:
```csharp
public interface IGraphUserDirectoryService
{
    Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
        string clientId,
        bool includeGuests = false,
        IProgress<int>? progress = null,
        CancellationToken ct = default);
}
```

<!-- GraphDirectoryUser (after 13-01) -->
From SharepointToolbox/Core/Models/GraphDirectoryUser.cs:
```csharp
public record GraphDirectoryUser(
    string DisplayName, string UserPrincipalName,
    string? Mail, string? Department, string? JobTitle, string? UserType);
```

<!-- Current ViewModel constructors -->
From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs:
```csharp
// Full constructor (DI):
public UserAccessAuditViewModel(
    IUserAccessAuditService auditService,
    IGraphUserSearchService graphUserSearchService,
    ISessionManager sessionManager,
    UserAccessCsvExportService csvExportService,
    UserAccessHtmlExportService htmlExportService,
    IBrandingService brandingService,
    ILogger<FeatureViewModelBase> logger)

// Test constructor:
internal UserAccessAuditViewModel(
    IUserAccessAuditService auditService,
    IGraphUserSearchService graphUserSearchService,
    ISessionManager sessionManager,
    ILogger<FeatureViewModelBase> logger,
    IBrandingService? brandingService = null)
```

<!-- FeatureViewModelBase patterns -->
From SharepointToolbox/ViewModels/FeatureViewModelBase.cs:
- IsRunning, StatusMessage, ProgressValue
- RunCommand / CancelCommand (uses own CTS)
- Protected abstract RunOperationAsync(ct, progress)
- OnTenantSwitched(profile) virtual override

<!-- Existing CollectionView pattern in same ViewModel -->
```csharp
var cvs = new CollectionViewSource { Source = Results };
ResultsView = cvs.View;
ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(...));
ResultsView.Filter = FilterPredicate;
// On change: ResultsView.Refresh();
```

<!-- Existing test helper pattern -->
From SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs:
```csharp
private static (UserAccessAuditViewModel vm, Mock<IUserAccessAuditService> auditMock, Mock<IGraphUserSearchService> graphMock)
    CreateViewModel(IReadOnlyList<UserAccessEntry>? auditResult = null)
{
    var mockAudit = new Mock<IUserAccessAuditService>();
    // ... setup
    var vm = new UserAccessAuditViewModel(mockAudit.Object, mockGraph.Object, mockSession.Object,
        NullLogger<FeatureViewModelBase>.Instance);
    vm._currentProfile = new TenantProfile { ... };
    return (vm, mockAudit, mockGraph);
}
```

<!-- DI registration pattern -->
From SharepointToolbox/App.xaml.cs:
```csharp
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
// ...
services.AddTransient<UserAccessAuditViewModel>();
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add IGraphUserDirectoryService to ViewModel constructors and DI</name>
  <files>
    SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs,
    SharepointToolbox/App.xaml.cs
  </files>
  <behavior>
    - Full constructor accepts IGraphUserDirectoryService as a parameter
    - Test constructor accepts IGraphUserDirectoryService? as optional parameter
    - Field _graphUserDirectoryService stores the injected service
    - App.xaml.cs DI resolves IGraphUserDirectoryService for UserAccessAuditViewModel
  </behavior>
  <action>
    1. Add field to ViewModel:
       ```csharp
       private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
       ```

    2. Update full constructor — add `IGraphUserDirectoryService graphUserDirectoryService` parameter after `brandingService`:
       ```csharp
       public UserAccessAuditViewModel(
           IUserAccessAuditService auditService,
           IGraphUserSearchService graphUserSearchService,
           ISessionManager sessionManager,
           UserAccessCsvExportService csvExportService,
           UserAccessHtmlExportService htmlExportService,
           IBrandingService brandingService,
           IGraphUserDirectoryService graphUserDirectoryService,
           ILogger<FeatureViewModelBase> logger)
       ```
       Assign `_graphUserDirectoryService = graphUserDirectoryService;`

    3. Update test constructor — add optional parameter:
       ```csharp
       internal UserAccessAuditViewModel(
           IUserAccessAuditService auditService,
           IGraphUserSearchService graphUserSearchService,
           ISessionManager sessionManager,
           ILogger<FeatureViewModelBase> logger,
           IBrandingService? brandingService = null,
           IGraphUserDirectoryService? graphUserDirectoryService = null)
       ```
       Assign `_graphUserDirectoryService = graphUserDirectoryService;`

    4. In App.xaml.cs, the existing DI registration for `UserAccessAuditViewModel` is Transient and uses constructor injection — since `IGraphUserDirectoryService` is already registered as Transient, DI auto-resolves it. No change needed in App.xaml.cs unless the constructor parameter order requires explicit factory. Verify by building.
  </action>
  <verify>
    <automated>dotnet build --no-restore -warnaserror</automated>
  </verify>
  <done>IGraphUserDirectoryService injected into ViewModel. DI resolves it automatically. Build passes.</done>
</task>

<task type="auto">
  <name>Task 2: Add directory browse mode properties and commands</name>
  <files>
    SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
  </files>
  <behavior>
    - IsBrowseMode (bool) toggle property, default false
    - DirectoryUsers (ObservableCollection of GraphDirectoryUser)
    - DirectoryUsersView (ICollectionView) with filter and default sort on DisplayName
    - IsLoadingDirectory (bool) loading indicator
    - DirectoryLoadStatus (string) for "Loading... X users" display
    - IncludeGuests (bool) toggle for member/guest filtering
    - DirectoryFilterText (string) for text search
    - DirectoryUserCount (int) computed property showing filtered count
    - LoadDirectoryCommand (IAsyncRelayCommand)
    - CancelDirectoryLoadCommand (RelayCommand)
    - Own CancellationTokenSource for directory load (separate from base class CTS)
  </behavior>
  <action>
    1. Add observable properties:
       ```csharp
       [ObservableProperty]
       private bool _isBrowseMode;

       [ObservableProperty]
       private ObservableCollection<GraphDirectoryUser> _directoryUsers = new();

       [ObservableProperty]
       private bool _isLoadingDirectory;

       [ObservableProperty]
       private string _directoryLoadStatus = string.Empty;

       [ObservableProperty]
       private bool _includeGuests;

       [ObservableProperty]
       private string _directoryFilterText = string.Empty;
       ```

    2. Add computed property:
       ```csharp
       public int DirectoryUserCount => DirectoryUsersView?.Cast<object>().Count() ?? 0;
       ```

    3. Add ICollectionView + CTS:
       ```csharp
       public ICollectionView DirectoryUsersView { get; }
       private CancellationTokenSource? _directoryCts;
       ```

    4. Add commands:
       ```csharp
       public IAsyncRelayCommand LoadDirectoryCommand { get; }
       public RelayCommand CancelDirectoryLoadCommand { get; }
       ```

    5. Initialize in BOTH constructors (after existing init):
       ```csharp
       var dirCvs = new CollectionViewSource { Source = DirectoryUsers };
       DirectoryUsersView = dirCvs.View;
       DirectoryUsersView.SortDescriptions.Add(
           new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending));
       DirectoryUsersView.Filter = DirectoryFilterPredicate;

       LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory);
       CancelDirectoryLoadCommand = new RelayCommand(
           () => _directoryCts?.Cancel(),
           () => IsLoadingDirectory);
       ```

    6. Add change handlers:
       ```csharp
       partial void OnIncludeGuestsChanged(bool value)
       {
           DirectoryUsersView.Refresh();
           OnPropertyChanged(nameof(DirectoryUserCount));
       }

       partial void OnDirectoryFilterTextChanged(string value)
       {
           DirectoryUsersView.Refresh();
           OnPropertyChanged(nameof(DirectoryUserCount));
       }

       partial void OnIsLoadingDirectoryChanged(bool value)
       {
           LoadDirectoryCommand.NotifyCanExecuteChanged();
           CancelDirectoryLoadCommand.NotifyCanExecuteChanged();
       }
       ```
  </action>
  <verify>
    <automated>dotnet build --no-restore -warnaserror</automated>
  </verify>
  <done>All directory browse properties, commands, and change handlers exist. Build passes.</done>
</task>

<task type="auto">
  <name>Task 3: Implement LoadDirectoryAsync, CancelDirectoryLoad, and filter predicate</name>
  <files>
    SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
  </files>
  <behavior>
    - LoadDirectoryAsync fetches all users via IGraphUserDirectoryService.GetUsersAsync(clientId, includeGuests: true)
    - Reports progress via DirectoryLoadStatus = $"Loading... {count} users"
    - Populates DirectoryUsers on UI thread
    - Sets IsLoadingDirectory true/false around the operation
    - Handles cancellation (OperationCanceledException → sets status message)
    - Handles errors (Exception → sets status message, logs)
    - CancelDirectoryLoad cancels _directoryCts
    - DirectoryFilterPredicate filters by DisplayName, UPN, Department, JobTitle (case-insensitive contains)
    - When IncludeGuests is false, only shows users where UserType == "Member" (or UserType is null — defensive)
    - When IncludeGuests is true, shows all users
    - OnTenantSwitched clears DirectoryUsers, DirectoryFilterText, resets IsBrowseMode to false
  </behavior>
  <action>
    1. Implement LoadDirectoryAsync:
       ```csharp
       private async Task LoadDirectoryAsync()
       {
           if (_graphUserDirectoryService is null) return;
           var clientId = _currentProfile?.ClientId;
           if (string.IsNullOrEmpty(clientId))
           {
               StatusMessage = "No tenant profile selected. Please connect first.";
               return;
           }

           _directoryCts?.Cancel();
           _directoryCts?.Dispose();
           _directoryCts = new CancellationTokenSource();
           var ct = _directoryCts.Token;

           IsLoadingDirectory = true;
           DirectoryLoadStatus = "Loading...";
           try
           {
               var progress = new Progress<int>(count =>
                   DirectoryLoadStatus = $"Loading... {count} users");

               var users = await _graphUserDirectoryService.GetUsersAsync(
                   clientId, includeGuests: true, progress, ct);

               ct.ThrowIfCancellationRequested();

               var dispatcher = System.Windows.Application.Current?.Dispatcher;
               if (dispatcher != null)
               {
                   await dispatcher.InvokeAsync(() => PopulateDirectory(users));
               }
               else
               {
                   PopulateDirectory(users);
               }

               DirectoryLoadStatus = $"{users.Count} users loaded";
           }
           catch (OperationCanceledException)
           {
               DirectoryLoadStatus = "Load cancelled.";
           }
           catch (Exception ex)
           {
               DirectoryLoadStatus = $"Failed: {ex.Message}";
               _logger.LogError(ex, "Directory load failed.");
           }
           finally
           {
               IsLoadingDirectory = false;
           }
       }

       private void PopulateDirectory(IReadOnlyList<GraphDirectoryUser> users)
       {
           DirectoryUsers.Clear();
           foreach (var u in users)
               DirectoryUsers.Add(u);
           DirectoryUsersView.Refresh();
           OnPropertyChanged(nameof(DirectoryUserCount));
       }
       ```

    2. Implement DirectoryFilterPredicate:
       ```csharp
       private bool DirectoryFilterPredicate(object obj)
       {
           if (obj is not GraphDirectoryUser user) return false;

           // Member/guest filter
           if (!IncludeGuests && !string.Equals(user.UserType, "Member", StringComparison.OrdinalIgnoreCase))
               return false;

           // Text filter
           if (string.IsNullOrWhiteSpace(DirectoryFilterText)) return true;
           var filter = DirectoryFilterText.Trim();
           return user.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase)
               || user.UserPrincipalName.Contains(filter, StringComparison.OrdinalIgnoreCase)
               || (user.Department?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false)
               || (user.JobTitle?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
       }
       ```
       NOTE: When IncludeGuests is false, show users where UserType is "Member". Users with null UserType are excluded (defensive — should not happen with the updated select).

    3. Update OnTenantSwitched — add directory state reset after existing code:
       ```csharp
       // Directory browse mode reset
       _directoryCts?.Cancel();
       _directoryCts?.Dispose();
       _directoryCts = null;
       DirectoryUsers.Clear();
       DirectoryFilterText = string.Empty;
       DirectoryLoadStatus = string.Empty;
       IsBrowseMode = false;
       IsLoadingDirectory = false;
       IncludeGuests = false;
       OnPropertyChanged(nameof(DirectoryUserCount));
       ```
  </action>
  <verify>
    <automated>dotnet build --no-restore -warnaserror</automated>
  </verify>
  <done>LoadDirectoryAsync, filter predicate, and tenant switch cleanup implemented. Build passes.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: Write comprehensive tests for directory browse mode</name>
  <files>
    SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
  </files>
  <behavior>
    - Test 1: IsBrowseMode defaults to false
    - Test 2: DirectoryUsers is empty by default
    - Test 3: LoadDirectoryCommand exists and is not null
    - Test 4: LoadDirectoryAsync populates DirectoryUsers with results from service
    - Test 5: LoadDirectoryAsync reports progress via DirectoryLoadStatus
    - Test 6: LoadDirectoryAsync with no profile sets StatusMessage and returns
    - Test 7: CancelDirectoryLoadCommand cancels in-flight load
    - Test 8: IncludeGuests=false filters out non-Member users in DirectoryUsersView
    - Test 9: IncludeGuests=true shows all users in DirectoryUsersView
    - Test 10: DirectoryFilterText filters by DisplayName
    - Test 11: DirectoryFilterText filters by Department
    - Test 12: DirectoryUsersView default sort is DisplayName ascending
    - Test 13: OnTenantSwitched clears DirectoryUsers and resets IsBrowseMode
    - Test 14: DirectoryUserCount reflects filtered count
    - Test 15: Search mode properties (SearchQuery, SelectedUsers) still work (no regression)
  </behavior>
  <action>
    1. Create `SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs`

    2. Create helper factory similar to existing tests but also including IGraphUserDirectoryService mock:
       ```csharp
       private static (UserAccessAuditViewModel vm,
           Mock<IGraphUserDirectoryService> dirMock,
           Mock<IUserAccessAuditService> auditMock)
           CreateViewModel(IReadOnlyList<GraphDirectoryUser>? directoryResult = null)
       {
           var mockAudit = new Mock<IUserAccessAuditService>();
           var mockGraph = new Mock<IGraphUserSearchService>();
           var mockSession = new Mock<ISessionManager>();
           var mockDir = new Mock<IGraphUserDirectoryService>();
           mockDir.Setup(s => s.GetUsersAsync(
               It.IsAny<string>(),
               It.IsAny<bool>(),
               It.IsAny<IProgress<int>>(),
               It.IsAny<CancellationToken>()))
               .ReturnsAsync(directoryResult ?? Array.Empty<GraphDirectoryUser>());

           var vm = new UserAccessAuditViewModel(
               mockAudit.Object, mockGraph.Object, mockSession.Object,
               NullLogger<FeatureViewModelBase>.Instance,
               graphUserDirectoryService: mockDir.Object);
           vm._currentProfile = new TenantProfile { ... };
           return (vm, mockDir, mockAudit);
       }
       ```

    3. Create test data helpers:
       ```csharp
       private static GraphDirectoryUser MakeMember(string name = "Alice", string dept = "IT") =>
           new(name, $"{name.ToLower()}@contoso.com", null, dept, "Engineer", "Member");

       private static GraphDirectoryUser MakeGuest(string name = "Bob External") =>
           new(name, $"{name.ToLower().Replace(" ", "")}@external.com", null, null, null, "Guest");
       ```

    4. Write all tests. Use `[Trait("Category", "Unit")]`.
       For LoadDirectoryAsync test: call the command via `vm.LoadDirectoryCommand.ExecuteAsync(null)` or expose an internal test method.
       For ICollectionView filtering tests: add users to DirectoryUsers, set IncludeGuests/DirectoryFilterText, then check DirectoryUsersView.Cast<GraphDirectoryUser>().Count().
  </action>
  <verify>
    <automated>dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModelDirectory" --no-build -q</automated>
  </verify>
  <done>15+ tests covering all directory browse mode behavior. All pass.</done>
</task>

</tasks>

<verification>
```bash
dotnet build --no-restore -warnaserror
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModel" --no-build -q
```
Both must pass. Existing UserAccessAuditViewModelTests must still pass (no regression).
</verification>

<success_criteria>
- SC1: IsBrowseMode toggle switches between Search and Browse modes; default is Search; no regression
- SC2: LoadDirectoryCommand fetches all users with progress reporting and cancellation support
- SC3: IncludeGuests toggle filters DirectoryUsersView in-memory without new Graph request
- SC4: DirectoryUsersView exposes DisplayName, UPN, Department, JobTitle; sorted by DisplayName
- IGraphUserDirectoryService injected via DI
- OnTenantSwitched clears all directory state
- 15+ tests covering all behaviors
- Build passes with zero warnings
</success_criteria>

<output>
After completion, create `.planning/phases/13-user-directory-viewmodel/13-02-SUMMARY.md`
</output>
