Public/Restore.ps1

# --- Restore job XML size constants ---
$XmlOverheadPerItem = 400       # Approximate XML overhead bytes per restore item element
$XmlBaseOverhead    = 13        # Base XML element overhead bytes (<Path></Path> tags)
$MaxXmlBatchSize    = 61440     # Maximum XML batch size (60KB) for API submission

<#
.SYNOPSIS
    Submits a backup or restore job to a Keepit connector
.DESCRIPTION
    Submits a job to the Keepit API using an XML configuration blob. This is a low-level
    cmdlet that accepts raw XML job configuration, allowing full control over job parameters.
    For common backup operations, consider using Start-KeepitBackup instead.
.PARAMETER Connector
    The connector name or GUID to submit the job against.
    Can be piped from Get-KeepitConnector.
.PARAMETER Configuration
    An XML blob specifying the job contents. The XML structure depends on the job type.
    Maximum 64K length.

    Example for a restore job:
    <job>
        <description>Restore deleted items</description>
        <type>restore</type>
        <immediate />
        <commands>
            <restore>
                <source path="/Users/guid/Outlook/Inbox" snaptime="20241201T120000Z" />
                <destination path="/Users/guid/Outlook/Restored" />
            </restore>
        </commands>
    </job>
.EXAMPLE
    $xml = '<job><description>Test restore</description><type>restore</type><immediate /><commands><restore /></commands></job>'
    Submit-KeepitJob -Connector "abc123-def456" -Configuration $xml

    Submits a restore job with the specified XML configuration
.EXAMPLE
    $config = Get-Content -Path "restore-job.xml" -Raw
    Submit-KeepitJob -Connector "Production M365" -Configuration $config

    Submits a job using configuration from a file
.EXAMPLE
    Get-KeepitConnector -Connector "Production" | Submit-KeepitJob -Configuration $xml

    Submits a job to a connector found by name via pipeline
.OUTPUTS
    PSCustomObject containing job details with properties:
        - JobGuid: The GUID of the created job
        - ConnectorGuid: The connector GUID
        - Status: Job status (e.g., "created", "pending", "active")
        - CreatedAt: Timestamp when the job was created
        - EstimatedItems: Estimated number of items (if available)
.NOTES
    Requires an active connection via Connect-KeepitService.
    This cmdlet posts to the jobs API endpoint using application/xml content type.
    The API response is parsed to extract job details from either job or jobs.job structure.
#>

function Submit-KeepitJob {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('ConnectorGuid', 'Name')]
        [string]$Connector,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
            if ($_.Length -gt 65536) {
                throw "Configuration XML exceeds maximum length of 64K"
            }
            # Basic XML validation
            try {
                [xml]$_ | Out-Null
                return $true
            }
            catch {
                throw "Configuration must be valid XML: $($_.Exception.Message)"
            }
        })]
        [string]$Configuration
    )

    begin {
        Write-Verbose "=== Submit-KeepitJob: Initialization ==="

        # Get authentication header and base URL once for all pipeline items
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl
            Write-Verbose "Base URL: $baseUrl, User ID: $userId"
        }
        catch {
            throw
        }
    }

    process {
        try {
            # Resolve connector identity to GUID
            $resolved = Resolve-KeepitConnectorIdentity -Identity $Connector
            $connectorGuid = $resolved.ConnectorGuid

            Write-Verbose "=== Submit-KeepitJob: Processing ==="
            Write-Verbose "Connector: $($resolved.Name) ($connectorGuid)"
            Write-Verbose "Configuration length: $($Configuration.Length) characters"

            # Build request URI (matches Bohr's getRestoreJobSettings)
            $uri = "$baseUrl/users/$userId/devices/$connectorGuid/jobs/"

            # Headers matching Bohr implementation
            $headers = @{
                'Authorization' = $authHeader
                'Content-Type'  = 'application/xml'
                'Accept'        = 'application/vnd.keepit.v4+xml'
            }

            Write-Verbose "=== API Request ==="
            Write-Verbose "Method: POST"
            Write-Verbose "URI: $uri"
            Write-Verbose "Content-Type: application/xml"
            Write-Verbose "Accept: application/vnd.keepit.v4+xml"
            Write-Verbose "Request Body:`n$Configuration"

            # Make API call using Invoke-WebRequest for raw response handling
            if ($PSCmdlet.ShouldProcess("connector $connectorGuid", 'Submit job')) {
                $webResponse = Invoke-WebRequest -Uri $uri -Method Post -Headers $headers -Body $Configuration -ErrorAction Stop
                $rawContent = $webResponse.Content

                Write-Verbose "=== API Response ==="
                Write-Verbose "Status Code: $($webResponse.StatusCode)"
                Write-Verbose "Content-Type: $($webResponse.Headers.'Content-Type')"
                Write-Verbose "Response Body:`n$rawContent"

                # Parse response - try XML first (matching Bohr's applyDataCallback logic)
                $jobGuid = $null
                $status = 'created'
                $createdAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
                $estimatedItems = 0

                try {
                    $responseXml = [xml]$rawContent

                    # Check for job structure (matches Bohr: jsonData?.job)
                    if ($responseXml.job) {
                        $job = $responseXml.job
                        $jobGuid = if ($job.guid) { $job.guid } else { $null }
                        $status = if ($job.status) { $job.status } else { 'created' }
                        $createdAt = if ($job.created) { $job.created } else { $createdAt }
                        if ($job.'estimated-items') {
                            $estimatedItems = [int]$job.'estimated-items'
                        }
                        Write-Verbose "Parsed job from response.job"
                    }
                    # Check for jobs.job structure (matches Bohr: jsonData?.jobs?.job)
                    elseif ($responseXml.jobs.job) {
                        $jobNode = $responseXml.jobs.job
                        # Handle array case
                        $job = if ($jobNode -is [System.Array]) { $jobNode[0] } else { $jobNode }
                        $jobGuid = if ($job.guid) { $job.guid } else { $null }
                        $status = if ($job.status) { $job.status } else { 'created' }
                        $createdAt = if ($job.created) { $job.created } else { $createdAt }
                        if ($job.'estimated-items') {
                            $estimatedItems = [int]$job.'estimated-items'
                        }
                        Write-Verbose "Parsed job from response.jobs.job"
                    }
                    else {
                        Write-Verbose "Could not find job or jobs.job in response, using defaults"
                    }
                }
                catch {
                    Write-Verbose "Could not parse response as XML: $($_.Exception.Message)"
                }

                # If we still don't have a job GUID, generate a placeholder
                if (-not $jobGuid) {
                    $jobGuid = "job-$($createdAt -replace '[^0-9]','')"
                    Write-Warning "API did not return a job GUID. Using placeholder: $jobGuid"
                }

                # Create output object
                $result = [PSCustomObject]@{
                    JobGuid           = $jobGuid
                    ConnectorGuid     = $connectorGuid
                    Status            = $status
                    CreatedAt         = $createdAt
                    EstimatedItems    = $estimatedItems
                    IsPlaceholderGuid = ($jobGuid -like 'job-*')
                }

                Write-Verbose "=== Job Submitted Successfully ==="
                Write-Verbose "JobGuid: $($result.JobGuid)"
                Write-Verbose "Status: $($result.Status)"
                Write-Verbose "CreatedAt: $($result.CreatedAt)"
                Write-Verbose "EstimatedItems: $($result.EstimatedItems)"

                $result
            }
        }
        catch {
            # Handle HTTP errors
            $errorMessage = $_.Exception.Message
            $errorBody = $null

            if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
                $errorBody = $_.ErrorDetails.Message
                Write-Verbose "Error response body: $errorBody"

                # Try to parse error XML for better error message
                try {
                    $errorXml = [xml]$errorBody
                    if ($errorXml.error) {
                        $errorMessage = "API Error: $($errorXml.error.code) - $($errorXml.error.description)"
                    }
                }
                catch {
                    Write-Verbose "Could not parse error response as XML"
                }
            }

            $connectorIdentifier = if ($connectorGuid) { $connectorGuid } else { $Connector }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to submit job for connector $connectorIdentifier : $errorMessage", $_.Exception),
                    'KeepitJobError',
                    [System.Management.Automation.ErrorCategory]::ConnectionError,
                    $connectorIdentifier
                )
            )
        }
    }

    end {
        Write-Verbose "Submit-KeepitJob completed"
    }
}

<#
.SYNOPSIS
    Calculates the estimated XML size for a set of restore items
.DESCRIPTION
    Internal helper function that estimates the XML job configuration size for a given
    set of items. Used to determine if job batching is needed to stay under the 64KB limit.
