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 ', '
}

#endregion