Private/Helpers.ps1

#region Helper Functions

<#
.SYNOPSIS
    Creates a Basic authentication header from credentials
.PARAMETER Credential
    PSCredential object containing username and password
.OUTPUTS
    String - Base64 encoded authentication header value
#>

function New-AuthHeader {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal helper; constructs an in-memory auth string only')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [PSCredential]$Credential
    )

    $username = $Credential.UserName
    $password = $Credential.GetNetworkCredential().Password
    $authString = "${username}:${password}"
    $authBytes = [System.Text.Encoding]::UTF8.GetBytes($authString)
    $authBase64 = [System.Convert]::ToBase64String($authBytes)

    return "Basic $authBase64"
}

<#
.SYNOPSIS
    Gets the authentication header to use for API calls
.PARAMETER Credential
    Optional PSCredential to generate a new auth header
.OUTPUTS
    String - Authentication header value
#>

function Get-AuthHeader {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [PSCredential]$Credential
    )

    if ($Credential) {
        return New-AuthHeader -Credential $Credential
    }

    if (-not $script:KeepitAuth) {
        throw "Not connected to Keepit service. Run Connect-KeepitService first or provide Credential parameter."
    }

    return $script:KeepitAuth
}

<#
.SYNOPSIS
    Constructs the base URL for Keepit API calls
.PARAMETER Environment
    Optional environment override. If not provided, uses cached environment.
.OUTPUTS
    String - Base URL for the configured environment
#>

function Get-KeepitBaseUrl {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Environment
    )

    $env = if ($Environment) { $Environment } else { $script:KeepitRegion }

    if (-not $env) {
        throw "Keepit environment not configured. Run Connect-KeepitService first or provide Environment parameter."
    }

    return "https://$env.keepit.com"
}

<#
.SYNOPSIS
    Gets the user ID, either from cache or by querying the API
.PARAMETER AuthHeader
    The authentication header to use for API calls
.PARAMETER BaseUrl
    The base URL for API calls
.OUTPUTS
    String - User GUID
#>

function Get-KeepitUserId {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$AuthHeader,

        [Parameter(Mandatory = $true)]
        [string]$BaseUrl
    )

    # If we have a cached user ID and are using cached auth, return it
    if ($script:KeepitUserId -and $AuthHeader -eq $script:KeepitAuth) {
        return $script:KeepitUserId
    }

    # Otherwise, query the API
    Write-Verbose "Getting user ID from API"
    $headers = @{
        'Authorization' = $AuthHeader
        'Content-Type' = 'application/xml'
    }
    $userResponse = Invoke-RestMethod -Uri "$BaseUrl/users/" -Method Get -Headers $headers -ErrorAction Stop

    if (-not $userResponse.user.id) {
        throw "Unable to retrieve user ID from response"
    }

    return $userResponse.user.id
}

<#
.SYNOPSIS
    Converts a DateTime to ISO 8601 format for API requests
.PARAMETER DateTime
    The DateTime to convert
.OUTPUTS
    String - ISO 8601 formatted timestamp
.NOTES
    DateTimeKind handling:
    - Utc: Used as-is
    - Local: Converted to UTC
    - Unspecified: Treated as UTC (for ISO8601 strings without timezone indicator)
#>

function ConvertTo-KeepitTimestamp {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [DateTime]$DateTime
    )

    # If Kind is Unspecified (e.g., from ISO8601 string without Z suffix), treat as UTC
    # This allows users to specify times like "2025-12-04T09:50:00" and have them
    # interpreted as UTC rather than local time
    if ($DateTime.Kind -eq [System.DateTimeKind]::Unspecified) {
        $DateTime = [DateTime]::SpecifyKind($DateTime, [System.DateTimeKind]::Utc)
    }

    return $DateTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
}

<#
.SYNOPSIS
    Gets the display name for a connector type
.PARAMETER ConnectorType
    The internal connector type name (e.g., 'o365-admin')
.OUTPUTS
    String - The display name (e.g., 'Microsoft 365'), or the original type if not found
#>