.PARAMETER Items
    Array of items to calculate size for. Each item must have an Id property.
.OUTPUTS
    Integer representing the estimated XML size in bytes.
.NOTES
    This is an internal helper function not exported from the module.
#>

function Get-RestoreItemsXmlSize {
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [array]$Items
    )

    # Calculate the size of path elements in bytes (UTF-8)
    $pathElementsSize = 0
    foreach ($item in $Items) {
        $itemPath = $item.Id -replace '^kng://[^/]+', ''
        $pathElementsSize += $XmlBaseOverhead + [System.Text.Encoding]::UTF8.GetByteCount($itemPath)
    }

    return $XmlOverheadPerItem + $pathElementsSize
}

<#
.SYNOPSIS
    Splits items into batches that fit within the XML size limit
.DESCRIPTION
    Internal helper function that divides a large set of restore items into multiple
    batches, ensuring each batch's XML configuration stays under the 60KB threshold.
.PARAMETER Items
    Array of items to split into batches. Each item must have an Id property.
.PARAMETER MaxSizeBytes
    Maximum XML size in bytes per batch. Defaults to 60KB (61440 bytes).
.OUTPUTS
    Array of arrays, where each inner array is a batch of items.
.NOTES
    This is an internal helper function not exported from the module.
#>

function Split-RestoreItemsBatches {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Internal helper; plural accurately describes returning multiple batches')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [array]$Items,

        [Parameter(Mandatory = $false)]
        [int]$MaxSizeBytes = $MaxXmlBatchSize
    )

    $batches = [System.Collections.ArrayList]::new()
    $currentBatch = [System.Collections.ArrayList]::new()
    $currentSize = $XmlOverheadPerItem

    foreach ($item in $Items) {
        $itemPath = $item.Id -replace '^kng://[^/]+', ''
        $itemSize = $XmlBaseOverhead + [System.Text.Encoding]::UTF8.GetByteCount($itemPath)

        # Check if adding this item would exceed the limit
        if (($currentSize + $itemSize) -gt $MaxSizeBytes -and $currentBatch.Count -gt 0) {
            # Save current batch and start a new one
            [void]$batches.Add($currentBatch.ToArray())
            $currentBatch = [System.Collections.ArrayList]::new()
            $currentSize = $XmlOverheadPerItem
        }

        [void]$currentBatch.Add($item)
        $currentSize += $itemSize
    }

    # Add the final batch if it has items
    if ($currentBatch.Count -gt 0) {
        [void]$batches.Add($currentBatch.ToArray())
    }

    return , $batches.ToArray()
}

<#
.SYNOPSIS
    Generates XML job configuration for restore operations
.DESCRIPTION
    Internal helper function that creates the XML job definition for restore operations,
    selecting the appropriate configuration based on the item type being restored.

    Different item types require different FolderRestoreMode settings:
    - email: Uses DeltaAppend mode
    - user: Uses DeltaRestore mode (for Entra ID user objects)
    - OneDrive: Uses DeltaAppend mode (for OneDrive for Business files)
.PARAMETER Type
    The type of items being restored. Valid values: email, user, OneDrive
.PARAMETER SnapshotId
    The snapshot ID to restore from.
.PARAMETER Items
    Array of items to restore. Each item must have an Id property containing the kng:// path.
.OUTPUTS
    String containing the XML job configuration.
.NOTES
    This is an internal helper function not exported from the module.
#>

function New-RestoreJobXml {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal helper; constructs an in-memory XML string only')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('email', 'user', 'OneDrive')]
        [string]$Type,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SnapshotId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [array]$Items
    )

    # Select FolderRestoreMode based on item type
    $folderRestoreMode = switch ($Type) {
        'email'    { 'DeltaAppend' }
        'user'     { 'DeltaRestore' }
        'OneDrive' { 'DeltaAppend' }
    }

    # Build RestorePaths element with one Path per item
    # Strip kng://connector-guid prefix from item ID to get just the path
    $pathElements = ($Items | ForEach-Object {
        $itemPath = $_.Id -replace '^kng://[^/]+', ''
        $escapedPath = [System.Security.SecurityElement]::Escape($itemPath)
        "<Path>$escapedPath</Path>"
    }) -join ''

    # Generate the XML job configuration
    $xmlConfig = @"
<job><description>[srestore] [KeepitPSTools][$Type] Bulk restore of $($Items.Count) items</description><type>srestore</type><immediate/><priority>1</priority><commands><restore><RestoreConfig><SnapshotId>$SnapshotId</SnapshotId><Rules><Mode><FolderRestoreMode>$folderRestoreMode</FolderRestoreMode><FileConflictResolutionMode>Restore</FileConflictResolutionMode><Method>InPlace</Method></Mode><RestorePaths>$pathElements</RestorePaths></Rules></RestoreConfig></restore></commands></job>
"@


    return $xmlConfig
}

<#
.SYNOPSIS
    Resolves snapshots and creates batched restore job plans for grouped items
.DESCRIPTION
    Internal helper that, for each timestamp group in the supplied hashtable, resolves
    the matching snapshot and splits items into batches respecting the XML size limit.
    Returns an array of plan objects that both the WhatIf and normal execution paths consume.
.PARAMETER ItemsByTimestamp
    Hashtable keyed by updated-timestamp, each value an ArrayList of search-result items.
.PARAMETER ConnectorGuid
    The resolved connector GUID used for snapshot lookups.
.PARAMETER Type
    The item type (email, user, OneDrive) used for XML generation.
.OUTPUTS
    Array of PSCustomObjects with properties:
        - Timestamp : The original updated timestamp string
        - SnapshotId : The resolved snapshot ID
        - Batches : Array of item arrays (one per batch)
        - BatchCount : Number of batches
        - ItemCount : Total items in this timestamp group
        - XmlConfigs : Array of XML config strings (one per batch)
.NOTES
    This is an internal helper function not exported from the module.
#>

function Resolve-RestoreJobPlan {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$ItemsByTimestamp,

        [Parameter(Mandatory = $true)]
        [string]$ConnectorGuid,

        [Parameter(Mandatory = $true)]
        [ValidateSet('email', 'user', 'OneDrive')]
        [string]$Type
    )

    $plans = [System.Collections.ArrayList]::new()

    foreach ($timestamp in $ItemsByTimestamp.Keys) {
        $items = $ItemsByTimestamp[$timestamp]

        # Parse the timestamp and search backwards to find snapshot at or before this time
        try {
            $snapshotTime = [DateTime]::Parse(
                $timestamp,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::RoundtripKind
            )
        }
        catch {
            Write-Warning "Could not parse timestamp '$timestamp', skipping group"
            continue
        }

        $snapshotParams = @{
            Connector  = $ConnectorGuid
            StartTime  = $snapshotTime
            EndTime    = $snapshotTime.AddYears(-1)
            Reverse    = $true
            ResultSize = 1
        }

        $snapshot = Get-KeepitSnapshot @snapshotParams | Select-Object -First 1

        if (-not $snapshot) {
            Write-Warning "Could not find snapshot for timestamp '$timestamp', skipping group"
            continue
        }

        $snapshotId = $snapshot.Id
        Write-Verbose "Found snapshot ID: $snapshotId for timestamp: $timestamp"

        # Determine batching
        $estimatedSize = Get-RestoreItemsXmlSize -Items $items
        Write-Verbose "Estimated XML size for $($items.Count) items: $estimatedSize bytes"

        if ($estimatedSize -gt $MaxXmlBatchSize) {
            $batches = Split-RestoreItemsBatches -Items $items -MaxSizeBytes $MaxXmlBatchSize
            $batchCount = $batches.Count
            $avgItemSize = [math]::Round($estimatedSize / $items.Count, 1)
            Write-Verbose "Items exceed $MaxXmlBatchSize bytes - splitting into $batchCount batches (avg item size: $avgItemSize bytes)"
        }
        else {
            $batches = @(, $items)
            $batchCount = 1
        }

        # Generate XML for each batch
        $xmlConfigs = [System.Collections.ArrayList]::new()
        foreach ($batch in $batches) {
            $xmlConfig = New-RestoreJobXml -Type $Type -SnapshotId $snapshotId -Items $batch
            [void]$xmlConfigs.Add($xmlConfig)
        }

        [void]$plans.Add([PSCustomObject]@{
            Timestamp  = $timestamp
            SnapshotId = $snapshotId
            Batches    = $batches
            BatchCount = $batchCount
            ItemCount  = $items.Count
            XmlConfigs = $xmlConfigs.ToArray()
        })
    }

    return , $plans.ToArray()
}

