Public/UnifiedLogs/Search-UnifiedAuditLogCustom.ps1

<#
.SYNOPSIS
    Search-UnifiedAuditLogCustom is an enhanced wrapper around the native Search-UnifiedAuditLog cmdlet, providing additional features such as a user-friendly GUI for constructing search queries, simplified output formatting, and integration with the Microsoft 365 audit operations catalog.
 
    .DESCRIPTION
    This function allows administrators and security professionals to perform more efficient and targeted searches of the Microsoft 365 Unified Audit Log. It includes a helper GUI that enables users to easily select operations from the official Microsoft Learn catalog, specify date ranges, user filters, and other parameters without needing to remember complex cmdlet syntax.
    The output can be returned in a simplified format that flattens nested JSON structures for easier analysis and export. This is particularly useful for security investigations, compliance audits, and general monitoring of activities across Microsoft 365 services.
 
    .PARAMETER StartDate
    The start date and time for the audit log search. If not specified, defaults to 24 hours ago.
 
    .PARAMETER EndDate
    The end date and time for the audit log search. If not specified, defaults to the current date and time.
 
    .PARAMETER Operations
    An array of operation names to filter the search. These can be selected from the helper GUI, which loads the catalog of operations from Microsoft Learn. Users can also enter raw cmdlet names (e.g., New-TransportRule) to filter by specific operations.
 
    .PARAMETER UserIds
    An array of user identifiers (e.g., email addresses) to filter the search results by specific users.
 
    .PARAMETER FreeText
    A free text string to search for within the audit log records.
 
    .PARAMETER ResultSize
    The maximum number of results to return from the search. Defaults to 5000.
 
    .PARAMETER SimpleView
    When specified, the output will be processed to flatten nested JSON structures into a simpler format. This is ideal for exporting to CSV or performing quick analysis without dealing with complex nested properties.
 
    .PARAMETER HelperGUI
    When specified, opens a graphical user interface to assist in constructing the search query with user-friendly controls and operation selection.
    The operations list is populated from the Microsoft Learn catalog of audit log activities, allowing users to easily find and select relevant operations for their search.
    Make sure to have access to the Microsoft Learn page for audit log activities to load the operations catalog successfully (https://learn.microsoft.com/en-us/purview/audit-log-activities).
 
    .PARAMETER ChunkDays
    Size (in days) of each sub-window used to split the StartDate/EndDate range. Defaults to 7.
    The function loops over the full range one chunk at a time and uses session pagination inside each chunk,
    which avoids the server-side 'Search duration too long' error encountered on very wide windows.
    Lower this value (e.g. 1 or 3) if a chunk itself returns the 'too long' error.
 
    .EXAMPLE
    Search-UnifiedAuditLogCustom -StartDate (Get-Date).AddDays(-7) -EndDate (Get-Date) -Operations "UserLoggedIn", "FileAccessed" -SimpleView
 
    This example searches the Unified Audit Log for "UserLoggedIn" and "FileAccessed" operations that occurred in the last 7 days, and returns the results in a simplified format.
 
    .EXAMPLE
 
#>


function Search-UnifiedAuditLogCustom {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [datetime]$StartDate,

        [Parameter(Mandatory = $false)]
        [datetime]$EndDate,

        [Parameter(Mandatory = $false)]
        [string[]]$Operations,

        [Parameter(Mandatory = $false)]
        [string[]]$UserIds,

        [Parameter(Mandatory = $false)]
        [string]$FreeText,

        [Parameter(Mandatory = $false)]
        [int]$ResultSize = 5000,

        [Parameter(Mandatory = $false)]
        [Alias('Simple')]
        [switch]$SimpleView,

        [Parameter(Mandatory = $false)]
        [switch]$HelperGUI,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 90)]
        [int]$ChunkDays = 7
    )

    # function from HAWK module
    function Get-SimpleUnifiedAuditLog {
        <#
    .SYNOPSIS
        Flattens nested Microsoft 365 Unified Audit Log records into a simplified format.
 
    .DESCRIPTION
        This function processes Microsoft 365 Unified Audit Log records by converting nested JSON data
        (stored in the AuditData property) into a flat structure suitable for analysis and export.
        It handles complex nested objects, arrays, and special cases like parameter collections.
 
        The function:
        - Preserves base record properties
        - Flattens nested JSON structures
        - Provides special handling for Parameters collections
        - Creates human-readable command reconstructions
        - Supports type preservation for data analysis
 
    .PARAMETER Record
        A PowerShell object representing a unified audit log record. Typically, this is the output
        from Search-UnifiedAuditLog and should contain both base properties and an AuditData
        property containing a JSON string of additional audit information.
 
    .PARAMETER PreserveTypes
        When specified, maintains the original data types of values instead of converting them
        to strings. This is useful when the output will be used for further PowerShell processing
        rather than export to CSV/JSON.
 
    .EXAMPLE
        $auditLogs = Search-UnifiedAuditLog -StartDate $startDate -EndDate $endDate -RecordType ExchangeAdmin
 
        $auditLogs | Get-SimpleUnifiedAuditLog | Export-Csv -Path "AuditLogs.csv" -NoTypeInformation
 
        Processes Exchange admin audit logs and exports them to CSV with all nested properties flattened.
 
    .EXAMPLE
        $userChanges = Search-UnifiedAuditLog -UserIds user@domain.com -Operations "Add-*"
 
        $userChanges | Get-SimpleUnifiedAuditLog -PreserveTypes |
            Where-Object { $_.ResultStatus -eq $true } |
            Select-Object CreationTime, Operation, FullCommand
 
        Gets all "Add" operations for a specific user, preserves data types, filters for successful operations,
        and selects specific columns.
 
    .OUTPUTS
        Collection of PSCustomObjects with flattened properties from both the base record and AuditData.
        Properties include:
        - All base record properties (RecordType, CreationDate, etc.)
        - Flattened nested objects with property names using dot notation
        - Individual parameters as Param_* properties
        - ParameterString containing all parameters in a readable format
        - FullCommand showing reconstructed PowerShell command (when applicable)
 
    .NOTES
        Author: Jonathan Butler
        Version: 2.0
        Development Date: December 2024
 
        The function is designed to handle any RecordType from the Unified Audit Log and will
        automatically adapt to changes in the audit log schema. Special handling is implemented
        for common patterns like Parameters collections while maintaining flexibility for
        other nested structures.
    #>

        [CmdletBinding()]
        param (
            [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
            [PSObject]$Record,

            [Parameter(Mandatory = $false)]
            [switch]$PreserveTypes
        )

        begin {
            [System.Collections.Generic.List[PSCustomObject]]$resultsArray = @()
        }

        process {
            try {
                $baseProperties = $Record | Select-Object -Property * -ExcludeProperty AuditData

                $auditData = $Record.AuditData | ConvertFrom-Json
                if ($auditData) {
                    $flatAuditData = ConvertTo-FlatObject -InputObject $auditData -PreserveTypes:$PreserveTypes

                    $combinedProperties = @{}
                    $baseProperties.PSObject.Properties | ForEach-Object { $combinedProperties[$_.Name] = $_.Value }
                    $flatAuditData.GetEnumerator() | ForEach-Object { $combinedProperties[$_.Key] = $_.Value }

                    $null = $resultsArray.Add([PSCustomObject]$combinedProperties)
                }
            }
            catch {
                Write-Warning "Error processing record: $_"
                $errorProperties = @{
                    RecordType   = $Record.RecordType
                    CreationDate = Get-Date
                    Error        = $_.Exception.Message
                    Record       = $Record
                }
                $null = $resultsArray.Add([PSCustomObject]$errorProperties)
            }
        }

        end {
            $orderedProperties = @(
                'CreationTime',
                'Workload',
                'RecordType',
                'Operation',
                'ResultStatus',
                'ClientIP',
                'ClientIPAddress',
                'UserId',
                'MailboxOwnerUPN',
                # Send / mail item properties (only present on Exchange RecordType=2 entries)
                'Item_Subject',
                'Item_Recipients',
                'Item_RecipientsCount',
                'Item_SizeInBytes',
                'Item_ParentFolder_Path',
                'Item_InternetMessageId',
                'SaveToSentItems',
                # Identifiers / context
                'Id',
                'OrganizationId',
                'UserType',
                'UserKey',
                'ObjectId',
                'Scope',
                'AppAccessContext',
                'ClientInfoString',
                'ActorInfoString'
            )

            $orderedResults = $resultsArray | ForEach-Object {
                $orderedObject = [ordered]@{}

                foreach ($prop in $orderedProperties) {
                    if ($_.PSObject.Properties.Name -contains $prop) {
                        $orderedObject[$prop] = $_.$prop
                    }
                }

                if ($_.PSObject.Properties.Name -contains 'ParameterString') {
                    $orderedObject['ParameterString'] = $_.ParameterString

                    $_.PSObject.Properties |
                    Where-Object { $_.Name -like 'Param_*' } |
                    Sort-Object -Property Name |
                    ForEach-Object {
                        $orderedObject[$_.Name] = $_.Value
                    }
                }

                $_.PSObject.Properties |
                Where-Object {
                    $_.Name -notin $orderedProperties -and
                    $_.Name -ne 'ParameterString' -and
                    $_.Name -notlike 'Param_*'
                } |
                ForEach-Object {
                    $orderedObject[$_.Name] = $_.Value
                }

                [PSCustomObject]$orderedObject
            }

            return $orderedResults
        }
    }

    if ($HelperGUI) {
        Invoke-SearchUnifiedAuditLogCustomHelperGUI
        return
    }

    if (-not $StartDate) {
        $StartDate = (Get-Date).AddDays(-1)
    }

    if (-not $EndDate) {
        $EndDate = Get-Date
    }

    $searchParams = @{
        StartDate  = $StartDate
        EndDate    = $EndDate
        ResultSize = $ResultSize
    }

    if ($Operations) {
        $searchParams['Operations'] = $Operations
    }

    if ($UserIds) {
        $searchParams['UserIds'] = $UserIds
    }

    if ($FreeText) {
        $searchParams['FreeText'] = $FreeText
    }

    # Chunk the date range to avoid the server-side 'Search duration too long' error on wide windows.
    # Within each chunk we use session pagination (-SessionId + -SessionCommand ReturnLargeSet) so we can
    # gather more than 5000 records per sub-window. The global -ResultSize is enforced as the overall cap.
    [System.Collections.Generic.List[object]]$auditLogs = @()
    $chunkSpan = New-TimeSpan -Days $ChunkDays
    $cursor    = $StartDate
    $chunkIdx  = 0
    $totalSeconds = [math]::Max(1, ($EndDate - $StartDate).TotalSeconds)

    while ($cursor -lt $EndDate -and $auditLogs.Count -lt $ResultSize) {
        $chunkIdx++
        $chunkEnd = $cursor + $chunkSpan
        if ($chunkEnd -gt $EndDate) { $chunkEnd = $EndDate }

        $percent = [math]::Min(100, [math]::Max(0, [int]((($cursor - $StartDate).TotalSeconds / $totalSeconds) * 100)))
        Write-Progress -Activity 'Searching Unified Audit Log' -Status "Window $($cursor.ToString('yyyy-MM-dd')) -> $($chunkEnd.ToString('yyyy-MM-dd')) | $($auditLogs.Count)/$ResultSize records" -PercentComplete $percent

        Write-Verbose "Chunk $chunkIdx : $($cursor.ToString('yyyy-MM-dd HH:mm')) -> $($chunkEnd.ToString('yyyy-MM-dd HH:mm'))"

        $chunkParams = @{}
        foreach ($key in $searchParams.Keys) { $chunkParams[$key] = $searchParams[$key] }
        $chunkParams['StartDate'] = $cursor
        $chunkParams['EndDate']   = $chunkEnd
        # Per-call page size is capped server-side at 5000.
        $chunkParams['ResultSize'] = 5000

        $sessionId = [Guid]::NewGuid().ToString()
        $pageIndex = 0
        do {
            $pageIndex++
            try {
                $page = Search-UnifiedAuditLog @chunkParams -SessionId $sessionId -SessionCommand ReturnLargeSet -ErrorAction Stop
            }
            catch {
                Write-Warning "Search-UnifiedAuditLog failed on chunk $chunkIdx page $pageIndex ($($cursor.ToString('yyyy-MM-dd HH:mm')) -> $($chunkEnd.ToString('yyyy-MM-dd HH:mm'))): $($_.Exception.Message). Try a smaller -ChunkDays value (current: $ChunkDays)."
                $page = $null
            }

            if ($page) {
                foreach ($entry in $page) {
                    if ($auditLogs.Count -ge $ResultSize) { break }
                    $auditLogs.Add($entry)
                }
                Write-Verbose "Chunk $chunkIdx page $pageIndex : +$($page.Count) records (total $($auditLogs.Count)/$ResultSize)"
            }
        } while ($page -and $page.Count -gt 0 -and $auditLogs.Count -lt $ResultSize)

        # Advance the cursor by one second past chunkEnd to avoid re-fetching the boundary record.
        $cursor = $chunkEnd.AddSeconds(1)
    }

    Write-Progress -Activity 'Searching Unified Audit Log' -Completed

    if ($auditLogs.Count -eq 0) {
        Write-Warning 'Search-UnifiedAuditLog returned no records for the specified filters and time window.'
        return
    }

    if ($SimpleView) {
        return $auditLogs | Get-SimpleUnifiedAuditLog
    }

    return $auditLogs
}