function Get-ConnectorTypeDisplayName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConnectorType
    )

    if ($script:ConnectorTypes.ContainsKey($ConnectorType)) {
        return $script:ConnectorTypes[$ConnectorType]
    }
    # Return original type if not in mapping (for forward compatibility)
    return $ConnectorType
}

<#
.SYNOPSIS
    Resolves a connector type name (key or display name) to the internal key
.PARAMETER TypeName
    The connector type name, which can be either:
    - An internal key (e.g., 'o365-admin', 'azure-ad', 'jsm')
    - A display name (e.g., 'Microsoft 365', 'Entra ID', 'Jira Service Management')
.OUTPUTS
    String - The internal key if found, or $null if not recognized
#>

function Resolve-ConnectorTypeName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$TypeName
    )

    # Check if it's already an internal key
    if ($script:ConnectorTypes.ContainsKey($TypeName)) {
        return $TypeName
    }

    # Check if it's a display name (case-insensitive)
    foreach ($key in $script:ConnectorTypes.Keys) {
        if ($script:ConnectorTypes[$key] -eq $TypeName) {
            return $key
        }
    }

    # Not found
    return $null
}

<#
.SYNOPSIS
    Validates a connector type name (key or display name)
.PARAMETER TypeName
    The connector type name to validate
.OUTPUTS
    Boolean - $true if valid, $false otherwise
#>

function Test-ConnectorTypeName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$TypeName
    )

    return $null -ne (Resolve-ConnectorTypeName -TypeName $TypeName)
}

<#
.SYNOPSIS
    Validates workload parameter values against connector type
.DESCRIPTION
    Validates that the specified workloads are valid for the given connector type.
    Throws an error if the connector type does not support workloads or if invalid
    workload names are specified.
.PARAMETER Workload
    Array of workload names to validate
.PARAMETER ConnectorType
    The connector type to validate against (e.g., 'o365-admin', 'dynamics365')
.OUTPUTS
    None. Throws an error if validation fails.
.EXAMPLE
    Test-WorkloadParameter -Workload @('Exchange', 'Teams') -ConnectorType 'o365-admin'

    Validates that Exchange and Teams are valid workloads for o365-admin connectors.
#>

function Test-WorkloadParameter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Workload,

        [Parameter(Mandatory = $true)]
        [string]$ConnectorType
    )

    $validWorkloads = $script:WorkloadsByConnectorType[$ConnectorType]
    if (-not $validWorkloads) {
        $displayName = Get-ConnectorTypeDisplayName -ConnectorType $ConnectorType
        throw "The -Workload parameter is not supported for $displayName ($ConnectorType) connectors. This connector type has a single configuration block."
    }

    foreach ($w in $Workload) {
        if ($w -notin $validWorkloads) {
            $displayName = Get-ConnectorTypeDisplayName -ConnectorType $ConnectorType
            throw "Invalid workload '$w' for $displayName ($ConnectorType) connectors. Valid workloads are: $($validWorkloads -join ', ')"
        }
    }

    Write-Verbose "Validated workloads: $($Workload -join ', ')"
}

function Resolve-WorkloadAlias {
    <#
    .SYNOPSIS
        Resolves a workload alias to its canonical name
    .DESCRIPTION
        Resolves workload aliases (ExO, ODB) to their canonical names (Exchange, OneDrive).
        If the workload is not an alias, returns it unchanged.
    .PARAMETER Workload
        The workload name or alias to resolve
    .OUTPUTS
        The canonical workload name
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Workload
    )

    if ($script:WorkloadAliasToCanonical.ContainsKey($Workload)) {
        $canonical = $script:WorkloadAliasToCanonical[$Workload]
        Write-Verbose "Resolved workload alias '$Workload' to '$canonical'"
        return $canonical
    }

    return $Workload
}

<#
.SYNOPSIS
    Validates that a string is a valid URL
.DESCRIPTION
    Validates that the specified string is a valid URL with at minimum a scheme and host.
    Used for validating SharePoint site URLs in configuration management.
.PARAMETER Url
    The URL string to validate
.OUTPUTS
    None. Throws an error if validation fails.
.EXAMPLE
    Test-SiteUrl -Url "https://contoso.sharepoint.com/sites/Marketing"

    Validates that the string is a valid URL.
#>