<#
.SYNOPSIS
    Restores bulk deleted items from Keepit backups
.DESCRIPTION
    Searches for deleted items in a specified date range and submits restore jobs to recover them.
    Items are grouped by their snapshot timestamp, and one restore job is submitted per snapshot.
    Currently supports email and user item types.

.PARAMETER UserPrincipalName
    The User Principal Name (UPN) or GUID of the user whose account or items should be restored.
    If a UPN is provided, it will be converted to a GUID using Convert-KeepitUPNToGuid.
    Accepts pipeline input by property name.
    Aliases: UPN, Email, UserId
.PARAMETER Connector
    The connector name or GUID to use for the restore operation.
    Can be piped from Get-KeepitConnector.
.PARAMETER RootPath
    The folder path to search from deleted items, relative to the user's Outlook folder.
    Examples: "Inbox", "Calendar", "Deleted Items"
    This will be expanded to: /Users/{UPN}/Outlook/{RootPath} for mail items
.PARAMETER RestorePath
    The folder path to restore items to. Currently not implemented - items are restored
    in-place to their original location. A warning will be displayed if this parameter is used.
.PARAMETER StartTime
    The start of the date range for searching deleted items.
.PARAMETER EndTime
    The end of the date range for searching deleted items. Must be after StartTime. If you specify StartTime and EndTime as equal, the restore will include items from midnight on the start date until midnight on the following day.
.PARAMETER Type
    The type of items to restore. Valid values: email, user, OneDrive.
    Default is "email".
.PARAMETER SearchTerms
    Optional text to filter deleted items by content before restoring.
    Performs a fuzzy search across item metadata (subject, sender, recipients).
    Use quoted strings for exact match, e.g., '"user@contoso.com"'.
    When omitted, all deleted items in the date range are restored.
    This parameter is passed directly to Search-KeepitSnapshot's -SearchTerms.
.PARAMETER Recursive
    Search recursively in subfolders of RootPath.
    By default, searches only the immediate RootPath. Use -Recursive to include subfolders. Not available when restoring mail.
.PARAMETER ShowJobs
    When specified, prints the XML job configuration blob for each restore job.
    Works with both -WhatIf (to see what would be submitted) and normal execution (to see what was submitted).
.EXAMPLE
    Restore-KeepitBulkDeletedItems -UserPrincipalName "user@example.com" -Connector "Production M365" -RootPath "Inbox" -StartTime "2026-01-01" -EndTime "2026-01-15"

    Restores all deleted items from the user's Inbox for the period 1-15 January 2026
.EXAMPLE
    Import-Csv users.csv | Restore-KeepitBulkDeletedItems -Connector "abc123-def456" -RootPath "Inbox" -StartTime "2026-01-01" -EndTime "2026-01-15"

    Restores deleted items for multiple users from a CSV file with UserPrincipalName, UPN, or Email column
.EXAMPLE
    Restore-KeepitBulkDeletedItems -UPN "user@example.com" -Connector "Production M365" -RootPath "Deleted Items" -StartTime (Get-Date).AddDays(-30) -EndTime (Get-Date) -WhatIf

    Shows what would be restored from the Deleted Items folder for the last 30 days without actually restoring
.EXAMPLE
    Restore-KeepitBulkDeletedItems -Connector "M365 Prod" -UserPrincipalName "jsmith@contoso.com" -RootPath "Inbox" -StartTime "2026-01-01" -EndTime "2026-03-01" -SearchTerms '"ceo@contoso.com"' -WhatIf

    Shows a preview of deleted items in jsmith's Inbox that match "ceo@contoso.com" (emails from or to the CEO) in the specified date range.
.OUTPUTS
    With -WhatIf: PSCustomObject with properties (jobs are NOT submitted):
        - TotalItems: Total number of items that would be restored
        - JobCount: Number of restore jobs that would be created
        - ItemsBySnapshot: Hashtable showing item counts per snapshot timestamp

    Without -WhatIf: Array of PSCustomObjects containing job results (jobs ARE submitted):
        - JobGuid: The GUID of the created restore job
        - ConnectorGuid: The connector GUID
        - SnapshotId: The snapshot ID used for this restore
        - SnapshotTime: The snapshot timestamp
        - ItemCount: Number of items in this restore job
        - Status: Job status
        - CreatedAt: Timestamp when the job was created
.NOTES
    Requires an active connection via Connect-KeepitService.
    Items are restored in-place to their original location.
    One restore job is created per unique snapshot timestamp to optimize the restore process.
#>