function Invoke-SearchUnifiedAuditLogCustomHelperGUI {
    Add-Type -AssemblyName PresentationFramework
    Add-Type -AssemblyName PresentationCore
    Add-Type -AssemblyName WindowsBase

    $moduleVersion = $null
    $loadedModule = Get-Module -Name 'PS365' -ErrorAction SilentlyContinue
    if ($loadedModule -and $loadedModule.Version) {
        $moduleVersion = "v$($loadedModule.Version)"
    }
    else {
        try {
            $manifestPath = Join-Path $PSScriptRoot '..\..\PS365.psd1'
            $manifest = Import-PowerShellDataFile -Path $manifestPath -ErrorAction Stop
            if ($manifest.ModuleVersion) { $moduleVersion = "v$($manifest.ModuleVersion)" }
        }
        catch {
            Write-Verbose "Could not read module version: $($_.Exception.Message)"
        }
    }

    $splashLogoPath = Join-Path $PSScriptRoot '..\..\Private\Assets\Search-UnifiedAuditLogCustom.png'
    $splash = Show-Splash `
        -Title 'Search-UnifiedAuditLogCustom' `
        -Subtitle 'Audit log search helper' `
        -InitialMessage 'Initializing...' `
        -Version $moduleVersion `
        -LogoPath $splashLogoPath

    [System.Collections.Generic.List[PSCustomObject]]$operationChoices = @()
    $operationLookupByDisplay = @{}

    # Operations catalog is loaded from Microsoft Learn with a local cache and an offline fallback
    # (fresh cache > live refresh > stale cache > bundled seed). See Get-UnifiedAuditLogOperationCatalog.
    if ($splash) { $splash.Update('Loading audit operations catalog...') }
    $catalog = Get-UnifiedAuditLogOperationCatalog
    if ($catalog -and $catalog.Operations) {
        foreach ($entry in $catalog.Operations) {
            $operationChoices.Add($entry)
        }
    }

    if ($splash) {
        $catalogSource = if ($catalog) { $catalog.Source } else { 'None' }
        switch ($catalogSource) {
            'Live' { $splash.Update("Loaded $($operationChoices.Count) operations from Microsoft Learn") }
            'Cache' { $splash.Update("Microsoft Learn unavailable or cached - loaded $($operationChoices.Count) operations from local cache") }
            'Seed' { $splash.Update("Microsoft Learn unavailable - loaded $($operationChoices.Count) operations from bundled list") }
            default { $splash.Update('Could not load operations catalog (you can still type raw cmdlets)') }
        }
    }

    $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Search-UnifiedAuditLogCustom Helper"
        Width="1200"
        MinWidth="1100" MinHeight="400" MaxHeight="900"
        SizeToContent="Height"
        WindowStartupLocation="CenterScreen"
        Background="#FAFAFA"
        FontFamily="Segoe UI"
        TextOptions.TextFormattingMode="Display">
    <Window.Resources>
        <SolidColorBrush x:Key="AccentBrush" Color="#0F6CBD"/>
        <SolidColorBrush x:Key="AccentHoverBrush" Color="#115EA3"/>
        <SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
        <SolidColorBrush x:Key="StrokeBrush" Color="#E1E1E1"/>
        <SolidColorBrush x:Key="StrokeStrongBrush" Color="#C7C7C7"/>
        <SolidColorBrush x:Key="TextPrimaryBrush" Color="#242424"/>
        <SolidColorBrush x:Key="TextSecondaryBrush" Color="#616161"/>
        <SolidColorBrush x:Key="TextHintBrush" Color="#8A8A8A"/>
        <SolidColorBrush x:Key="ChromeBrush" Color="#F5F5F5"/>
        <SolidColorBrush x:Key="ChromeHoverBrush" Color="#EBEBEB"/>
 
        <Style x:Key="SectionTitleStyle" TargetType="TextBlock">
            <Setter Property="FontSize" Value="13"/>
            <Setter Property="FontWeight" Value="SemiBold"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="Margin" Value="0,0,0,4"/>
        </Style>
        <Style x:Key="FieldLabelStyle" TargetType="TextBlock">
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="FontWeight" Value="SemiBold"/>
            <Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
            <Setter Property="Margin" Value="0,0,0,2"/>
        </Style>
        <Style x:Key="HelperTextStyle" TargetType="TextBlock">
            <Setter Property="FontSize" Value="11"/>
            <Setter Property="Foreground" Value="{StaticResource TextHintBrush}"/>
            <Setter Property="Margin" Value="0,2,0,0"/>
            <Setter Property="TextWrapping" Value="Wrap"/>
        </Style>
 
        <Style x:Key="CardStyle" TargetType="Border">
            <Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
            <Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="CornerRadius" Value="4"/>
            <Setter Property="Padding" Value="10"/>
        </Style>
 
        <Style TargetType="TextBox">
            <Setter Property="Padding" Value="6,3"/>
            <Setter Property="BorderBrush" Value="{StaticResource StrokeStrongBrush}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Background" Value="White"/>
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="MinHeight" Value="24"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
        </Style>
        <Style x:Key="CompactInputStyle" TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
            <Setter Property="Height" Value="24"/>
        </Style>
        <Style TargetType="CheckBox">
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
        </Style>
        <SolidColorBrush x:Key="AccentSoftBrush" Color="#EAF2FB"/>
 
        <Style TargetType="Calendar">
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="Background" Value="White"/>
            <Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
        </Style>
 
        <Style TargetType="CalendarDayButton">
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="MinWidth" Value="28"/>
            <Setter Property="MinHeight" Value="28"/>
            <Setter Property="Background" Value="Transparent"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="BorderThickness" Value="0"/>
            <Setter Property="Margin" Value="1"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="CalendarDayButton">
                        <Border x:Name="bg" Background="{TemplateBinding Background}" CornerRadius="4">
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="bg" Property="Background" Value="{StaticResource AccentSoftBrush}"/>
                            </Trigger>
                            <Trigger Property="IsToday" Value="True">
                                <Setter Property="FontWeight" Value="SemiBold"/>
                                <Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
                            </Trigger>
                            <Trigger Property="IsSelected" Value="True">
                                <Setter TargetName="bg" Property="Background" Value="{StaticResource AccentBrush}"/>
                                <Setter Property="Foreground" Value="White"/>
                            </Trigger>
                            <Trigger Property="IsInactive" Value="True">
                                <Setter Property="Foreground" Value="{StaticResource TextHintBrush}"/>
                            </Trigger>
                            <Trigger Property="IsBlackedOut" Value="True">
                                <Setter Property="Opacity" Value="0.35"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
 
        <Style TargetType="CalendarButton">
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="MinWidth" Value="56"/>
            <Setter Property="MinHeight" Value="36"/>
            <Setter Property="Background" Value="Transparent"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="BorderThickness" Value="0"/>
            <Setter Property="Margin" Value="2"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="CalendarButton">
                        <Border x:Name="bg" Background="{TemplateBinding Background}" CornerRadius="4">
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="bg" Property="Background" Value="{StaticResource AccentSoftBrush}"/>
                            </Trigger>
                            <Trigger Property="IsSelected" Value="True">
                                <Setter TargetName="bg" Property="Background" Value="{StaticResource AccentBrush}"/>
                                <Setter Property="Foreground" Value="White"/>
                            </Trigger>
                            <Trigger Property="HasSelectedDays" Value="True">
                                <Setter Property="FontWeight" Value="SemiBold"/>
                            </Trigger>
                            <Trigger Property="IsInactive" Value="True">
                                <Setter Property="Foreground" Value="{StaticResource TextHintBrush}"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
 
        <Style TargetType="CalendarItem">
            <Setter Property="Margin" Value="0"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="CalendarItem">
                        <ControlTemplate.Resources>
                            <DataTemplate x:Key="{x:Static CalendarItem.DayTitleTemplateResourceKey}">
                                <TextBlock Text="{Binding}" FontSize="11" FontWeight="SemiBold"
                                           Foreground="{StaticResource TextSecondaryBrush}"
                                           HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,4"/>
                            </DataTemplate>
                        </ControlTemplate.Resources>
                        <Border Background="White" Padding="8" CornerRadius="4">
                            <Grid>
                                <Grid.RowDefinitions>
                                    <RowDefinition Height="Auto"/>
                                    <RowDefinition Height="*"/>
                                </Grid.RowDefinitions>
 
                                <Grid Grid.Row="0">
                                    <Grid.ColumnDefinitions>
                                        <ColumnDefinition Width="Auto"/>
                                        <ColumnDefinition Width="*"/>
                                        <ColumnDefinition Width="Auto"/>
                                    </Grid.ColumnDefinitions>
                                    <Button x:Name="PART_PreviousButton" Grid.Column="0"
                                            Width="28" Height="28" Style="{StaticResource CalendarToggleStyle}"
                                            Focusable="False">
                                        <Path Width="8" Height="10" Stretch="Uniform"
                                              Fill="{StaticResource TextSecondaryBrush}"
                                              Data="M 7,0 L 0,5 L 7,10 Z"/>
                                    </Button>
                                    <Button x:Name="PART_HeaderButton" Grid.Column="1"
                                            Style="{StaticResource CalendarToggleStyle}"
                                            HorizontalContentAlignment="Center"
                                            FontWeight="SemiBold" FontSize="12"
                                            Foreground="{StaticResource TextPrimaryBrush}"
                                            Focusable="False"/>
                                    <Button x:Name="PART_NextButton" Grid.Column="2"
                                            Width="28" Height="28" Style="{StaticResource CalendarToggleStyle}"
                                            Focusable="False">
                                        <Path Width="8" Height="10" Stretch="Uniform"
                                              Fill="{StaticResource TextSecondaryBrush}"
                                              Data="M 0,0 L 7,5 L 0,10 Z"/>
                                    </Button>
                                </Grid>
 
                                <Grid Grid.Row="1" Margin="0,6,0,0">
                                    <Grid x:Name="PART_MonthView" Visibility="Visible"/>
                                    <Grid x:Name="PART_YearView" Visibility="Hidden"/>
                                </Grid>
                            </Grid>
                        </Border>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
 
        <Style TargetType="DatePickerTextBox">
            <Setter Property="Background" Value="Transparent"/>
            <Setter Property="BorderThickness" Value="0"/>
            <Setter Property="Padding" Value="6,0"/>
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="DatePickerTextBox">
                        <Grid>
                            <ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Center" Focusable="False"/>
                        </Grid>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
 
        <Style x:Key="CalendarToggleStyle" TargetType="Button">
            <Setter Property="Background" Value="Transparent"/>
            <Setter Property="BorderBrush" Value="Transparent"/>
            <Setter Property="BorderThickness" Value="0"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Padding" Value="0"/>
            <Setter Property="Margin" Value="0"/>
            <Setter Property="MinWidth" Value="0"/>
            <Setter Property="MinHeight" Value="0"/>
            <Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Border x:Name="bg" Background="{TemplateBinding Background}" CornerRadius="2" Padding="0">
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="bg" Property="Background" Value="#EAF2FB"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
 
        <Style TargetType="DatePicker">
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="MinHeight" Value="26"/>
            <Setter Property="Background" Value="White"/>
            <Setter Property="BorderBrush" Value="{StaticResource StrokeStrongBrush}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="DatePicker">
                        <Border Background="{TemplateBinding Background}"
                                BorderBrush="{TemplateBinding BorderBrush}"
                                BorderThickness="{TemplateBinding BorderThickness}"
                                CornerRadius="2"
                                SnapsToDevicePixels="True">
                            <Grid x:Name="PART_Root">
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition Width="*"/>
                                    <ColumnDefinition Width="26"/>
                                </Grid.ColumnDefinitions>
                                <DatePickerTextBox x:Name="PART_TextBox" Grid.Column="0"
                                                   Foreground="{TemplateBinding Foreground}"
                                                   VerticalAlignment="Center"/>
                                <Button x:Name="PART_Button" Grid.Column="1" Width="26"
                                        Style="{StaticResource CalendarToggleStyle}"
                                        Focusable="False">
                                    <Path Width="13" Height="13" Stretch="Uniform"
                                          Stroke="{StaticResource TextSecondaryBrush}" StrokeThickness="1.1"
                                          Fill="Transparent" SnapsToDevicePixels="True"
                                          Data="M0,3 L14,3 M3,0 L3,5 M11,0 L11,5 M0,7 L14,7 M0,3 L0,14 L14,14 L14,3 Z"/>
                                </Button>
                                <Popup x:Name="PART_Popup"
                                       PlacementTarget="{Binding ElementName=PART_Button}"
                                       Placement="Bottom" StaysOpen="False"/>
                                <Grid x:Name="PART_DisabledVisual" Background="#80FFFFFF" Visibility="Collapsed"/>
                            </Grid>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsKeyboardFocusWithin" Value="True">
                                <Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="False">
                                <Setter TargetName="PART_DisabledVisual" Property="Visibility" Value="Visible"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="ListBox">
            <Setter Property="BorderBrush" Value="{StaticResource StrokeBrush}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Background" Value="White"/>
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="Padding" Value="0"/>
        </Style>
 
        <Style TargetType="Button">
            <Setter Property="Padding" Value="10,4"/>
            <Setter Property="MinHeight" Value="26"/>
            <Setter Property="MinWidth" Value="72"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="BorderBrush" Value="{StaticResource StrokeStrongBrush}"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
            <Setter Property="Background" Value="{StaticResource ChromeBrush}"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="FontWeight" Value="Normal"/>
            <Setter Property="FontSize" Value="12"/>
            <Setter Property="Margin" Value="0,0,8,0"/>
            <Style.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                    <Setter Property="Background" Value="{StaticResource ChromeHoverBrush}"/>
                </Trigger>
            </Style.Triggers>
        </Style>
        <Style x:Key="PrimaryButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="Background" Value="{StaticResource AccentBrush}"/>
            <Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="FontWeight" Value="SemiBold"/>
            <Style.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                    <Setter Property="Background" Value="{StaticResource AccentHoverBrush}"/>
                    <Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}"/>
                </Trigger>
            </Style.Triggers>
        </Style>
        <Style x:Key="NeutralButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"/>
        <Style x:Key="SuccessButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"/>
        <Style x:Key="GhostButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="Background" Value="Transparent"/>
            <Setter Property="BorderBrush" Value="Transparent"/>
            <Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
            <Setter Property="MinWidth" Value="0"/>
            <Setter Property="Padding" Value="6,3"/>
            <Style.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                    <Setter Property="Background" Value="#EAF2FB"/>
                </Trigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
 
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
 
        <Border Grid.Row="0" Background="White" BorderBrush="{StaticResource StrokeBrush}" BorderThickness="0,0,0,1" Padding="20,8">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <StackPanel Grid.Column="0">
                    <TextBlock Text="Unified Audit Log search" FontSize="15" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}"/>
                    <TextBlock Text="Build, preview and run a Microsoft 365 Unified Audit Log query, then export the results to Excel."
                               FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,1,0,0"/>
                </StackPanel>
                <TextBlock x:Name="HeaderVersionText" Grid.Column="1" VerticalAlignment="Center"
                           FontSize="11" Foreground="{StaticResource TextHintBrush}"/>
            </Grid>
        </Border>
 
        <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Padding="20,12,20,12">
            <StackPanel>
 
                <Border Style="{StaticResource CardStyle}">
                    <StackPanel>
                        <TextBlock Text="Time range" Style="{StaticResource SectionTitleStyle}"/>
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*"/>
                                <ColumnDefinition Width="*"/>
                                <ColumnDefinition Width="220"/>
                            </Grid.ColumnDefinitions>
 
                            <StackPanel Grid.Column="0" Margin="0,0,16,0">
                                <TextBlock Text="Start date" Style="{StaticResource FieldLabelStyle}"/>
                                <DatePicker x:Name="StartDatePicker"/>
                                <TextBox x:Name="StartDateBox" Margin="0,6,0,0" Style="{StaticResource CompactInputStyle}" ToolTip="Format: yyyy-MM-dd HH:mm"/>
                                <StackPanel Orientation="Horizontal" Margin="0,6,0,0">
                                    <Button x:Name="StartYesterdayButton" Content="Yesterday 00:00" Style="{StaticResource GhostButtonStyle}"/>
                                    <Button x:Name="StartNowButton" Content="Now" Style="{StaticResource GhostButtonStyle}"/>
                                </StackPanel>
                            </StackPanel>
 
                            <StackPanel Grid.Column="1" Margin="0,0,16,0">
                                <TextBlock Text="End date" Style="{StaticResource FieldLabelStyle}"/>
                                <DatePicker x:Name="EndDatePicker"/>
                                <TextBox x:Name="EndDateBox" Margin="0,6,0,0" Style="{StaticResource CompactInputStyle}" ToolTip="Format: yyyy-MM-dd HH:mm"/>
                                <StackPanel Orientation="Horizontal" Margin="0,6,0,0">
                                    <Button x:Name="EndTodayButton" Content="Today 23:59" Style="{StaticResource GhostButtonStyle}"/>
                                    <Button x:Name="EndNowButton" Content="Now" Style="{StaticResource GhostButtonStyle}"/>
                                </StackPanel>
                            </StackPanel>
 
                            <StackPanel Grid.Column="2">
                                <TextBlock Text="Result size (max)" Style="{StaticResource FieldLabelStyle}"/>
                                <TextBox x:Name="ResultSizeBox" Text="5000" Style="{StaticResource CompactInputStyle}"/>
                                <CheckBox x:Name="SimpleViewCheckBox" Content="Flatten records (Simple view)" Margin="0,10,0,0" IsChecked="True"/>
                                <Button x:Name="RetentionInfoButton" Content="How does retention work?" Margin="-6,8,0,0" HorizontalAlignment="Left" Style="{StaticResource GhostButtonStyle}"/>
                            </StackPanel>
                        </Grid>
                    </StackPanel>
                </Border>
 
                <Border Style="{StaticResource CardStyle}" Margin="0,6,0,0">
                    <StackPanel>
                        <TextBlock Text="Presets" Style="{StaticResource SectionTitleStyle}"/>
                        <StackPanel Orientation="Horizontal">
                            <Button x:Name="LoadSharingEventsButton" Content="Sharing activity (SPO/OneDrive)"/>
                        </StackPanel>
                    </StackPanel>
                </Border>
 
                <Border Style="{StaticResource CardStyle}" Margin="0,6,0,0">
                    <StackPanel>
                        <TextBlock Text="Filters" Style="{StaticResource SectionTitleStyle}"/>
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*"/>
                                <ColumnDefinition Width="*"/>
                            </Grid.ColumnDefinitions>
 
                            <StackPanel Grid.Column="0" Margin="0,0,16,0">
                                <TextBlock Text="Operations" Style="{StaticResource FieldLabelStyle}"/>
                                <Grid>
                                    <Grid.ColumnDefinitions>
                                        <ColumnDefinition Width="*"/>
                                        <ColumnDefinition Width="Auto"/>
                                    </Grid.ColumnDefinitions>
                                    <TextBox x:Name="OperationsSearchBox" Grid.Column="0" Style="{StaticResource CompactInputStyle}"
                                             ToolTip="Type to filter the list below, OR type a raw cmdlet (e.g. New-TransportRule) and press Enter / click 'Add as raw'"/>
                                    <Button x:Name="AddCustomOperationButton" Grid.Column="1" Content="Add as raw" Margin="8,0,0,0"/>
                                </Grid>
                                <TextBlock Style="{StaticResource HelperTextStyle}"
                                           Text="Search a friendly name OR type raw cmdlets (comma/semicolon-separated)."/>
                            </StackPanel>
 
                            <StackPanel Grid.Column="1">
                                <TextBlock Text="User IDs" Style="{StaticResource FieldLabelStyle}"/>
                                <TextBox x:Name="UserIdsBox" Style="{StaticResource CompactInputStyle}"
                                         ToolTip="Optional. Filter the search by one or more user principal names. Leave empty to search all users."/>
                                <TextBlock Style="{StaticResource HelperTextStyle}"
                                           Text="Comma or semicolon-separated. Example: alice@contoso.com;bob@contoso.com"/>
                            </StackPanel>
                        </Grid>
                    </StackPanel>
                </Border>
 
                <Border Style="{StaticResource CardStyle}" Margin="0,6,0,0">
                    <StackPanel>
                        <TextBlock Text="Selected operations" Style="{StaticResource SectionTitleStyle}"/>
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*"/>
                                <ColumnDefinition Width="Auto"/>
                                <ColumnDefinition Width="*"/>
                            </Grid.ColumnDefinitions>
 
                            <StackPanel Grid.Column="0">
                                <TextBlock Text="Available" Style="{StaticResource FieldLabelStyle}"/>
                                <ListBox x:Name="AvailableOperationsListBox" Height="140" SelectionMode="Extended"
                                         ScrollViewer.HorizontalScrollBarVisibility="Auto"
                                         ScrollViewer.CanContentScroll="True"
                                         VirtualizingStackPanel.IsVirtualizing="True"
                                         VirtualizingStackPanel.VirtualizationMode="Recycling"/>
                            </StackPanel>
 
                            <StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="14,0">
                                <Button x:Name="AddOperationButton" Content="Add &#x2192;" Width="92" Margin="0,0,0,8"/>
                                <Button x:Name="RemoveOperationButton" Content="&#x2190; Remove" Width="92" Margin="0,0,0,8"/>
                                <Button x:Name="ClearOperationsButton" Content="Clear" Width="92" Style="{StaticResource GhostButtonStyle}"/>
                            </StackPanel>
 
                            <StackPanel Grid.Column="2">
                                <TextBlock Text="Selected" Style="{StaticResource FieldLabelStyle}"/>
                                <ListBox x:Name="SelectedOperationsListBox" Height="140" SelectionMode="Extended"
                                         ScrollViewer.HorizontalScrollBarVisibility="Auto"
                                         ScrollViewer.CanContentScroll="True"
                                         VirtualizingStackPanel.IsVirtualizing="True"
                                         VirtualizingStackPanel.VirtualizationMode="Recycling"/>
                            </StackPanel>
                        </Grid>
                    </StackPanel>
                </Border>
 
                <Border Style="{StaticResource CardStyle}" Margin="0,6,0,0">
                    <StackPanel>
                        <TextBlock Text="Generated command" Style="{StaticResource SectionTitleStyle}"/>
                        <TextBox x:Name="CommandBox" MinHeight="56" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
                                 IsReadOnly="True" AcceptsReturn="True" FontFamily="Consolas" FontSize="12"
                                 Background="#F9F9F9"/>
                    </StackPanel>
                </Border>
            </StackPanel>
        </ScrollViewer>
 
        <Border Grid.Row="2" Background="White" BorderBrush="{StaticResource StrokeBrush}" BorderThickness="0,1,0,0" Padding="20,10">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
 
                <TextBlock x:Name="FooterText" Grid.Column="0" VerticalAlignment="Center"
                           Foreground="{StaticResource TextHintBrush}" FontSize="11">
                    <Run Text="by "/>
                    <Hyperlink x:Name="ClidsysLink" NavigateUri="https://clidsys.com"
                               Foreground="{StaticResource AccentBrush}" TextDecorations="None">
                        <Run Text="Clidsys"/>
                    </Hyperlink>
                    <Run Text=" - "/>
                    <Hyperlink x:Name="BastienLink" NavigateUri="https://www.linkedin.com/in/perez-bastien/"
                               Foreground="{StaticResource AccentBrush}" TextDecorations="None">
                        <Run Text="Bastien Perez"/>
                    </Hyperlink>
                </TextBlock>
 
                <StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
                    <Button x:Name="CopyButton" Content="Copy command" Style="{StaticResource SuccessButtonStyle}"
                            ToolTip="Copies the generated command to the clipboard. Rarely needed here - use 'Run now' to execute the query and export the results directly."/>
                    <Button x:Name="CloseButton" Content="Close" Style="{StaticResource NeutralButtonStyle}"/>
                    <Button x:Name="RunButton" Content="Run now" Style="{StaticResource PrimaryButtonStyle}" Margin="8,0,0,0"
                            ToolTip="Runs the query against the Unified Audit Log. A file dialog will open first to choose where to save the results - in this mode, results are always exported to an Excel file."/>
                </StackPanel>
            </Grid>
        </Border>
    </Grid>
</Window>
'@


    if ($splash) { $splash.Update('Building interface...') }
    $reader = New-Object System.Xml.XmlNodeReader ([xml]$xaml)
    $window = [Windows.Markup.XamlReader]::Load($reader)

    $iconPath = Join-Path $PSScriptRoot '..\..\Private\Assets\Search-UnifiedAuditLogCustom.png'
    if (Test-Path -LiteralPath $iconPath) {
        try {
            $iconBitmap = New-Object System.Windows.Media.Imaging.BitmapImage
            $iconBitmap.BeginInit()
            $iconBitmap.UriSource = [Uri]$iconPath
            $iconBitmap.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
            $iconBitmap.EndInit()
            $iconBitmap.Freeze()
            $window.Icon = $iconBitmap
        }
        catch {
            Write-Verbose "Could not load window icon: $($_.Exception.Message)"
        }
    }

    $clidsysLink = $window.FindName('ClidsysLink')
    if ($clidsysLink) {
        $clidsysLink.Add_RequestNavigate({
                param($source, $e)
                Start-Process $e.Uri.AbsoluteUri
                $e.Handled = $true
            })
    }

    $bastienLink = $window.FindName('BastienLink')
    if ($bastienLink) {
        $bastienLink.Add_RequestNavigate({
                param($source, $e)
                Start-Process $e.Uri.AbsoluteUri
                $e.Handled = $true
            })
    }

    $headerVersionText = $window.FindName('HeaderVersionText')
    if ($headerVersionText -and $moduleVersion) {
        $headerVersionText.Text = "PS365 $moduleVersion"
    }

    $startDateBox = $window.FindName('StartDateBox')
    $endDateBox = $window.FindName('EndDateBox')
    $startDatePicker = $window.FindName('StartDatePicker')
    $endDatePicker = $window.FindName('EndDatePicker')
    $startYesterdayButton = $window.FindName('StartYesterdayButton')
    $startNowButton = $window.FindName('StartNowButton')
    $endTodayButton = $window.FindName('EndTodayButton')
    $endNowButton = $window.FindName('EndNowButton')
    $resultSizeBox = $window.FindName('ResultSizeBox')
    $simpleViewCheckBox = $window.FindName('SimpleViewCheckBox')
    $loadSharingEventsButton = $window.FindName('LoadSharingEventsButton')
    $operationsSearchBox = $window.FindName('OperationsSearchBox')
    $userIdsBox = $window.FindName('UserIdsBox')
    $addCustomOperationButton = $window.FindName('AddCustomOperationButton')
    $availableOperationsListBox = $window.FindName('AvailableOperationsListBox')
    $selectedOperationsListBox = $window.FindName('SelectedOperationsListBox')
    $addOperationButton = $window.FindName('AddOperationButton')
    $removeOperationButton = $window.FindName('RemoveOperationButton')
    $clearOperationsButton = $window.FindName('ClearOperationsButton')
    $commandBox = $window.FindName('CommandBox')
    $copyButton = $window.FindName('CopyButton')
    $runButton = $window.FindName('RunButton')
    $closeButton = $window.FindName('CloseButton')
    $retentionInfoButton = $window.FindName('RetentionInfoButton')

    $startDateBox.Text = (Get-Date).AddDays(-1).Date.ToString('yyyy-MM-dd 00:00')
    $endDateBox.Text = (Get-Date).Date.ToString('yyyy-MM-dd 23:59')
    $startDatePicker.SelectedDate = (Get-Date).AddDays(-1).Date
    $endDatePicker.SelectedDate = (Get-Date).Date

    foreach ($entry in $operationChoices) {
        if ([string]::IsNullOrWhiteSpace($entry.Operation)) {
            continue
        }

        $display = "$($entry.FriendlyName) [$($entry.Operation)]"
        $operationLookupByDisplay[$display] = $entry.Operation
    }

    $refreshOperationsList = {
        $availableOperationsListBox.Items.Clear()

        $searchValue = $operationsSearchBox.Text
        $filteredOperations = $operationChoices
        if (-not [string]::IsNullOrWhiteSpace($searchValue)) {
            $filteredOperations = $operationChoices | Where-Object {
                $_.FriendlyName -like "*$searchValue*" -or $_.Operation -like "*$searchValue*"
            }
        }

        foreach ($entry in $filteredOperations) {
            if ([string]::IsNullOrWhiteSpace($entry.Operation)) {
                continue
            }

            $display = "$($entry.FriendlyName) [$($entry.Operation)]"
            $null = $availableOperationsListBox.Items.Add($display)
        }
    }

    $parseDateInput = {
        param(
            [string]$InputText,
            [datetime]$Fallback
        )

        try {
            return [datetime]::ParseExact($InputText, 'yyyy-MM-dd HH:mm', [System.Globalization.CultureInfo]::InvariantCulture)
        }
        catch {
            return $Fallback
        }
    }

    $buildCommand = {
        $startParsed = & $parseDateInput -InputText $startDateBox.Text -Fallback ((Get-Date).AddDays(-1).Date)
        $endParsed = & $parseDateInput -InputText $endDateBox.Text -Fallback ((Get-Date).Date.AddHours(23).AddMinutes(59))

        $startValue = $startParsed.ToString('yyyy-MM-dd HH:mm')
        $endValue = $endParsed.ToString('yyyy-MM-dd HH:mm')

        $sizeValue = 5000
        [void][int]::TryParse($resultSizeBox.Text, [ref]$sizeValue)
        if ($sizeValue -lt 1) { $sizeValue = 1 }

        [System.Collections.Generic.List[string]]$selectedOperations = @()
        foreach ($selectedDisplay in $selectedOperationsListBox.Items) {
            if ($operationLookupByDisplay.ContainsKey([string]$selectedDisplay)) {
                $selectedOperations.Add($operationLookupByDisplay[[string]$selectedDisplay])
            }
        }

        $command = "Search-UnifiedAuditLogCustom -StartDate '$startValue' -EndDate '$endValue' -ResultSize $sizeValue"
        if ($selectedOperations.Count -gt 0) {
            $operationsString = '"' + ($selectedOperations -join '","') + '"'
            $command += " -Operations @($operationsString)"
        }

        $userIds = @()
        if (-not [string]::IsNullOrWhiteSpace($userIdsBox.Text)) {
            $userIds = $userIdsBox.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
        }
        if ($userIds.Count -gt 0) {
            $userIdsString = '"' + ($userIds -join '","') + '"'
            $command += " -UserIds @($userIdsString)"
        }

        if ($simpleViewCheckBox.IsChecked -eq $true) {
            $command += ' -SimpleView'
        }

        $commandBox.Text = $command
    }

    $operationsSearchBox.Add_TextChanged({
            & $refreshOperationsList
            & $buildCommand
        })

    $userIdsBox.Add_TextChanged({ & $buildCommand })

    $availableOperationsListBox.Add_MouseDoubleClick({
            if ($availableOperationsListBox.SelectedItem -and -not $selectedOperationsListBox.Items.Contains($availableOperationsListBox.SelectedItem)) {
                $null = $selectedOperationsListBox.Items.Add($availableOperationsListBox.SelectedItem)
                & $buildCommand
            }
        })

    $availableOperationsListBox.Add_SelectionChanged({
            & $buildCommand
        })

    $selectedOperationsListBox.Add_MouseDoubleClick({
            if ($selectedOperationsListBox.SelectedItem) {
                $selectedOperationsListBox.Items.Remove($selectedOperationsListBox.SelectedItem)
                & $buildCommand
            }
        })

    $addOperationButton.Add_Click({
            if ($availableOperationsListBox.SelectedItems.Count -gt 0) {
                foreach ($item in @($availableOperationsListBox.SelectedItems)) {
                    if (-not $selectedOperationsListBox.Items.Contains($item)) {
                        $null = $selectedOperationsListBox.Items.Add($item)
                    }
                }
                & $buildCommand
            }
        })

    $removeOperationButton.Add_Click({
            if ($selectedOperationsListBox.SelectedItems.Count -gt 0) {
                foreach ($item in @($selectedOperationsListBox.SelectedItems)) {
                    $selectedOperationsListBox.Items.Remove($item)
                }
                & $buildCommand
            }
        })

    $clearOperationsButton.Add_Click({
            $selectedOperationsListBox.Items.Clear()
            & $buildCommand
        })

    $addCustomOperation = {
        $raw = $operationsSearchBox.Text
        if ([string]::IsNullOrWhiteSpace($raw)) {
            return
        }
        foreach ($entry in ($raw -split '[,;]')) {
            $opName = $entry.Trim()
            if ([string]::IsNullOrWhiteSpace($opName)) {
                continue
            }
            $display = "$opName [custom]"
            $operationLookupByDisplay[$display] = $opName
            if (-not $selectedOperationsListBox.Items.Contains($display)) {
                $null = $selectedOperationsListBox.Items.Add($display)
            }
        }
        $operationsSearchBox.Text = ''
        & $buildCommand
    }

    $addCustomOperationButton.Add_Click({ & $addCustomOperation })

    $operationsSearchBox.Add_KeyDown({
            param($source, $e)
            if ($e.Key -eq 'Return') {
                & $addCustomOperation
                $e.Handled = $true
            }
        })

    $selectedOperationsListBox.Add_SelectionChanged({
            & $buildCommand
        })

    $startDateBox.Add_TextChanged({
            & $buildCommand
        })

    $endDateBox.Add_TextChanged({
            & $buildCommand
        })

    $startDatePicker.Add_SelectedDateChanged({
            if ($startDatePicker.SelectedDate) {
                $existingTime = '00:00'
                if ($startDateBox.Text -match '^\d{4}-\d{2}-\d{2}\s(\d{2}:\d{2})$') {
                    $existingTime = $Matches[1]
                }
                $startDateBox.Text = ([datetime]$startDatePicker.SelectedDate).ToString("yyyy-MM-dd $existingTime")
            }
        })

    $endDatePicker.Add_SelectedDateChanged({
            if ($endDatePicker.SelectedDate) {
                $existingTime = '23:59'
                if ($endDateBox.Text -match '^\d{4}-\d{2}-\d{2}\s(\d{2}:\d{2})$') {
                    $existingTime = $Matches[1]
                }
                $endDateBox.Text = ([datetime]$endDatePicker.SelectedDate).ToString("yyyy-MM-dd $existingTime")
            }
        })

    $startYesterdayButton.Add_Click({
            $startDateBox.Text = (Get-Date).AddDays(-1).Date.ToString('yyyy-MM-dd 00:00')
            $startDatePicker.SelectedDate = (Get-Date).AddDays(-1).Date
        })

    $startNowButton.Add_Click({
            $now = Get-Date
            $startDateBox.Text = $now.ToString('yyyy-MM-dd HH:mm')
            $startDatePicker.SelectedDate = $now.Date
        })

    $endTodayButton.Add_Click({
            $endDateBox.Text = (Get-Date).Date.ToString('yyyy-MM-dd 23:59')
            $endDatePicker.SelectedDate = (Get-Date).Date
        })

    $endNowButton.Add_Click({
            $now = Get-Date
            $endDateBox.Text = $now.ToString('yyyy-MM-dd HH:mm')
            $endDatePicker.SelectedDate = $now.Date
        })

    $resultSizeBox.Add_TextChanged({
            & $buildCommand
        })

    $simpleViewCheckBox.Add_Click({
            & $buildCommand
        })

    $loadSharingEventsButton.Add_Click({
            # Load preset: Sharing & SharePoint/OneDrive Events from 2026-01-01 to today
            $startDateBox.Text = '2026-01-01 00:00'
            $endDateBox.Text = (Get-Date).ToString('yyyy-MM-dd 23:59')
            $startDatePicker.SelectedDate = [datetime]'2026-01-01'
            $endDatePicker.SelectedDate = (Get-Date).Date
            $resultSizeBox.Text = '5000'
            $simpleViewCheckBox.IsChecked = $true

            # Sharing operations documented in MS Learn (audit-log-sharing).
            # EmailAuthOTPAuthenticationSucceeded is added (as raw/custom) because external sharing
            # flows often rely on email OTP authentication.
            $sharingOperations = @(
                'SharingInvitationCreated',
                'SharingInvitationAccepted',
                'AnonymousLinkCreated',
                'AnonymousLinkUsed',
                'SecureLinkCreated',
                'AddedToSecureLink',
                'SharingSet',
                'AddedToGroup',
                'EmailAuthOTPAuthenticationSucceeded'
            )

            $selectedOperationsListBox.Items.Clear()
            foreach ($op in $sharingOperations) {
                $matchedDisplay = $null
                foreach ($display in $availableOperationsListBox.Items) {
                    if ($display.Contains("[$op]")) {
                        $matchedDisplay = $display
                        break
                    }
                }
                if ($matchedDisplay) {
                    $null = $selectedOperationsListBox.Items.Add($matchedDisplay)
                }
                else {
                    $customDisplay = "$op [custom]"
                    $operationLookupByDisplay[$customDisplay] = $op
                    $null = $selectedOperationsListBox.Items.Add($customDisplay)
                }
            }

            & $buildCommand
        })

    $copyButton.Add_Click({
            if (-not [string]::IsNullOrWhiteSpace($commandBox.Text)) {
                [System.Windows.Clipboard]::SetText($commandBox.Text)
            }
        })

    $runButton.Add_Click({
            $testExoConnection = {
                if (Get-Command -Name Get-ConnectionInformation -ErrorAction SilentlyContinue) {
                    return [bool](Get-ConnectionInformation -ErrorAction SilentlyContinue |
                        Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' })
                }
                return $false
            }

            $exoConnected = & $testExoConnection
            if (-not $exoConnected) {
                $answer = [System.Windows.MessageBox]::Show(
                    "Not connected to Exchange Online.`n`nDo you want to connect now? A sign-in prompt will open.",
                    'Exchange Online connection required',
                    'YesNo',
                    'Question')
                if ($answer -ne [System.Windows.MessageBoxResult]::Yes) {
                    return
                }

                $window.Cursor = [System.Windows.Input.Cursors]::Wait
                try {
                    Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
                }
                catch {
                    $null = [System.Windows.MessageBox]::Show(
                        "Failed to connect to Exchange Online:`n$($_.Exception.Message)",
                        'Connection failed',
                        'OK',
                        'Error')
                    $window.Cursor = [System.Windows.Input.Cursors]::Arrow
                    return
                }
                $window.Cursor = [System.Windows.Input.Cursors]::Arrow

                $exoConnected = & $testExoConnection
                if (-not $exoConnected) {
                    $null = [System.Windows.MessageBox]::Show(
                        'Exchange Online connection could not be verified. Please retry.',
                        'Connection not active',
                        'OK',
                        'Error')
                    return
                }
            }

            $startDate = & $parseDateInput -InputText $startDateBox.Text -Fallback ((Get-Date).AddDays(-1).Date)
            $endDate = & $parseDateInput -InputText $endDateBox.Text -Fallback ((Get-Date).Date.AddHours(23).AddMinutes(59))

            $sizeValue = 5000
            [void][int]::TryParse($resultSizeBox.Text, [ref]$sizeValue)
            if ($sizeValue -lt 1) { $sizeValue = 1 }

            [System.Collections.Generic.List[string]]$selectedOperations = @()
            foreach ($selectedDisplay in $selectedOperationsListBox.Items) {
                if ($operationLookupByDisplay.ContainsKey([string]$selectedDisplay)) {
                    $selectedOperations.Add($operationLookupByDisplay[[string]$selectedDisplay])
                }
            }

            $runParams = @{
                StartDate  = $startDate
                EndDate    = $endDate
                ResultSize = $sizeValue
            }

            if ($selectedOperations.Count -gt 0) {
                $runParams['Operations'] = $selectedOperations
            }

            $userIdsForRun = @()
            if (-not [string]::IsNullOrWhiteSpace($userIdsBox.Text)) {
                $userIdsForRun = $userIdsBox.Text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
            }
            if ($userIdsForRun.Count -gt 0) {
                $runParams['UserIds'] = $userIdsForRun
            }

            if ($simpleViewCheckBox.IsChecked -eq $true) {
                $runParams['SimpleView'] = $true
            }

            # Resolve a short tenant name (e.g. 'contoso.onmicrosoft.com' -> 'contoso') for the filename
            $tenantName = 'tenant'
            try {
                $exoConn = Get-ConnectionInformation -ErrorAction Stop |
                    Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' } |
                    Select-Object -First 1
                if ($exoConn -and $exoConn.Organization) {
                    $tenantName = ($exoConn.Organization -split '\.')[0]
                }
            }
            catch {
                Write-Verbose "Could not resolve tenant name for filename: $($_.Exception.Message)"
            }
            $tenantSafe = ($tenantName -replace '[^A-Za-z0-9\-_]', '_')
            $executionStamp = (Get-Date).ToString('yyyy-MM-dd-HHmmss')

            $defaultFileName = "${executionStamp}_UnifiedAuditLog_${tenantSafe}_$($startDate.ToString('yyyyMMdd-HHmmss'))_to_$($endDate.ToString('yyyyMMdd-HHmmss')).xlsx"
            $saveDialog = New-Object Microsoft.Win32.SaveFileDialog
            $saveDialog.Title = 'Save audit log results as Excel'
            $saveDialog.Filter = 'Excel workbook (*.xlsx)|*.xlsx'
            $saveDialog.FileName = $defaultFileName
            $saveDialog.InitialDirectory = [Environment]::GetFolderPath('UserProfile')
            if (-not $saveDialog.ShowDialog()) {
                return
            }
            $excelPath = $saveDialog.FileName

            $window.Cursor = [System.Windows.Input.Cursors]::Wait
            try {
                $results = Search-UnifiedAuditLogCustom @runParams

                if (-not $results -or @($results).Count -eq 0) {
                    $null = [System.Windows.MessageBox]::Show('No audit log entries returned for the selected filters.', 'No results', 'OK', 'Warning')
                    return
                }

                $rawCount = @($results).Count
                $dedupKey = $null
                foreach ($candidate in @('Identity', 'Id')) {
                    if ($results[0].PSObject.Properties.Name -contains $candidate) {
                        $dedupKey = $candidate
                        break
                    }
                }
                if ($dedupKey) {
                    $results = $results | Sort-Object -Property $dedupKey -Unique
                }
                $finalCount = @($results).Count
                $duplicatesRemoved = $rawCount - $finalCount

                if (-not (Get-Module -ListAvailable -Name ImportExcel)) {
                    $null = [System.Windows.MessageBox]::Show('ImportExcel module is not installed. Run: Install-Module ImportExcel', 'Missing module', 'OK', 'Error')
                    return
                }

                $excelSplat = @{
                    Path          = $excelPath
                    WorksheetName = 'AuditLog'
                    AutoSize      = $true
                    AutoFilter    = $true
                    FreezeTopRow  = $true
                    TableName     = 'AuditLog'
                    ClearSheet    = $true
                }
                $results | Export-Excel @excelSplat

                $dedupMessage = if ($duplicatesRemoved -gt 0) { "`n($duplicatesRemoved duplicate row(s) removed)" } else { '' }
                $answer = [System.Windows.MessageBox]::Show("$finalCount row(s) exported to:`n$excelPath$dedupMessage`n`nOpen the file now?", 'Export complete', 'YesNo', 'Information')
                if ($answer -eq [System.Windows.MessageBoxResult]::Yes) {
                    Start-Process -FilePath $excelPath
                }
            }
            catch {
                $null = [System.Windows.MessageBox]::Show("Error during export:`n$($_.Exception.Message)", 'Error', 'OK', 'Error')
            }
            finally {
                $window.Cursor = [System.Windows.Input.Cursors]::Arrow
            }
        })

    $closeButton.Add_Click({
            $window.Close()
        })

    $retentionInfoButton.Add_Click({
            $retentionXaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="How audit log retention works"
        Width="680" Height="500"
        WindowStartupLocation="CenterOwner"
        ResizeMode="NoResize"
        Background="#F3F5F8"
        FontFamily="Segoe UI">
    <Window.Resources>
        <Style TargetType="Button">
            <Setter Property="Margin" Value="0,6,8,0"/>
            <Setter Property="Padding" Value="12,6"/>
            <Setter Property="MinHeight" Value="30"/>
            <Setter Property="MinWidth" Value="120"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="BorderBrush" Value="#CBD5E1"/>
            <Setter Property="Foreground" Value="#0F172A"/>
            <Setter Property="Background" Value="#F8FAFC"/>
            <Setter Property="Cursor" Value="Hand"/>
        </Style>
    </Window.Resources>
 
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
 
        <Border Grid.Row="0" Background="#FDEFD0" CornerRadius="10" Padding="14" BorderBrush="#F1C97A" BorderThickness="1">
            <StackPanel>
                <TextBlock Text="Audit log retention - what Microsoft says vs. what actually works"
                           FontWeight="SemiBold" FontSize="14" Foreground="#7A3A00" TextWrapping="Wrap"/>
            </StackPanel>
        </Border>
 
        <Border Grid.Row="1" Margin="0,6,0,0" Background="White" CornerRadius="10" Padding="16"
                BorderBrush="#E2E8F0" BorderThickness="1">
            <StackPanel>
                <TextBlock FontWeight="SemiBold" Foreground="#0F172A" Margin="0,0,0,4"
                           Text="Microsoft's official retention tiers"/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="- 180 days: default retention since October 2023 (no specific license required)."/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="- Up to 1 year: requires Office 365 E5 / Microsoft 365 E5 / Microsoft 365 E7, Microsoft Purview Suite (formerly Microsoft 365 E5 Compliance), or the E5 eDiscovery and Audit add-on."/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="- Up to 10 years: requires the above plus the 10-year audit log retention add-on."/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155" Margin="0,4,0,0"
                           Text="Get-AdminAuditLogConfig nonetheless returns AdminAuditLogAgeLimit = 90 days."/>
 
                <TextBlock FontWeight="SemiBold" Foreground="#0F172A" Margin="0,12,0,4"
                           Text="In practice: up to 365 days on all tenants"/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="Search-UnifiedAuditLog accepts queries up to 365 days back even on non-E5 tenants, regardless of the values advertised by the documentation and the PowerShell configuration."/>
 
                <TextBlock Margin="0,6,0,0" TextWrapping="Wrap" Foreground="#64748B" FontStyle="Italic"
                           Text="Click 'Open article' to read the full write-up with PowerShell examples."/>
            </StackPanel>
        </Border>
 
        <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
            <Button x:Name="OpenArticleButton" Content="Open article"/>
            <Button x:Name="CloseRetentionButton" Content="Close"/>
        </StackPanel>
    </Grid>
</Window>
'@


            $retentionReader = New-Object System.Xml.XmlNodeReader ([xml]$retentionXaml)
            $retentionWindow = [Windows.Markup.XamlReader]::Load($retentionReader)
            $retentionWindow.Owner = $window

            $openArticleButton = $retentionWindow.FindName('OpenArticleButton')
            $closeRetentionButton = $retentionWindow.FindName('CloseRetentionButton')

            $openArticleButton.Add_Click({
                    Start-Process 'https://itpro-tips.com/microsoft-365-audit-logs-are-now-retained-for-365-days-for-all-tenants-with-powershell/'
                    $retentionWindow.Close()
                })

            $closeRetentionButton.Add_Click({ $retentionWindow.Close() })

            [void]$retentionWindow.ShowDialog()
        })

    & $refreshOperationsList
    & $buildCommand

    $window.Add_Closed({
            try {
                $availableOperationsListBox.ItemsSource = $null
                $availableOperationsListBox.Items.Clear()
                $selectedOperationsListBox.Items.Clear()
            }
            catch { }
        })

    try {
        if ($splash) { $splash.Close(); $splash = $null }
        [void]$window.ShowDialog()
    }
    finally {
        if ($splash) { try { $splash.Close() } catch { } }
        $window = $null
        $availableOperationsListBox = $null
        $selectedOperationsListBox = $null
        [System.GC]::Collect()
        [System.GC]::WaitForPendingFinalizers()
        [System.GC]::Collect()
    }
}