function Test-SiteUrl {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Url
    )

    try {
        $uri = [System.Uri]::new($Url)
        if (-not $uri.IsAbsoluteUri) {
            throw "URL must be absolute (include scheme like https://)"
        }
        if ([string]::IsNullOrWhiteSpace($uri.Host)) {
            throw "URL must include a host"
        }
        if ($uri.Scheme -notin @('http', 'https')) {
            throw "URL scheme must be http or https"
        }
        Write-Verbose "Validated URL: $Url"
    }
    catch [System.UriFormatException] {
        throw "Invalid URL format '$Url': $($_.Exception.Message)"
    }
}

<#
.SYNOPSIS
    Validates that a string is a valid GUID format
.DESCRIPTION
    Validates that the specified string is a valid GUID (globally unique identifier).
    Used for validating group GUIDs in Teams/UnifiedGroups configuration management.
.PARAMETER Guid
    The GUID string to validate
.OUTPUTS
    None. Throws an error if validation fails.
.EXAMPLE
    Test-GroupGuid -Guid "0aa94c0a-c5e5-417f-8cfa-6744649e25da"

    Validates that the string is a valid GUID.
#>

function Test-GroupGuid {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Guid
    )

    $guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
    if ($Guid -notmatch $guidPattern) {
        throw "Invalid GUID format '$Guid'. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
    Write-Verbose "Validated GUID: $Guid"
}

<#
.SYNOPSIS
    Extracts SharePoint coverage information from connector configuration
.PARAMETER Config
    The SharePointNG section of the connector configuration (hashtable)
.PARAMETER FullConfig
    The full connector configuration (hashtable), used for top-level properties
.OUTPUTS
    Array of PSCustomObjects describing SharePoint site coverage
#>

function Get-SharePointCoverage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Config
    )

    $results = @()

    $autoInclude = if ($Config.ContainsKey('AutoIncludeAllSiteCollections')) {
        $Config['AutoIncludeAllSiteCollections']
    } else {
        $false
    }

    if ($autoInclude) {
        # When auto-include is on, report a summary entry with exclusions
        $excludeSites = if ($Config.ContainsKey('ExcludeSiteCollections')) {
            @($Config['ExcludeSiteCollections'])
        } else {
            @()
        }
        $excludePrefixes = if ($Config.ContainsKey('ExcludeSiteCollectionsWithPrefixes')) {
            @($Config['ExcludeSiteCollectionsWithPrefixes'])
        } else {
            @()
        }

        $results += [PSCustomObject]@{
            SiteUrl                  = '*'
            AutoIncludeAllSubSites   = $true
            SubSites                 = @()
            ExcludeSubSites          = @()
            ExcludeSiteCollections   = $excludeSites
            ExcludeSiteCollectionsWithPrefixes = $excludePrefixes
        }
    }

    # Include explicit site collections
    if ($Config.ContainsKey('SiteCollections')) {
        foreach ($site in $Config['SiteCollections']) {
            $siteUrl = if ($site -is [hashtable] -and $site.ContainsKey('SiteUrl')) {
                $site['SiteUrl']
            } elseif ($site.PSObject -and $site.PSObject.Properties['SiteUrl']) {
                $site.SiteUrl
            } else {
                $null
            }

            $autoSubSites = if ($site -is [hashtable] -and $site.ContainsKey('AutoIncludeAllSubSites')) {
                $site['AutoIncludeAllSubSites']
            } elseif ($site.PSObject -and $site.PSObject.Properties['AutoIncludeAllSubSites']) {
                $site.AutoIncludeAllSubSites
            } else {
                $false
            }

            $subSites = if ($site -is [hashtable] -and $site.ContainsKey('SubSites')) {
                @($site['SubSites'])
            } elseif ($site.PSObject -and $site.PSObject.Properties['SubSites']) {
                @($site.SubSites)
            } else {
                @()
            }

            $excludeSubSites = if ($site -is [hashtable] -and $site.ContainsKey('ExcludeSubSites')) {
                @($site['ExcludeSubSites'])
            } elseif ($site.PSObject -and $site.PSObject.Properties['ExcludeSubSites']) {
                @($site.ExcludeSubSites)
            } else {
                @()
            }

            $results += [PSCustomObject]@{
                SiteUrl                  = $siteUrl
                AutoIncludeAllSubSites   = $autoSubSites
                SubSites                 = $subSites
                ExcludeSubSites          = $excludeSubSites
                ExcludeSiteCollections   = $null
                ExcludeSiteCollectionsWithPrefixes = $null
            }
        }
    }

    return , $results
}