function Restore-KeepitBulkDeletedItems {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Public API name; renaming would be a breaking change')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Intentional: WhatIf and diagnostic output uses Write-Host for colored console formatting')]
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('UPN', 'Email', 'UserId')]
        [string]$UserPrincipalName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('ConnectorGuid', 'Name')]
        [string]$Connector,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RootPath,

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

        [Parameter(Mandatory = $true)]
        [DateTime]$StartTime,

        [Parameter(Mandatory = $true)]
        [DateTime]$EndTime,

        [Parameter(Mandatory = $false)]
        [ValidateSet('email', 'user', 'OneDrive')]
        [string]$Type = 'email',

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

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

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

    begin {
        Write-Verbose "=== Restore-KeepitBulkDeletedItems: Initialization ==="

        # Validate date range (allow same-day for whole-day searches)
        if ($EndTime -lt $StartTime) {
            throw "EndTime cannot be before StartTime. StartTime: $($StartTime.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture)), EndTime: $($EndTime.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture))"
        }

        # Normalize date range into local variables to avoid mutating the original parameters
        if ($StartTime.Date -eq $EndTime.Date) {
            Write-Verbose "StartTime and EndTime are the same date - expanding to full day"
            $normalizedStartTime = $StartTime.Date  # Midnight start
            $normalizedEndTime = $EndTime.Date.AddDays(1).AddSeconds(-1)  # 23:59:59
            Write-Verbose "Expanded range: $($normalizedStartTime.ToString('yyyy-MM-ddTHH:mm:ss', [System.Globalization.CultureInfo]::InvariantCulture)) to $($normalizedEndTime.ToString('yyyy-MM-ddTHH:mm:ss', [System.Globalization.CultureInfo]::InvariantCulture))"
        }
        else {
            $normalizedStartTime = $StartTime
            $normalizedEndTime = $EndTime
        }

        # Warn about RestorePath not being implemented
        if ($RestorePath) {
            Write-Warning "RestorePath parameter is not yet implemented. Items will be restored in-place to their original location."
        }

        # Get authentication header and base URL
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl
            Write-Verbose "Base URL: $baseUrl, User ID: $userId"
        }
        catch {
            throw
        }
    }

    process {
        try {
            # Resolve connector identity to GUID
            $resolved = Resolve-KeepitConnectorIdentity -Identity $Connector
            $connectorGuid = $resolved.ConnectorGuid

            Write-Verbose "=== Restore-KeepitBulkDeletedItems: Processing ==="
            Write-Verbose "UserPrincipalName: $UserPrincipalName"
            Write-Verbose "Connector: $($resolved.Name) ($connectorGuid)"
            Write-Verbose "RootPath: $RootPath"
            Write-Verbose "StartTime: $StartTime"
            Write-Verbose "EndTime: $EndTime"
            Write-Verbose "Type: $Type"
            if ($SearchTerms) {
                Write-Verbose "SearchTerms: $SearchTerms"
            }

            # Step 1: Construct the path using the UPN. Search-KeepitSnapshot
            # handles UPN-to-GUID conversion internally, so we pass the UPN
            # directly in the path to avoid double-dash GUID format issues.
            # For user type, we filter by UPN in the Title field instead,
            # since the user may have been recreated with a new GUID after deletion.
            $userIdentity = $UserPrincipalName
            $cleanRootPath = $RootPath.Trim('/')

            if ($Type -eq 'email') {
                # For email items, the path is under Outlook
                $pathRoot = "/Users/$userIdentity/Outlook/$cleanRootPath"
            } elseif ($Type -eq 'user') {
                # For user items, search at /Users level to find user objects
                # Results will be filtered by UPN after search
                $pathRoot = "/Users"
            } elseif ($Type -eq 'OneDrive') {
                # this is a little tricky. Some devices will have a path of /Users/{userGuid}/OneDrive, others /users/{userGuid}/OneDriveSP/DocLibs/Documents/Content
                # there's no way for us to tell in advance which path the device / user combo will have so we will have to check both
                # Start with the user-provided path under the user's folder
                $pathRoot = "/Users/$userIdentity/$cleanRootPath"
            }
            Write-Verbose "Constructed RootPath: $pathRoot"

            # Step 3: Search for deleted items
            Write-Verbose "Searching for deleted items..."
            $searchParams = @{
                Connector   = $connectorGuid
                RootPath    = $pathRoot
                DeletedOnly = $true
                ResultSize  = 'Unlimited'
                StartTime   = $normalizedStartTime
                EndTime     = $normalizedEndTime
            }
            if ($SearchTerms) {
                $searchParams.SearchTerms = $SearchTerms
            }
            if ($Type -eq 'user') {
                $searchParams.Recursive = $false
            } else {
                if ($Recursive) {
                    $searchParams.Recursive = $true
                }
            }

            $deletedItems = @(Search-KeepitSnapshot @searchParams)

            # if we're doing OneDrive, and there were no results, this might be becaue of the path issue.
            # if those are both true, check the path the user supplied, swap to the other style, and search again
            if ($Type -eq 'OneDrive' -and $deletedItems.Count -eq 0) {
                Write-Verbose "No deleted items found for OneDrive - trying alternate path format..."
                if ($cleanRootPath -like 'OneDrive*') {
                    # user supplied /OneDrive*, try /OneDriveSP/DocLibs/Documents/Content
                    $altPathRoot = "/Users/$userIdentity/OneDriveSP/DocLibs/Documents/Content"
                } else {
                    # user supplied /OneDriveSP/DocLibs/Documents/Content, try /OneDrive
                    $altPathRoot = "/Users/$userIdentity/OneDrive"
                }
                Write-Verbose "Trying alternate RootPath: $altPathRoot"
                $searchParams.RootPath = $altPathRoot
                $deletedItems = @(Search-KeepitSnapshot @searchParams)
            }
            Write-Verbose "Found $($deletedItems.Count) deleted items"

            # For user type, filter results by UPN in the Title field
            # This handles cases where a user was recreated with a new GUID after deletion
            if ($Type -eq 'user') {
                # Filter by UPN in Title (format: "Display Name - user@domain.com")
                $deletedItems = @($deletedItems | Where-Object { $_.Title -like "*$UserPrincipalName*" })
                Write-Verbose "After filtering for UPN '$UserPrincipalName' in Title: $($deletedItems.Count) items"
            }

            if ($deletedItems.Count -eq 0) {
                Write-Warning "No deleted items found for user '$UserPrincipalName' in the specified date range."
                return
            }

            # Step 4: Group items by their <updated> timestamp
            Write-Verbose "Grouping items by snapshot timestamp..."
            $itemsByTimestamp = @{}
            foreach ($item in $deletedItems) {
                # Get the updated timestamp from the item
                $updated = $item.Updated
                if (-not $updated) {
                    Write-Verbose "Item $($item.Id) has no Updated timestamp, skipping"
                    continue
                }

                if (-not $itemsByTimestamp.ContainsKey($updated)) {
                    $itemsByTimestamp[$updated] = [System.Collections.ArrayList]::new()
                }
                [void]$itemsByTimestamp[$updated].Add($item)
            }

            Write-Verbose "Grouped items into $($itemsByTimestamp.Count) snapshot groups"

            # Step 5: Handle WhatIf
            if ($WhatIfPreference) {
                $itemCounts = @{}
                $batchCounts = @{}
                $totalJobCount = 0

                foreach ($key in $itemsByTimestamp.Keys) {
                    $groupItems = $itemsByTimestamp[$key]
                    $itemCounts[$key] = $groupItems.Count

                    # Calculate if batching would be needed
                    $estimatedSize = Get-RestoreItemsXmlSize -Items $groupItems
                    if ($estimatedSize -gt $MaxXmlBatchSize) {
                        $batches = Split-RestoreItemsBatches -Items $groupItems -MaxSizeBytes $MaxXmlBatchSize
                        $batchCounts[$key] = $batches.Count
                        $totalJobCount += $batches.Count
                    } else {
                        $batchCounts[$key] = 1
                        $totalJobCount += 1
                    }
                }

                $whatIfResult = [PSCustomObject]@{
                    TotalItems        = $deletedItems.Count
                    SnapshotGroups    = $itemsByTimestamp.Count
                    TotalJobCount     = $totalJobCount
                    ItemsBySnapshot   = $itemCounts
                    BatchesBySnapshot = $batchCounts
                }

                Write-Host "WhatIf: Would restore $($deletedItems.Count) items in $totalJobCount restore job(s) across $($itemsByTimestamp.Count) snapshot group(s)"
                foreach ($key in $itemsByTimestamp.Keys | Sort-Object) {
                    $batchInfo = if ($batchCounts[$key] -gt 1) { " ($($batchCounts[$key]) batches due to size limit)" } else { "" }
                    Write-Host " Snapshot $key : $($itemsByTimestamp[$key].Count) items$batchInfo"
                    foreach ($item in $itemsByTimestamp[$key]) {
                        $title = if ($item.Title) { $item.Title } else { $item.Id }
                        Write-Host " + $title"
                    }
                }

                # Generate and display XML blobs for WhatIf only if ShowJobs is specified
                if ($ShowJobs) {
                    Write-Host ""
                    Write-Host "XML job configurations that would be submitted:"
                    foreach ($timestamp in $itemsByTimestamp.Keys | Sort-Object) {
                        $items = $itemsByTimestamp[$timestamp]

                        # Find the matching snapshot
                        try {
                            $snapshotTime = [DateTime]::Parse($timestamp, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)
                        }
                        catch {
                            Write-Warning "Could not parse timestamp '$timestamp', skipping group"
                            continue
                        }

                        # Search backwards from item timestamp to find the snapshot at or before this time
                        $snapshotParams = @{
                            Connector   = $connectorGuid
                            StartTime   = $snapshotTime
                            EndTime     = $snapshotTime.AddYears(-1)
                            Reverse     = $true
                            ResultSize  = 1
                        }

                        $snapshot = Get-KeepitSnapshot @snapshotParams | Select-Object -First 1

                        if (-not $snapshot) {
                            Write-Warning "Could not find snapshot for timestamp '$timestamp', skipping group"
                            continue
                        }

                        $snapshotId = $snapshot.Id

                        # Check if batching is needed and generate XML for each batch
                        $estimatedSize = Get-RestoreItemsXmlSize -Items $items
                        if ($estimatedSize -gt $MaxXmlBatchSize) {
                            $batches = Split-RestoreItemsBatches -Items $items -MaxSizeBytes $MaxXmlBatchSize
                            $batchIndex = 0
                            foreach ($batch in $batches) {
                                $batchIndex++
                                $xmlConfig = New-RestoreJobXml -Type $Type -SnapshotId $snapshotId -Items $batch
                                Write-Host ""
                                Write-Host "Job XML for snapshot $timestamp (batch $batchIndex of $($batches.Count)):"
                                $xmlConfig | Out-Host
                            }
                        } else {
                            $xmlConfig = New-RestoreJobXml -Type $Type -SnapshotId $snapshotId -Items $items
                            Write-Host ""
                            Write-Host "Job XML for snapshot $timestamp :"
                            $xmlConfig | Out-Host
                        }
                    }
                }

                return $whatIfResult
            }

            # Step 6: For each group, find the matching snapshot and create restore job
            $jobResults = [System.Collections.ArrayList]::new()

            foreach ($timestamp in $itemsByTimestamp.Keys) {
                $items = $itemsByTimestamp[$timestamp]
                Write-Verbose "Processing snapshot group: $timestamp with $($items.Count) items"

                # Find the matching snapshot
                # Parse the timestamp and search backwards to find snapshot at or before this time
                try {
                    $snapshotTime = [DateTime]::Parse($timestamp, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)
                }
                catch {
                    Write-Warning "Could not parse timestamp '$timestamp', skipping group"
                    continue
                }

                $snapshotParams = @{
                    Connector   = $connectorGuid
                    StartTime   = $snapshotTime
                    EndTime     = $snapshotTime.AddYears(-1)
                    Reverse     = $true
                    ResultSize  = 1
                }

                $snapshot = Get-KeepitSnapshot @snapshotParams | Select-Object -First 1

                if (-not $snapshot) {
                    Write-Warning "Could not find snapshot for timestamp '$timestamp', skipping group"
                    continue
                }

                $snapshotId = $snapshot.Id
                Write-Verbose "Found snapshot ID: $snapshotId"

                # Step 7: Check if items need to be batched due to XML size limits
                $estimatedSize = Get-RestoreItemsXmlSize -Items $items
                Write-Verbose "Estimated XML size for $($items.Count) items: $estimatedSize bytes"

                if ($estimatedSize -gt $MaxXmlBatchSize) {
                    # Split items into batches
                    $batches = Split-RestoreItemsBatches -Items $items -MaxSizeBytes $MaxXmlBatchSize
                    $batchCount = $batches.Count
                    $avgItemSize = [math]::Round($estimatedSize / $items.Count, 1)
                    Write-Verbose "Items exceed $MaxXmlBatchSize bytes - splitting into $batchCount batches (avg item size: $avgItemSize bytes)"
                } else {
                    # Single batch with all items
                    $batches = @(, $items)
                    $batchCount = 1
                }

                # Process each batch
                $batchIndex = 0
                foreach ($batch in $batches) {
                    $batchIndex++
                    $batchLabel = if ($batchCount -gt 1) { " (batch $batchIndex of $batchCount)" } else { "" }

                    # Generate XML job configuration using helper function
                    $xmlConfig = New-RestoreJobXml -Type $Type -SnapshotId $snapshotId -Items $batch

                    Write-Verbose "Created XML configuration for snapshot $snapshotId$batchLabel - $($batch.Count) items"
                    Write-Verbose "XML Config:`n$xmlConfig"

                    # Show XML blob if ShowJobs is specified
                    if ($ShowJobs) {
                        Write-Host "`nJob XML for snapshot ${timestamp}${batchLabel}:" -ForegroundColor Cyan
                        Write-Host $xmlConfig -ForegroundColor Yellow
                        Write-Host ""
                    }

                    # Step 8: Submit the job
                    if ($PSCmdlet.ShouldProcess("Connector $connectorGuid", "Submit restore job for $($batch.Count) items from snapshot $timestamp$batchLabel")) {
                        $submitParams = @{
                            Connector     = $connectorGuid
                            Configuration = $xmlConfig
                        }

                        $jobResult = Submit-KeepitJob @submitParams

                        # Enhance result with additional info
                        $enhancedResult = [PSCustomObject]@{
                            JobGuid       = $jobResult.JobGuid
                            ConnectorGuid = $jobResult.ConnectorGuid
                            SnapshotId    = $snapshotId
                            SnapshotTime  = $timestamp
                            ItemCount     = $batch.Count
                            BatchNumber   = if ($batchCount -gt 1) { $batchIndex } else { $null }
                            TotalBatches  = if ($batchCount -gt 1) { $batchCount } else { $null }
                            Status        = $jobResult.Status
                            CreatedAt     = $jobResult.CreatedAt
                        }

                        [void]$jobResults.Add($enhancedResult)
                        Write-Verbose "Submitted job $($jobResult.JobGuid) for $($batch.Count) items$batchLabel"
                    }
                }
            }

            Write-Verbose "=== Restore-KeepitBulkDeletedItems: Complete ==="
            Write-Verbose "Submitted $($jobResults.Count) restore jobs"

            # Return job results
            $jobResults.ToArray()
        }
        catch {
            $connectorIdentifier = if ($connectorGuid) { $connectorGuid } else { $Connector }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to restore deleted items: $($_.Exception.Message)", $_.Exception),
                    'KeepitRestoreError',
                    [System.Management.Automation.ErrorCategory]::ConnectionError,
                    $connectorIdentifier
                )
            )
        }
    }

    end {
        Write-Verbose "Restore-KeepitBulkDeletedItems completed"
    }
}


<#
.SYNOPSIS
    Converts a User Principal Name (UPN) to a Keepit backup GUID
.DESCRIPTION
    Uses the Keepit BSearch API to look up a user by their UPN (email address) and
    returns the corresponding GUID used in backup paths. Keepit snapshots use GUIDs
    to refer to users (e.g., /Users/xxxxx-yy-zzzzzz/Outlook/Inbox) for anonymization.
    This cmdlet allows you to convert a human-readable UPN to the internal GUID.
.PARAMETER UserPrincipalName
    The User Principal Name (UPN) to look up, typically an email address.
    Accepts pipeline input directly or by property name.
    Aliases: UPN, Id, Email, Identity
    Example: user@example.com
.PARAMETER Connector
    The name or GUID of the Keepit connector (device) to search within.
    Can be piped from Get-KeepitConnector. Aliases: ConnectorGuid, Name
.EXAMPLE
    Convert-KeepitUPNToGuid -UserPrincipalName "paulr@blackdotpub.com" -Connector "abc123-def456"

    Looks up the GUID for paulr@blackdotpub.com in the specified connector
.EXAMPLE
    "user1@example.com", "user2@example.com" | Convert-KeepitUPNToGuid -Connector "ExO Only"

    Looks up GUIDs for multiple users via pipeline using connector name
.EXAMPLE
    Import-Csv users.csv | Convert-KeepitUPNToGuid -Connector "abc123-def456"

    Looks up GUIDs for users from a CSV file with UPN, Email, or UserPrincipalName column
.EXAMPLE
    Get-KeepitConnector | ForEach-Object { Convert-KeepitUPNToGuid -UserPrincipalName "user@example.com" -Connector $_.ConnectorGuid }

    Searches for a user across all connectors
.EXAMPLE
    $result = Convert-KeepitUPNToGuid -UserPrincipalName "user@example.com" -Connector "Production M365"
    $result.Guid

    Gets just the GUID value from the result
.OUTPUTS
    PSCustomObject with properties:
        - UserPrincipalName: The input UPN
        - Guid: The Keepit backup GUID for the user
    Returns $null if the UPN is not found.
.NOTES
    Requires an active connection via Connect-KeepitService.
    The search is performed against the /Users path root in the backup.
#>