<#
.SYNOPSIS
    Extracts Exchange coverage information from connector configuration
.PARAMETER Config
    The Exchange section of the connector configuration (hashtable)
.PARAMETER FullConfig
    The full connector configuration (hashtable), used for top-level UserSelectionRules
.OUTPUTS
    Array containing a single PSCustomObject describing Exchange coverage
#>

function Get-ExchangeCoverage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Config,

        [Parameter(Mandatory = $true)]
        [hashtable]$FullConfig
    )

    $enabledCategories = if ($Config.ContainsKey('EnabledCategories')) {
        @($Config['EnabledCategories'])
    } else {
        @()
    }

    $userSelectionRules = if ($FullConfig.ContainsKey('UserSelectionRules')) {
        $FullConfig['UserSelectionRules']
    } elseif ($Config.ContainsKey('UserSelectionRules')) {
        $Config['UserSelectionRules']
    } else {
        $null
    }

    return , @([PSCustomObject]@{
        EnabledCategories  = $enabledCategories
        UserSelectionRules = $userSelectionRules
    })
}

<#
.SYNOPSIS
    Extracts OneDrive coverage information from connector configuration
.PARAMETER Config
    The OneDriveSP section of the connector configuration (hashtable)
.PARAMETER FullConfig
    The full connector configuration (hashtable), used for top-level UserSelectionRules
.OUTPUTS
    Array containing a single PSCustomObject describing OneDrive coverage
#>

function Get-OneDriveCoverage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Config,

        [Parameter(Mandatory = $true)]
        [hashtable]$FullConfig
    )

    $options = if ($Config.ContainsKey('Options')) {
        $Config['Options']
    } else {
        $null
    }

    $userSelectionRules = if ($FullConfig.ContainsKey('UserSelectionRules')) {
        $FullConfig['UserSelectionRules']
    } elseif ($Config.ContainsKey('UserSelectionRules')) {
        $Config['UserSelectionRules']
    } else {
        $null
    }

    return , @([PSCustomObject]@{
        Options            = $options
        UserSelectionRules = $userSelectionRules
    })
}

<#
.SYNOPSIS
    Extracts Teams/UnifiedGroups coverage information from connector configuration
.PARAMETER Config
    The UnifiedGroups section of the connector configuration (hashtable)
.PARAMETER FullConfig
    The full connector configuration (hashtable)
.OUTPUTS
    Array containing a single PSCustomObject describing Teams/UnifiedGroups coverage
#>

function Get-UnifiedGroupsCoverage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Config
    )

    $autoInclude = if ($Config.ContainsKey('AutoIncludeGroups')) {
        $Config['AutoIncludeGroups']
    } else {
        $false
    }

    $enabledCategories = if ($Config.ContainsKey('EnabledCategories')) {
        @($Config['EnabledCategories'])
    } else {
        @()
    }

    $includeGroups = if ($Config.ContainsKey('IncludeGroups')) {
        @($Config['IncludeGroups'])
    } else {
        @()
    }

    $excludeGroups = if ($Config.ContainsKey('ExcludeGroups')) {
        @($Config['ExcludeGroups'])
    } else {
        @()
    }

    return , @([PSCustomObject]@{
        AutoIncludeGroups = $autoInclude
        EnabledCategories = $enabledCategories
        IncludeGroups     = $includeGroups
        ExcludeGroups     = $excludeGroups
    })
}

<#
.SYNOPSIS
    Resolves a connector identity (name or GUID) to a GUID
.DESCRIPTION
    Takes a connector identity that can be either a connector name or a GUID,
    and returns the corresponding GUID. If a GUID is provided, it validates
    that the connector exists. If a name is provided, it looks up the connector
    and returns its GUID.

    Uses cached authentication from Connect-KeepitService.