function Convert-KeepitUPNToGuid {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('UPN', 'Id', 'Email', 'Identity')]
        [string]$UserPrincipalName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('ConnectorGuid', 'Name')]
        [string]$Connector
    )

    begin {
        Write-Verbose "Convert-KeepitUPNToGuid: Initializing"

        # Get auth info once for all pipeline items (connector resolved per-item in process block)
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl
            Write-Verbose "Base URL: $baseUrl, User ID: $userId"
        }
        catch {
            throw
        }
    }

    process {
        try {
            # Resolve connector per pipeline item so different ConnectorGuid values are handled
            $resolved = Resolve-KeepitConnectorIdentity -Identity $Connector
            $connectorGuid = $resolved.ConnectorGuid
            Write-Verbose "Connector: $($resolved.Name) ($connectorGuid)"

            Write-Verbose "Looking up GUID for UPN: $UserPrincipalName in connector: $connectorGuid"

            # Build bsearch query parameters matching Bohr's implementation
            # URL: /users/{userId}/bsearch?apiVersion=2&count=1&startIndex=0&pathRoot=/Users&device={connectorGUID}&filterOr=AND:!sys;&searchTerms="{upn}"
            $encodedUPN = [System.Uri]::EscapeDataString("`"$UserPrincipalName`"")
            $queryParams = @(
                "apiVersion=2",
                "count=1",
                "startIndex=0",
                "pathRoot=/Users",
                "device=$connectorGuid",
                "filterOr=AND:!sys;",
                "searchTerms=$encodedUPN"
            )
            $queryString = $queryParams -join '&'
            $uri = "$baseUrl/users/$userId/bsearch?$queryString"

            Write-Verbose "Request URI: $uri"

            # Headers for the request
            $headers = @{
                'Authorization' = $authHeader
                'Content-Type'  = 'application/json'
                'Accept'        = 'application/json'
            }

            # Make API call - use Invoke-WebRequest for raw response
            $webResponse = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers -ErrorAction Stop
            $rawContent = $webResponse.Content

            # Handle byte array response (PowerShell 7 may return byte[] for some content types)
            if ($rawContent -is [byte[]]) {
                $rawContent = [System.Text.Encoding]::UTF8.GetString($rawContent)
            }

            Write-Verbose "Response Status: $($webResponse.StatusCode)"
            Write-Verbose "Response Content-Type: $($webResponse.Headers.'Content-Type')"
            if ($rawContent) {
                Write-Verbose "Raw Content (first 500 chars): $($rawContent.Substring(0, [Math]::Min(500, $rawContent.Length)))"
            }
            else {
                Write-Verbose "Raw Content: (empty)"
            }

            # Extract GUID from <kng:name> tag using regex (matching Bohr's approach)
            $guid = $null

            if ($rawContent -match '<kng:name>([^<]+)</kng:name>') {
                $guid = $Matches[1]
                Write-Verbose "Found GUID: $guid"
            }

            if (-not $guid) {
                Write-Warning "No GUID found for UPN '$UserPrincipalName' in connector '$connectorGuid'"
                return $null
            }

            # Return the raw GUID — path masking (dash doubling) is handled
            # by ConvertTo-MaskedPath in Search-KeepitSnapshot, so we must
            # not double dashes here or they get quadrupled.

            # Return result object
            [PSCustomObject]@{
                UserPrincipalName = $UserPrincipalName
                Guid              = $guid
            }
        }
        catch {
            $connectorIdentifier = if ($connectorGuid) { $connectorGuid } else { $Connector }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to look up UPN '$UserPrincipalName' in connector '$connectorIdentifier': $($_.Exception.Message)", $_.Exception),
                    'KeepitApiError',
                    [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                    $UserPrincipalName
                )
            )
        }
    }

    end {
        Write-Verbose "Convert-KeepitUPNToGuid completed"
    }
}

<#
.SYNOPSIS
    Converts a Keepit backup GUID to a User Principal Name (UPN)
.DESCRIPTION
    Resolves one or more Keepit path-masked GUIDs (as found in backup paths such as
    /Users/{guid}/Outlook) back to the corresponding User Principal Names. The function
    performs two BSearch calls in the begin block – one for active users and one for
    deleted users – and builds an in-memory lookup table that is reused for every GUID
    in the pipeline. This makes it efficient for bulk resolution.

    The GUID may be provided in either path-masked form (double dashes, as returned by
    Search-KeepitSnapshot or EverCovered.ps1) or in standard UUID form (single dashes).
    Both are accepted and normalised automatically.
.PARAMETER Guid
    The Keepit backup GUID to resolve. Accepts path-masked GUIDs
    (e.g. bf06910a--a25b--42ef--b656--260b4592db40) or standard UUID format.
    Accepts pipeline input directly or by property name.
    Aliases: UserGUID, Id
.PARAMETER Connector
    The name or GUID of the Keepit connector to resolve against.
    Aliases: ConnectorGuid, Name
.EXAMPLE
    Convert-KeepitGuidToUPN -Guid "bf06910a--a25b--42ef--b656--260b4592db40" -Connector "Production M365"

    Resolves a single GUID to its UPN.
.EXAMPLE
    $guids | Convert-KeepitGuidToUPN -Connector "Production M365"

    Resolves multiple GUIDs via the pipeline using only two BSearch API calls.
.EXAMPLE
    Import-Csv covered.csv | Select-Object -ExpandProperty UserGUID |
        Convert-KeepitGuidToUPN -Connector "abc123-def456"

    Resolves all GUIDs from an EverCovered report.
.OUTPUTS
    PSCustomObject with properties:
        - Guid : The input GUID (preserved as supplied)
        - UserPrincipalName : The resolved UPN, or $null if not found
.NOTES
    Requires an active connection via Connect-KeepitService.
    The lookup is performed against the /Users path on the specified connector.
    Only two BSearch calls are made per cmdlet invocation, regardless of the number
    of GUIDs processed.
#>

function Convert-KeepitGuidToUPN {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, Position = 0,
            ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('UserGUID', 'Id')]
        [string]$Guid,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('ConnectorGuid', 'Name')]
        [string]$Connector
    )

    begin {
        Write-Verbose "Convert-KeepitGuidToUPN: Initializing"

        # Lookup table is built per-connector in process; cache connector GUID to avoid
        # rebuilding when successive pipeline items share the same connector.
        $guidToUpnMap = $null
        $lastConnectorGuid = $null
    }

    process {
        # Resolve connector per pipeline item to support multi-connector pipelines
        try {
            $resolved      = Resolve-KeepitConnectorIdentity -Identity $Connector
            $connectorGuid = $resolved.ConnectorGuid
            Write-Verbose "Connector: $($resolved.Name) ($connectorGuid)"
        }
        catch {
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to resolve connector '$Connector': $($_.Exception.Message)", $_.Exception),
                    'KeepitApiError',
                    [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                    $Connector
                )
            )
        }

        # Rebuild the GUID -> UPN lookup table when the connector changes
        if ($connectorGuid -ne $lastConnectorGuid) {
            $guidToUpnMap = [System.Collections.Generic.Dictionary[string, string]]::new(
                [System.StringComparer]::OrdinalIgnoreCase
            )
            $lastConnectorGuid = $connectorGuid

            # Active users
            try {
                $activeUsers = @(
                    Search-KeepitSnapshot -Connector $connectorGuid `
                        -RootPath '/Users' `
                        -ResultSize Unlimited `
                        -WarningAction SilentlyContinue `
                        -ErrorAction Stop
                )
                foreach ($u in $activeUsers) {
                    if (-not [string]::IsNullOrWhiteSpace($u.Name)) {
                        $guidToUpnMap[$u.Name] = $u.Title
                    }
                }
                Write-Verbose "Loaded $($activeUsers.Count) active user entry/entries"
            }
            catch {
                Write-Warning "Could not fetch active user list: $($_.Exception.Message)"
            }

            # Deleted users (previously backed up, since removed from connector)
            try {
                $deletedUsers = @(
                    Search-KeepitSnapshot -Connector $connectorGuid `
                        -RootPath '/Users' `
                        -DeletedOnly `
                        -ResultSize Unlimited `
                        -WarningAction SilentlyContinue `
                        -ErrorAction Stop
                )
                foreach ($u in $deletedUsers) {
                    if (-not [string]::IsNullOrWhiteSpace($u.Name) -and
                        -not $guidToUpnMap.ContainsKey($u.Name)) {
                        $guidToUpnMap[$u.Name] = $u.Title
                    }
                }
                Write-Verbose "Loaded $($deletedUsers.Count) deleted user entry/entries"
            }
            catch {
                Write-Warning "Could not fetch deleted user list (skipping): $($_.Exception.Message)"
            }

            Write-Verbose "GUID lookup table contains $($guidToUpnMap.Count) entry/entries"
        }

        # Normalise: convert path-masked double-dashes back to single dashes for lookup.
        # This reverses the Keepit path-masking convention where single dashes in GUIDs
        # are doubled (e.g. bf06910a--a25b--42ef becomes bf06910a-a25b-42ef). This is
        # appropriate because GUIDs never contain legitimate double-dash sequences.
        $rawGuid = $Guid -replace '--', '-'

        $upn = $null
        if (-not $guidToUpnMap.TryGetValue($rawGuid, [ref]$upn)) {
            Write-Verbose "No UPN found for GUID '$Guid'"
        }
        else {
            Write-Verbose "Resolved '$rawGuid' -> '$upn'"
        }

        [PSCustomObject]@{
            Guid              = $Guid
            UserPrincipalName = $upn
        }
    }

    end {
        Write-Verbose "Convert-KeepitGuidToUPN completed"
    }
}


<#
.SYNOPSIS
    Performs an express restore of recent user data from Keepit backups
.DESCRIPTION
    Searches for items modified within a specified time window and submits restore jobs
    to recover them. Items are grouped by snapshot timestamp and one restore job is submitted
    per snapshot group. Jobs exceeding 60 KB of XML are automatically split into batches.

    For Exchange workloads, the -PrioritizeCalendar switch creates separate calendar restore
    jobs before processing other mail folders. The -InboxOnly switch restricts the mail restore
    to the Inbox folder only.

    Phase 1 supports the Exchange workload. OneDrive support is planned for Phase 2.
.PARAMETER UserPrincipalName
    The User Principal Name (UPN) of the target user whose data should be restored.
    Accepts pipeline input by property name.
    Aliases: UPN, Email, UserId
.PARAMETER Connector
    The connector name or GUID to use for the restore operation. Must be an M365 connector.
    Can be piped from Get-KeepitConnector. Aliases: ConnectorGuid, Name
.PARAMETER StartTime
    The anchor time for the restore window. Items modified between (StartTime - Timespan) and
    StartTime are restored. Defaults to the current time if omitted.
.PARAMETER Timespan
    Duration of the restore window. Accepts a PowerShell TimeSpan object or an ISO 8601 duration
    string (e.g., "P7D" for 7 days, "P1M" for 1 month, "PT12H" for 12 hours).
.PARAMETER Workload
    The workload to restore. Currently supports "Exchange". "OneDrive" is planned for Phase 2.
.PARAMETER PrioritizeCalendar
    When specified with -Workload Exchange, creates separate restore jobs for the Calendar folder
    first, then processes the remaining mail folders (excluding Calendar to avoid duplicates).
.PARAMETER InboxOnly
    When specified with -Workload Exchange, restricts the mail restore to the Inbox folder and
    its subitems only. When not set, all mail folders under Outlook are restored (Inbox, Sent
    Items, Drafts, etc.).
.PARAMETER ShowJobs
    When specified, prints the XML job configuration blob for each restore job.
    Works with both -WhatIf and normal execution.
.EXAMPLE
    Start-KeepitExpressRestore -UserPrincipalName "user@example.com" -Connector "Production M365" -Workload Exchange -Timespan "P7D"

    Restores all Exchange items modified in the last 7 days for the specified user
.EXAMPLE
    Start-KeepitExpressRestore -UPN "user@example.com" -Connector "Production M365" -Workload Exchange -Timespan ([TimeSpan]::FromDays(7)) -PrioritizeCalendar

    Restores Calendar items first, then other mail items from the last 7 days
.EXAMPLE
    Import-Csv users.csv | Start-KeepitExpressRestore -Connector "abc123" -Workload Exchange -Timespan "P7D" -InboxOnly -WhatIf

    Shows what would be restored from the Inbox for multiple users without submitting jobs
.OUTPUTS
    With -WhatIf: PSCustomObject with properties (jobs are NOT submitted):
        - TotalItems: Total number of items that would be restored
        - SnapshotGroups: Number of unique snapshot timestamp groups
        - TotalJobCount: Number of restore jobs that would be created
        - ItemsBySnapshot: Hashtable showing item counts per snapshot timestamp
        - BatchesBySnapshot: Hashtable showing batch counts per snapshot timestamp

    Without -WhatIf: Array of PSCustomObjects containing job results (jobs ARE submitted):
        - JobGuid: The GUID of the created restore job
        - ConnectorGuid: The connector GUID
        - SnapshotId: The snapshot ID used for this restore
        - SnapshotTime: The snapshot timestamp
        - ItemCount: Number of items in this restore job
        - BatchNumber: Batch number if split (null if single batch)
        - TotalBatches: Total batches for this snapshot (null if single batch)
        - Status: Job status
        - CreatedAt: Timestamp when the job was created
.NOTES
    Requires an active connection via Connect-KeepitService.
    Items are restored in-place to their original location.
    One restore job is created per unique snapshot timestamp to optimize the restore process.
#>

function Start-KeepitExpressRestore {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Intentional: WhatIf and diagnostic output uses Write-Host for colored console formatting')]
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('UPN', 'Email', 'UserId')]
        [string]$UserPrincipalName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('ConnectorGuid', 'Name')]
        [string]$Connector,

        [Parameter(Mandatory = $false)]
        [DateTime]$StartTime,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        $Timespan,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Exchange')]
        [string]$Workload,

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

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

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

    begin {
        Write-Verbose "=== Start-KeepitExpressRestore: Initialization ==="

        # Default StartTime to now if not specified
        if (-not $PSBoundParameters.ContainsKey('StartTime')) {
            $StartTime = [DateTime]::UtcNow
            Write-Verbose "StartTime not specified, using current time: $($StartTime.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture))"
        }

        # Parse Timespan: accept [TimeSpan] or ISO 8601 duration string
        if ($Timespan -is [TimeSpan]) {
            $resolvedTimespan = $Timespan
        }
        elseif ($Timespan -is [string]) {
            try {
                $resolvedTimespan = [System.Xml.XmlConvert]::ToTimeSpan($Timespan)
            }
            catch {
                throw "Invalid ISO 8601 duration string '$Timespan'. Examples: P7D (7 days), P1M (1 month), PT12H (12 hours)."
            }
        }
        else {
            throw "Timespan must be a [TimeSpan] object or an ISO 8601 duration string. Got: $($Timespan.GetType().Name)"
        }

        if ($resolvedTimespan.TotalSeconds -le 0) {
            throw "Timespan must be a positive duration."
        }

        # Calculate the search window
        $searchEnd = $StartTime
        $searchStart = $StartTime - $resolvedTimespan

        Write-Verbose "Restore window: $($searchStart.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)) to $($searchEnd.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture))"

        # Validate workload-specific switches
        if ($PrioritizeCalendar -and $Workload -ne 'Exchange') {
            throw "The -PrioritizeCalendar switch is only valid when -Workload is Exchange."
        }
        if ($InboxOnly -and $Workload -ne 'Exchange') {
            throw "The -InboxOnly switch is only valid when -Workload is Exchange."
        }

        # Get authentication header and base URL
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl
            Write-Verbose "Base URL: $baseUrl, User ID: $userId"
        }
        catch {
            throw
        }
    }

    process {
        try {
            # Resolve connector identity to GUID
            $resolved = Resolve-KeepitConnectorIdentity -Identity $Connector
            $connectorGuid = $resolved.ConnectorGuid

            Write-Verbose "=== Start-KeepitExpressRestore: Processing ==="
            Write-Verbose "UserPrincipalName: $UserPrincipalName"
            Write-Verbose "Connector: $($resolved.Name) ($connectorGuid)"
            Write-Verbose "Workload: $Workload"

            # Use UPN directly for path construction. Search-KeepitSnapshot
            # handles UPN-to-GUID conversion internally.
            $userIdentity = $UserPrincipalName

            $jobResults = [System.Collections.ArrayList]::new()
            $whatIfItems = [System.Collections.ArrayList]::new()

            # --- Calendar priority pass ---
            if ($PrioritizeCalendar) {
                Write-Verbose "PrioritizeCalendar: Searching Calendar folder..."
                $calendarPath = "/Users/$userIdentity/Outlook/Calendar"

                $calSearchParams = @{
                    Connector       = $connectorGuid
                    RootPath        = $calendarPath
                    Recursive       = $true
                    ResultSize      = 'Unlimited'
                    ReceivedTime    = $searchStart
                    ReceivedEndTime = $searchEnd
                }

                $calendarItems = @(Search-KeepitSnapshot @calSearchParams)
                Write-Verbose "Found $($calendarItems.Count) calendar items"

                if ($calendarItems.Count -gt 0) {
                    $calResult = Submit-ExpressRestoreJobs `
                        -Items $calendarItems `
                        -ConnectorGuid $connectorGuid `
                        -Label "Calendar" `
                        -ShowJobs:$ShowJobs `
                        -PSCmdlet $PSCmdlet
                    if ($WhatIfPreference) {
                        [void]$whatIfItems.AddRange(@($calendarItems))
                    }
                    else {
                        foreach ($r in $calResult) { [void]$jobResults.Add($r) }
                    }
                }
                else {
                    Write-Verbose "No calendar items found in the specified time window"
                }
            }

            # --- Mail restore pass ---
            # TODO: this will probably fail with localized folder names
            Write-Verbose "Searching mail folders..."
            if ($InboxOnly) {
                $useRecursive = $false
                $mailPath = "/Users/$userIdentity/Outlook/Inbox"
            }
            else {
                $mailPath = "/Users/$userIdentity/Outlook"
                $useRecursive = $true
            }

            $mailSearchParams = @{
                Connector       = $connectorGuid
                RootPath        = $mailPath
                Recursive       = $useRecursive
                ResultSize      = 'Unlimited'
                ReceivedTime    = $searchStart
                ReceivedEndTime = $searchEnd
            }

            $mailItems = @(Search-KeepitSnapshot @mailSearchParams)
            Write-Verbose "Found $($mailItems.Count) mail items"

            # Exclude Calendar items if they were already restored in the priority pass
            if ($PrioritizeCalendar -and $mailItems.Count -gt 0) {
                $calendarPathPrefix = "/Users/$userIdentity/Outlook/Calendar"
                $beforeCount = $mailItems.Count
                $mailItems = @($mailItems | Where-Object {
                    $itemPath = $_.Id -replace '^kng://[^/]+', ''
                    $itemPath -notlike "$calendarPathPrefix*"
                })
                Write-Verbose "Excluded $($beforeCount - $mailItems.Count) calendar items from mail pass"
            }

            if ($mailItems.Count -gt 0) {
                $mailLabel = if ($InboxOnly) { "Inbox" } else { "Mail" }
                $mailResult = Submit-ExpressRestoreJobs `
                    -Items $mailItems `
                    -ConnectorGuid $connectorGuid `
                    -Label $mailLabel `
                    -ShowJobs:$ShowJobs `
                    -PSCmdlet $PSCmdlet
                if ($WhatIfPreference) {
                    [void]$whatIfItems.AddRange(@($mailItems))
                }
                else {
                    foreach ($r in $mailResult) { [void]$jobResults.Add($r) }
                }
            }
            else {
                if ($WhatIfPreference) {
                    Write-Verbose "No mail items found for user '$UserPrincipalName' in the specified time window."
                }
                else {
                    Write-Warning "No mail items found for user '$UserPrincipalName' in the specified time window."
                }
            }

            # --- Return results ---
            if ($WhatIfPreference) {
                $allItems = $whatIfItems.ToArray()
                if ($allItems.Count -eq 0) {
                    Write-Warning "No items found for user '$UserPrincipalName' in the specified time window."
                    return
                }

                # Group all items by timestamp for the summary
                $itemsByTimestamp = @{}
                foreach ($item in $allItems) {
                    $updated = $item.Updated
                    if (-not $updated) { continue }
                    if (-not $itemsByTimestamp.ContainsKey($updated)) {
                        $itemsByTimestamp[$updated] = [System.Collections.ArrayList]::new()
                    }
                    [void]$itemsByTimestamp[$updated].Add($item)
                }

                $itemCounts = @{}
                $batchCounts = @{}
                $totalJobCount = 0

                foreach ($key in $itemsByTimestamp.Keys) {
                    $groupItems = $itemsByTimestamp[$key]
                    $itemCounts[$key] = $groupItems.Count
                    $estimatedSize = Get-RestoreItemsXmlSize -Items $groupItems
                    if ($estimatedSize -gt $MaxXmlBatchSize) {
                        $batches = Split-RestoreItemsBatches -Items $groupItems -MaxSizeBytes $MaxXmlBatchSize
                        $batchCounts[$key] = $batches.Count
                        $totalJobCount += $batches.Count
                    }
                    else {
                        $batchCounts[$key] = 1
                        $totalJobCount += 1
                    }
                }

                $whatIfResult = [PSCustomObject]@{
                    TotalItems        = $allItems.Count
                    SnapshotGroups    = $itemsByTimestamp.Count
                    TotalJobCount     = $totalJobCount
                    ItemsBySnapshot   = $itemCounts
                    BatchesBySnapshot = $batchCounts
                }

                Write-Host "WhatIf: Would restore $($allItems.Count) items in $totalJobCount restore job(s) across $($itemsByTimestamp.Count) snapshot group(s) for $UserPrincipalName"
                foreach ($key in $itemsByTimestamp.Keys | Sort-Object) {
                    $batchInfo = if ($batchCounts[$key] -gt 1) { " ($($batchCounts[$key]) batches due to size limit)" } else { "" }
                    Write-Host " Snapshot $key : $($itemsByTimestamp[$key].Count) items$batchInfo"
                    foreach ($item in $itemsByTimestamp[$key]) {
                        $title = if ($item.Title) { $item.Title } else { $item.Id }
                        Write-Host " + $title [Updated: $($item.Updated)]"
                    }
                }

                if ($ShowJobs) {
                    Write-Host ""
                    Write-Host "XML job configurations that would be submitted:"
                    $plans = Resolve-RestoreJobPlan -ItemsByTimestamp $itemsByTimestamp -ConnectorGuid $connectorGuid -Type 'email'
                    foreach ($plan in $plans) {
                        $batchIndex = 0
                        foreach ($xmlConfig in $plan.XmlConfigs) {
                            $batchIndex++
                            $batchLabel = if ($plan.BatchCount -gt 1) { " (batch $batchIndex of $($plan.BatchCount))" } else { "" }
                            Write-Host ""
                            Write-Host "Job XML for snapshot $($plan.Timestamp)$batchLabel :"
                            $xmlConfig | Out-Host
                        }
                    }
                }

                return $whatIfResult
            }

            Write-Verbose "=== Start-KeepitExpressRestore: Complete ==="
            Write-Verbose "Submitted $($jobResults.Count) restore jobs for $UserPrincipalName"

            $jobResults.ToArray()
        }
        catch {
            $connectorIdentifier = if ($connectorGuid) { $connectorGuid } else { $Connector }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to perform express restore: $($_.Exception.Message)", $_.Exception),
                    'KeepitExpressRestoreError',
                    [System.Management.Automation.ErrorCategory]::ConnectionError,
                    $connectorIdentifier
                )
            )
        }
    }

    end {
        Write-Verbose "Start-KeepitExpressRestore completed"
    }
}