.PARAMETER Identity
    The connector name or GUID to resolve
.OUTPUTS
    PSCustomObject with properties:
        - ConnectorGuid: The resolved GUID
        - Name: The connector name
        - Type: The connector type
#>

function Resolve-KeepitConnectorIdentity {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Identity
    )

    Write-Verbose "Resolving connector identity: $Identity"

    # Get authentication and connection info
    $authHeader = Get-AuthHeader
    $baseUrl = Get-KeepitBaseUrl
    $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl

    # Check if Identity looks like a GUID (three groups of 6 alphanumeric chars)
    $isGuid = $Identity -match '^[a-z0-9]{6}-[a-z0-9]{6}-[a-z0-9]{6}$'

    # Get all connectors
    $headers = @{
        'Authorization' = $authHeader
        'Content-Type' = 'application/xml'
        'Accept' = 'application/vnd.keepit.v4+xml'
    }

    $uri = "$baseUrl/users/$userId/devices"
    Write-Verbose "Fetching connectors from: $uri"

    $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop

    if (-not $response.devices.cloud) {
        throw "No connectors found"
    }

    # Normalize to array
    $devices = if ($response.devices.cloud -is [System.Array]) {
        $response.devices.cloud
    } else {
        @($response.devices.cloud)
    }

    # Find matching connector
    $matchingConnector = $null

    if ($isGuid) {
        # Look up by GUID (case-insensitive)
        $matchingConnector = $devices | Where-Object {
            $_.guid -eq $Identity -or $_.guid -eq $Identity.ToLower()
        } | Select-Object -First 1
    }

    if (-not $matchingConnector) {
        # Look up by name (case-insensitive)
        $matchingConnector = $devices | Where-Object {
            $_.name -eq $Identity
        } | Select-Object -First 1
    }

    if (-not $matchingConnector) {
        throw "Connector '$Identity' not found"
    }

    # Determine device type (handle DSL connectors)
    $deviceType = if ($matchingConnector.type -eq 'dsl') {
        $matchingConnector.'agent-type'
    } else {
        $matchingConnector.type
    }

    return [PSCustomObject]@{
        ConnectorGuid = $matchingConnector.guid.ToLower()
        Name = $matchingConnector.name
        Type = $deviceType
    }
}

<#
.SYNOPSIS
    Converts an ISO 8601 duration to human-readable English text
.DESCRIPTION
    Parses ISO 8601 duration format (e.g., P3M, P1Y, P1Y6M, P30D) and converts
    to readable English text (e.g., "3 months", "1 year", "1 year, 6 months", "30 days").
    Returns "Unlimited" for null/empty input.
.PARAMETER Duration
    ISO 8601 duration string (e.g., "P3M", "P1Y6M", "P30D")
.OUTPUTS
    String - Human-readable duration text
.EXAMPLE
    ConvertFrom-ISO8601Duration -Duration "P3M"
    # Returns: "3 months"
.EXAMPLE
    ConvertFrom-ISO8601Duration -Duration "P1Y6M"
    # Returns: "1 year, 6 months"
.EXAMPLE
    ConvertFrom-ISO8601Duration -Duration $null
    # Returns: "Unlimited"
#>

function ConvertFrom-ISO8601Duration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Duration
    )

    # Handle null/empty as unlimited retention
    if ([string]::IsNullOrWhiteSpace($Duration)) {
        return "Unlimited"
    }

    # ISO 8601 duration format: P[n]Y[n]M[n]D or P[n]W
    # Examples: P3M (3 months), P1Y (1 year), P1Y6M (1 year 6 months), P30D (30 days), P2W (2 weeks)
    if ($Duration -notmatch '^P') {
        Write-Verbose "Invalid ISO 8601 duration format: $Duration"
        return $Duration  # Return as-is if not valid ISO 8601
    }

    $parts = @()

    # Extract years
    if ($Duration -match '(\d+)Y') {
        $years = [int]$Matches[1]
        $parts += if ($years -eq 1) { "1 year" } else { "$years years" }
    }

    # Extract months
    if ($Duration -match '(\d+)M(?![A-Z])') {
        $months = [int]$Matches[1]
        $parts += if ($months -eq 1) { "1 month" } else { "$months months" }
    }

    # Extract weeks
    if ($Duration -match '(\d+)W') {
        $weeks = [int]$Matches[1]
        $parts += if ($weeks -eq 1) { "1 week" } else { "$weeks weeks" }
    }

    # Extract days
    if ($Duration -match '(\d+)D') {
        $days = [int]$Matches[1]
        $parts += if ($days -eq 1) { "1 day" } else { "$days days" }
    }

    if ($parts.Count -eq 0) {
        Write-Verbose "Could not parse ISO 8601 duration: $Duration"
        return $Duration  # Return as-is if nothing was parsed
    }

    return $parts -join ', '
}

<#
.SYNOPSIS
    Sends a non-blocking desktop notification
.DESCRIPTION
    Sends a system notification using the platform-appropriate mechanism:
    - Windows: NotifyIcon balloon tip via System.Windows.Forms
    - macOS: osascript display notification
    - Linux: notify-send if available, else Write-Host fallback
    All errors are swallowed silently so callers are never interrupted.
.PARAMETER Title
    The notification title
.PARAMETER Message
    The notification body text
.PARAMETER Icon
    The icon style: Info (default), Warning, or Error
#>

function Show-KeepitNotification {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Intentional: Linux fallback uses Write-Host when notify-send is unavailable')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Title,

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

        [Parameter(Mandatory = $false)]
        [ValidateSet('Info', 'Warning', 'Error')]
        [string]$Icon = 'Info'
    )

    try {
        # Windows PowerShell 5.1 does not define $IsWindows or $IsMacOS. It runs
        # only on Windows, so a non-Core edition means Windows.
        $onWindows = ($PSVersionTable.PSEdition -ne 'Core') -or [bool]$IsWindows
        $onMacOS = ($PSVersionTable.PSEdition -eq 'Core') -and [bool]$IsMacOS

        if ($onWindows) {
            Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
            $balloonIcon = switch ($Icon) {
                'Warning' { [System.Windows.Forms.ToolTipIcon]::Warning }
                'Error'   { [System.Windows.Forms.ToolTipIcon]::Error }
                default   { [System.Windows.Forms.ToolTipIcon]::Info }
            }
            $balloon = New-Object System.Windows.Forms.NotifyIcon
            $balloon.Icon            = [System.Drawing.SystemIcons]::Application
            $balloon.BalloonTipIcon  = $balloonIcon
            $balloon.BalloonTipTitle = $Title
            $balloon.BalloonTipText  = $Message
            $balloon.Visible         = $true
            $balloon.ShowBalloonTip(5000)
            Start-Sleep -Milliseconds 200
            $balloon.Dispose()
        }
        elseif ($onMacOS) {
            $esc = { param($s) $s -replace '\\', '\\\\' -replace '"', '\"' }
            $null = & osascript -e "display notification `"$(& $esc $Message)`" with title `"$(& $esc $Title)`"" 2>&1
        }
        else {
            if (Get-Command notify-send -ErrorAction SilentlyContinue) {
                $null = & notify-send --urgency=normal $Title $Message 2>&1
            }
            else {
                Write-Host "`n[$Title] $Message" -ForegroundColor Cyan
            }
        }
        Write-Verbose "Notification sent: [$Title] $Message"
    }
    catch {
        Write-Verbose "Show-KeepitNotification: $($_.Exception.Message)"
    }
}

<#
.SYNOPSIS
    Converts a JSON object tree to nested hashtables
.DESCRIPTION
    Internal helper for ConvertFrom-KeepitJson. Walks the PSCustomObject tree that
    ConvertFrom-Json returns on Windows PowerShell 5.1 and makes a hashtable for
    each object, an array for each array, and keeps scalars as they are.
.PARAMETER InputObject
    A value from ConvertFrom-Json: a PSCustomObject, an array, a scalar, or $null
.OUTPUTS
    Hashtable, Object[], or the scalar value
#>