<#
.SYNOPSIS
    Internal helper that groups items by timestamp, resolves snapshots, and submits restore jobs
.DESCRIPTION
    Groups items by their Updated timestamp, resolves each group to a snapshot, splits into
    batches if needed, and submits jobs via Submit-KeepitJob. Returns job result objects.
    Used by Start-KeepitExpressRestore to handle both the Calendar and Mail passes.
.PARAMETER Items
    Array of search result items from Search-KeepitSnapshot.
.PARAMETER ConnectorGuid
    The resolved connector GUID.
.PARAMETER Label
    A descriptive label for log output (e.g., "Calendar", "Mail", "Inbox").
.PARAMETER UserPrincipalName
    The UPN for logging purposes.
.PARAMETER ShowJobs
    When set, prints XML configuration for each job.
.PARAMETER PSCmdlet
    The calling cmdlet's $PSCmdlet for ShouldProcess support.
.OUTPUTS
    Array of PSCustomObjects with job result properties when not in WhatIf mode.
    Returns nothing in WhatIf mode (caller handles WhatIf summary).
.NOTES
    This is an internal helper function not exported from the module.
#>

function Submit-ExpressRestoreJobs {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Internal helper; plural accurately describes submitting multiple jobs')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Intentional: WhatIf and diagnostic output uses Write-Host for colored console formatting')]
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true)]
        [array]$Items,

        [Parameter(Mandatory = $true)]
        [string]$ConnectorGuid,

        [Parameter(Mandatory = $true)]
        [string]$Label,

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

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]$PSCmdlet
    )

    # Group items by updated timestamp
    $itemsByTimestamp = @{}
    foreach ($item in $Items) {
        $updated = $item.Updated
        if (-not $updated) {
            Write-Verbose "Item $($item.Id) has no Updated timestamp, skipping"
            continue
        }
        if (-not $itemsByTimestamp.ContainsKey($updated)) {
            $itemsByTimestamp[$updated] = [System.Collections.ArrayList]::new()
        }
        [void]$itemsByTimestamp[$updated].Add($item)
    }

    Write-Verbose "${Label}: Grouped $($Items.Count) items into $($itemsByTimestamp.Count) snapshot groups"

    # WhatIf mode: caller handles the summary, just return
    if ($WhatIfPreference) {
        return
    }

    # Resolve snapshots and create batched plans
    $plans = Resolve-RestoreJobPlan -ItemsByTimestamp $itemsByTimestamp -ConnectorGuid $ConnectorGuid -Type 'email'

    $jobResults = [System.Collections.ArrayList]::new()

    foreach ($plan in $plans) {
        $batchIndex = 0
        foreach ($xmlConfig in $plan.XmlConfigs) {
            $batchIndex++
            $batch = $plan.Batches[$batchIndex - 1]
            $batchLabel = if ($plan.BatchCount -gt 1) { " (batch $batchIndex of $($plan.BatchCount))" } else { "" }

            Write-Verbose "${Label}: Submitting job for snapshot $($plan.Timestamp)$batchLabel - $($batch.Count) items"

            if ($ShowJobs) {
                Write-Host "`n$Label job XML for snapshot $($plan.Timestamp)$batchLabel :" -ForegroundColor Cyan
                Write-Host $xmlConfig -ForegroundColor Yellow
                Write-Host ""
            }

            if ($PSCmdlet.ShouldProcess("Connector $ConnectorGuid", "Submit $Label restore job for $($batch.Count) items from snapshot $($plan.Timestamp)$batchLabel")) {
                $submitParams = @{
                    Connector     = $ConnectorGuid
                    Configuration = $xmlConfig
                }

                $jobResult = Submit-KeepitJob @submitParams

                $enhancedResult = [PSCustomObject]@{
                    JobGuid       = $jobResult.JobGuid
                    ConnectorGuid = $jobResult.ConnectorGuid
                    SnapshotId    = $plan.SnapshotId
                    SnapshotTime  = $plan.Timestamp
                    ItemCount     = $batch.Count
                    BatchNumber   = if ($plan.BatchCount -gt 1) { $batchIndex } else { $null }
                    TotalBatches  = if ($plan.BatchCount -gt 1) { $plan.BatchCount } else { $null }
                    Status        = $jobResult.Status
                    CreatedAt     = $jobResult.CreatedAt
                }

                [void]$jobResults.Add($enhancedResult)
                Write-Verbose "${Label}: Submitted job $($jobResult.JobGuid)$batchLabel"
            }
        }
    }

    return , $jobResults.ToArray()
}