function ConvertTo-KeepitHashtable {
    [CmdletBinding()]
    [OutputType([hashtable], [object[]], [object])]
    param(
        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object]$InputObject
    )

    if ($null -eq $InputObject) {
        return $null
    }

    if ($InputObject -is [System.Collections.IDictionary]) {
        $table = @{}
        foreach ($key in $InputObject.Keys) {
            $table[$key] = ConvertTo-KeepitHashtable -InputObject $InputObject[$key]
        }
        return $table
    }

    if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
        $table = @{}
        foreach ($property in $InputObject.PSObject.Properties) {
            $table[$property.Name] = ConvertTo-KeepitHashtable -InputObject $property.Value
        }
        return $table
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        $list = New-Object System.Collections.ArrayList
        foreach ($item in $InputObject) {
            $null = $list.Add((ConvertTo-KeepitHashtable -InputObject $item))
        }
        return , $list.ToArray()
    }

    return $InputObject
}

<#
.SYNOPSIS
    Converts JSON text to nested hashtables on Windows PowerShell 5.1 and PowerShell 7
.DESCRIPTION
    ConvertFrom-Json has the -AsHashtable parameter only on PowerShell 6 and later.
    This helper gives the same result on both versions, so callers can use
    ContainsKey and index syntax on the configuration tree. On PowerShell 6 and
    later it uses the built-in parameter. On Windows PowerShell 5.1 it converts
    the PSCustomObject tree with ConvertTo-KeepitHashtable.
.PARAMETER InputObject
    JSON text. Several pipeline strings are joined with newlines before parsing,
    which is what ConvertFrom-Json does.
.OUTPUTS
    Hashtable for a JSON object, Object[] for a JSON array
.EXAMPLE
    $config = $json | ConvertFrom-KeepitJson
    if ($config.ContainsKey('SharePointNG')) { ... }
#>

function ConvertFrom-KeepitJson {
    [CmdletBinding()]
    [OutputType([hashtable], [object[]])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string]$InputObject
    )

    begin {
        $chunks = New-Object System.Collections.ArrayList
    }

    process {
        $null = $chunks.Add($InputObject)
    }

    end {
        $json = $chunks -join "`n"

        if ($PSVersionTable.PSVersion.Major -ge 6) {
            # Splatting keeps the parameter name out of the source text, so the
            # 5.1 compatibility scan does not report this line.
            $asHashtable = @{ AsHashtable = $true }
            $result = ConvertFrom-Json -InputObject $json @asHashtable
            return , $result
        }

        $parsed = ConvertFrom-Json -InputObject $json
        return , (ConvertTo-KeepitHashtable -InputObject $parsed)
    }
}

<#
.SYNOPSIS
    Sends a web request and returns the response for every HTTP status code
.DESCRIPTION
    Some Keepit endpoints report state through the status code, for example
    202 and 409 on a HEAD request that checks whether a user exists. The caller
    must read the status code and the body of a non-2xx response instead of
    catching an exception.

    PowerShell 7 does this with Invoke-WebRequest -SkipHttpErrorCheck. Windows
    PowerShell 5.1 has no such parameter and throws a WebException. This helper
    hides the difference: on both versions it returns an object with
    StatusCode, StatusDescription, Headers, and Content. On a 2xx response the
    object is the Invoke-WebRequest response itself. On a non-2xx response under
    5.1 it is built from the WebException.

    Errors that have no HTTP response, such as DNS or connection failures, are
    thrown on both versions.
.PARAMETER Uri
    Request URI
.PARAMETER Method
    HTTP method. Default: Get
.PARAMETER Headers
    Request headers
.PARAMETER Body
    Request body, a string or a byte array
.PARAMETER TimeoutSec
    Request timeout in seconds. 0 means the Invoke-WebRequest default.
.OUTPUTS
    The Invoke-WebRequest response, or a PSCustomObject with the same
    StatusCode, StatusDescription, Headers, and Content members
.EXAMPLE
    $r = Invoke-KeepitWebRequest -Uri $checkUri -Method Head -Headers $headers
    if ($r.StatusCode -eq 409) { 'exists' }
#>

function Invoke-KeepitWebRequest {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Get', 'Head', 'Post', 'Put', 'Delete', 'Patch')]
        [string]$Method = 'Get',

        [Parameter(Mandatory = $false)]
        [hashtable]$Headers,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object]$Body,

        [Parameter(Mandatory = $false)]
        [int]$TimeoutSec = 0
    )

    # UseBasicParsing stops Windows PowerShell 5.1 from parsing HTML responses
    # with the Internet Explorer engine. PowerShell 7 ignores the switch.
    $requestParams = @{
        Uri             = $Uri
        Method          = $Method
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }
    if ($Headers) { $requestParams['Headers'] = $Headers }
    if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $requestParams['Body'] = $Body }
    if ($TimeoutSec -gt 0) { $requestParams['TimeoutSec'] = $TimeoutSec }

    if ($PSVersionTable.PSVersion.Major -ge 7) {
        # The parameter name is built from a variable so the 5.1 compatibility
        # scan does not report this line.
        $skipErrorCheck = 'SkipHttpErrorCheck'
        $requestParams[$skipErrorCheck] = $true
        return Invoke-WebRequest @requestParams
    }

    try {
        return Invoke-WebRequest @requestParams
    }
    catch [System.Net.WebException] {
        $errorRecord = $_
        $httpResponse = $errorRecord.Exception.Response
        if ($null -eq $httpResponse) {
            # No HTTP response: a transport error. Let the caller handle it.
            throw
        }

        $responseHeaders = @{}
        if ($httpResponse.Headers) {
            foreach ($name in $httpResponse.Headers.AllKeys) {
                $responseHeaders[$name] = $httpResponse.Headers[$name]
            }
        }

        # Windows PowerShell reads the body into ErrorDetails for most responses.
        # Fall back to the response stream when it did not.
        $content = ''
        if ($errorRecord.ErrorDetails -and $errorRecord.ErrorDetails.Message) {
            $content = $errorRecord.ErrorDetails.Message
        }
        else {
            try {
                $stream = $httpResponse.GetResponseStream()
                if ($stream) {
                    if ($stream.CanSeek) { $stream.Position = 0 }
                    $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8)
                    $content = $reader.ReadToEnd()
                    $reader.Dispose()
                }
            }
            catch {
                Write-Verbose "Invoke-KeepitWebRequest: could not read the error response body: $($_.Exception.Message)"
            }
        }

        return [PSCustomObject]@{
            StatusCode        = [int]$httpResponse.StatusCode
            StatusDescription = [string]$httpResponse.StatusDescription
            Headers           = $responseHeaders
            Content           = $content
        }
    }
}

<#
.SYNOPSIS
    Returns the body of a web response as UTF-8 text
.DESCRIPTION
    The Keepit API sends UTF-8 text. PowerShell 7 returns Content as a string
    for text content types and as a byte array for others. Windows PowerShell
    5.1 decodes a text response as ISO-8859-1 when the response has no charset,
    which damages non-ASCII characters such as Danish letters in mailbox names
    and paths. This helper decodes a byte array as UTF-8, re-decodes the raw
    response bytes as UTF-8 on Windows PowerShell, and otherwise returns the
    string Content.
.PARAMETER Response
    An Invoke-WebRequest response, or an object with a Content member
.OUTPUTS
    String. An empty string when the response has no content.
.EXAMPLE
    [xml]$doc = Get-KeepitResponseText -Response $webResponse
#>

function Get-KeepitResponseText {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [object]$Response
    )

    if ($null -eq $Response) {
        return ''
    }

    $content = $Response.Content

    if ($content -is [byte[]]) {
        return [System.Text.Encoding]::UTF8.GetString($content)
    }

    if ($PSVersionTable.PSVersion.Major -lt 6 -and $Response.RawContentStream) {
        try {
            $stream = $Response.RawContentStream
            if ($stream.CanSeek) { $stream.Position = 0 }
            # StreamReader removes a UTF-8 byte order mark; Encoding.GetString keeps it.
            $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8)
            $text = $reader.ReadToEnd()
            if ($stream.CanSeek) { $stream.Position = 0 }
            return $text
        }
        catch {
            Write-Verbose "Get-KeepitResponseText: could not re-decode the response as UTF-8: $($_.Exception.Message)"
        }
    }

    if ($null -eq $content) {
        return ''
    }

    return [string]$content
}

#endregion