Modules/M365DSCUtil.psm1

#region Session Objects
$Global:SessionSecurityCompliance = $null
#endregion

#region Push Notifications
$Global:M365DSCPushNotificationsURI = $null
$Global:M365DSCPushNotificationsHeaders = $null
$Global:M365DSCPushNotificationsBody = $null
#endregion

$Script:M365DSCWorkloads = @('AAD', 'ADO', 'AZURE', 'COMMERCE', 'DEFENDER', 'EXO', 'FABRIC', 'INTUNE', 'O365', 'OD', 'PLANNER', 'PP', 'SC', 'SENTINEL', 'SH', 'SPO', 'TEAMS')

<#
.Description
The Get-TemporaryPath function will return the temporary
path specific to the OS. It will return $env:TEMP when run
on Windows OS, '/tmp' when run in Linux and $env:TMPDIR when
run on MacOS.
 
.Example
Get-TemporaryPath
 
Get the temporary path (which will differ between operating system).
 
.Functionality
Internal,Hidden
#>

function Get-TemporaryPath
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param ()

    $temporaryPath = $null

    switch ($true)
    {
        (-not (Test-Path -Path variable:IsWindows) -or ((Get-Variable -Name 'IsWindows' -ValueOnly -ErrorAction SilentlyContinue) -eq $true))
        {
            # Windows PowerShell or PowerShell 6+
            $temporaryPath = (Get-Item -Path env:TEMP).Value
        }

        ((Get-Variable -Name 'IsMacOs' -ValueOnly -ErrorAction SilentlyContinue) -eq $true)
        {
            $temporaryPath = (Get-Item -Path env:TMPDIR).Value
        }

        ((Get-Variable -Name 'IsLinux' -ValueOnly -ErrorAction SilentlyContinue) -eq $true)
        {
            $temporaryPath = '/tmp'
        }

        default
        {
            throw 'Cannot set the temporary path. Unknown operating system.'
        }
    }

    return $temporaryPath
}

$env:TEMP = Get-TemporaryPath

<#
.SYNOPSIS
    Retrieves a Microsoft Teams team by display name.
 
.DESCRIPTION
    Queries Teams for a team matching the provided display name.
    Retries briefly to account for eventual consistency and returns null when the team cannot be resolved.
 
.PARAMETER TeamName
    Specifies the display name of the team to resolve.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    Hashtable
#>

function Get-TeamByName
{
    [CmdletBinding()]
    [OutputType([Hashtable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $TeamName
    )

    try
    {
        $loopCounter = 0
        do
        {
            $team = Get-Team -DisplayName $TeamName | Where-Object -Property DisplayName -EQ [System.Net.WebUtility]::UrlDecode($TeamName)
            if ($null -eq $team)
            {
                Start-Sleep 5
            }
            $loopCounter += 1
            if ($loopCounter -gt 5)
            {
                break
            }
        } while ($null -eq $team)

        if ($null -eq $team)
        {
            throw "Team with Name $TeamName doesn't exist in tenant"
        }
        elseif ($teams.Length -gt 1)
        {
            Write-Warning -Message "More than one Team with name {$TeamName} was found. This could prevent your configuration from compiling properly."
        }
        return $team
    }
    catch
    {
        return $null
    }
}

<#
.SYNOPSIS
    Converts a hashtable to Microsoft365DSC string format.
 
.DESCRIPTION
    Uses the Microsoft365DSC converter assembly to serialize a hashtable into the string representation used by logging and display helpers.
 
.PARAMETER Hashtable
    Specifies the hashtable to convert.
 
.FUNCTIONALITY
    Internal
#>

function Convert-M365DscHashtableToString
{
    param
    (
        [Parameter()]
        [System.Collections.Hashtable]
        $Hashtable
    )

    Initialize-M365DSCDllLoader -ErrorAction Stop
    return [Microsoft365DSC.Converter.HashtableConverter]::ToString($Hashtable)
}

<#
.SYNOPSIS
    Tests whether an imported cmdlet is available in the current session.
 
.DESCRIPTION
    Checks command discovery for a cmdlet name and returns a boolean result.
 
.PARAMETER CmdletName
    Specifies the cmdlet name to look up.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Boolean
#>

function Confirm-ImportedCmdletIsAvailable
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $CmdletName
    )

    try
    {
        $CmdletIsAvailable = (Get-Command -Name $CmdletName -ErrorAction SilentlyContinue)
        if ($CmdletIsAvailable)
        {
            return $true
        }
        else
        {
            return $false
        }
    }
    catch
    {
        return $false
    }
}

<#
.SYNOPSIS
    Normalizes an enumerable property value into a typed array.
 
.DESCRIPTION
    Creates an array of the requested element type from the provided property value.
    Used to ensure stable array typing in generated and compared values.
 
.PARAMETER PropertyValue
    Specifies the input value to convert to an array.
 
.PARAMETER ElementType
    Specifies the element type to use for the output array.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Array
#>

function Get-M365DSCArrayFromProperty
{
    [CmdletBinding()]
    [OutputType([System.Array])]
    param
    (
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [AllowNull()]
        [System.Object]
        $PropertyValue,

        [Parameter(Mandatory = $false)]
        [System.Type]
        $ElementType = [System.Object]
    )

    $array = [System.Array]::CreateInstance($ElementType, 0)
    foreach ($item in $PropertyValue)
    {
        $array += $item
    }

    ,$array
}

<#
.SYNOPSIS
    Compares desired and current parameter values for drift.
 
.DESCRIPTION
    Performs drift evaluation between desired and current values, records drift details, emits telemetry, and can write event log entries for drift and non-drift states.
 
.PARAMETER CurrentValues
    Specifies the current values retrieved from the tenant.
 
.PARAMETER DesiredValues
    Specifies the desired values to compare against.
 
.PARAMETER ValuesToCheck
    Specifies the property names that should be compared.
 
.PARAMETER Source
    Specifies the resource/source name used for logging and telemetry.
 
.PARAMETER IncludedDrifts
    Specifies precomputed drift details to include in comparison output.
 
.PARAMETER NoEventMessage
    Indicates that event log messages should not be written.
 
.PARAMETER NoDriftReset
    Indicates that global drift collections should not be reset before comparison.
 
.PARAMETER ExcludedProperties
    Specifies property names to skip during comparison.
 
.FUNCTIONALITY
    Public
 
.OUTPUTS
    System.Boolean
#>

function Test-M365DSCParameterState
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true, Position = 1)]
        [HashTable]
        $CurrentValues,

        [Parameter(Mandatory = $true, Position = 2)]
        [Object]
        $DesiredValues,

        [Parameter(Position = 3)]
        [Array]
        $ValuesToCheck,

        [Parameter(Position = 4)]
        [System.String]
        $Source = 'Generic',

        [Parameter(Position = 5)]
        [System.Collections.Hashtable]
        $IncludedDrifts,

        [Parameter(Position = 6)]
        [switch]
        $NoEventMessage,

        [Parameter(Position = 7)]
        [switch]
        $NoDriftReset,

        [Parameter(Position = 8)]
        [System.String[]]
        $ExcludedProperties
    )

    $startTime = [System.DateTime]::Now
    if ($null -eq $Global:AllDrifts -or -not $NoDriftReset)
    {
        $Global:AllDrifts = @{
            DriftInfo     = @()
            CurrentValues = @{}
            DesiredValues = @{}
        }
        $Global:PotentialDrifts = @()
    }

    $returnValue = $true
    $TenantName = Get-M365DSCTenantNameFromParameterSet -ParameterSet $DesiredValues

    #region Telemetry
    if (Test-IsM365DSCTelemetryEnabled)
    {
        $dataEvaluation = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new()
        $dataEvaluation.Add('Resource', "$Source")
        $dataEvaluation.Add('Method', 'Test-TargetResource')
        $dataEvaluation.Add('Tenant', $TenantName)

        $ConnectionMode = Get-M365DSCAuthenticationMode $DesiredValues
        $dataEvaluation.Add('ConnectionMode', $ConnectionMode)
        $dataEvaluation.Add('Parameters', $ValuesToCheck -join "`r`n")
        $dataEvaluation.Add('ParametersCount', $ValuesToCheck.Length)
        Add-M365DSCTelemetryEvent -Type 'DriftEvaluation' -Data $dataEvaluation
    }

    Initialize-M365DSCDllLoader -ErrorAction Stop
    $compareResult = [Microsoft365DSC.Compare.SimpleObjectComparer]::Compare($CurrentValues, $DesiredValues, $ValuesToCheck, $IncludedDrifts, $NoEventMessage, $NoDriftReset, $ExcludedProperties)
    $driftedParameters = $compareResult.DriftedParameters
    $driftObject = $compareResult.DriftObject
    $returnValue = $compareResult.TestResult

    $includeNonDriftsInformation = $false
    try
    {
        $includeNonDriftsInformation = [System.Environment]::GetEnvironmentVariable('M365DSCEventLogIncludeNonDrifted', `
                [System.EnvironmentVariableTarget]::Machine)
    }
    catch
    {
        Write-Verbose -Message $_
    }

    if ($returnValue -eq $false -or $DriftedParameters.Keys.Length -gt 0)
    {
        $EventMessage = [System.Text.StringBuilder]::new()
        $EventMessage.Append("<M365DSCEvent>`r`n") | Out-Null
        Write-Verbose -Message "Found Tenant Name: $TenantName"

        $LCMState = $null
        try
        {
            if (($PSEdition -eq 'Desktop' -or $IsWindows) -and ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) `
                -and $null -eq $Script:LCMInfo )
            {
                $Script:LCMInfo = Get-DscLocalConfigurationManager -ErrorAction Stop

                if ($Script:LCMInfo.LCMStateDetail -eq 'LCM is performing a consistency check.' -or `
                        $Script:LCMInfo.LCMStateDetail -eq 'LCM exécute une vérification de cohérence.' -or `
                        $Script:LCMInfo.LCMStateDetail -eq 'LCM führt gerade eine Konsistenzüberprüfung durch.')
                {
                    $LCMState = 'ConsistencyCheck'
                }
                elseif ($Script:LCMInfo.LCMStateDetail -eq 'LCM is testing node against the configuration.')
                {
                    $LCMState = 'ManualTestDSCConfiguration'
                }
                elseif ($Script:LCMInfo.LCMStateDetail -eq 'LCM is applying a new configuration.' -or `
                        $Script:LCMInfo.LCMStateDetail -eq 'LCM applique une nouvelle configuration.')
                {
                    $LCMState = 'Initial'
                }
            }
            else
            {
                $LCMState = 'Unauthorized'
            }
        }
        catch
        {
            Write-Verbose -Message $_.Exception
        }
        $EventMessage.Append(" <ConfigurationDrift Source=`"$Source`" TenantId=`"$TenantName`"") | Out-Null
        if (-not [System.String]::IsNullOrEmpty($LCMState))
        {
            $EventMessage.Append(" LCMState=`"" + $LCMState + "`"") | Out-Null
        }
        $EventMessage.Append(">`r`n") | Out-Null
        $EventMessage.Append(" <ParametersNotInDesiredState>`r`n") | Out-Null

        $DriftObject.Add('Tenant', $TenantName)
        $DriftObject.Add('Resource', $source.Split('_')[1])

        #endregion
        $telemetryDriftedParameters = ''
        foreach ($key in $DriftedParameters.Keys)
        {
            Write-Verbose -Message "Detected Drifted Parameter [$Source]$key"
            $telemetryDriftedParameters += $key + "`r`n"
            $EventMessage.Append(" <Param Name=`"$key`">" + $DriftedParameters.$key + "</Param>`r`n") | Out-Null
        }

        if (Test-IsM365DSCTelemetryEnabled)
        {
            $driftedData = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new()
            $driftedData.Add('Resource', $source.Split('_')[1])
            $driftedData.Add('Tenant', $TenantName)

            # If custom App Insights is specified, allow for the current and desired values to be captured;
            # ISSUE #1222
            if ($null -ne $env:M365DSCTelemetryInstrumentationKey -and `
                    $env:M365DSCTelemetryInstrumentationKey -ne 'bc5aa204-0b1e-4499-a955-d6a639bdb4fa' -and `
                    $env:M365DSCTelemetryInstrumentationKey -ne 'e670af5d-fd30-4407-a796-8ad30491ea7a')
            {
                $driftedData.Add('CurrentValues', $CurrentValues)
                $driftedData.Add('DesiredValues', $DesiredValues)
            }
            $driftedData.Add('Parameters', $telemetryDriftedParameters)
            Add-M365DSCTelemetryEvent -Type 'DriftInfo' -Data $driftedData
        }
        $EventMessage.Append(" </ParametersNotInDesiredState>`r`n") | Out-Null
        $EventMessage.Append(" </ConfigurationDrift>`r`n") | Out-Null
        $EventMessage.Append(" <DesiredValues>`r`n") | Out-Null
        foreach ($Key in $DesiredValues.Keys)
        {
            $Value = $DesiredValues.$Key
            if ([System.String]::IsNullOrEmpty($Value))
            {
                $Value = "`$null"
            }
            $EventMessage.Append(" <Param Name =`"$key`">$Value</Param>`r`n") | Out-Null
            $DriftObject.DesiredValues.Add($key, $value)
        }
        $EventMessage.Append(" </DesiredValues>`r`n") | Out-Null
        $EventMessage.Append(" <CurrentValues>`r`n") | Out-Null
        foreach ($Key in $CurrentValues.Keys)
        {
            $Value = $CurrentValues.$Key
            if ([System.String]::IsNullOrEmpty($Value))
            {
                $Value = "`$null"
            }
            $EventMessage.Append(" <Param Name =`"$key`">$Value</Param>`r`n") | Out-Null
            $DriftObject.CurrentValues.Add($key, $value)
        }
        $EventMessage.Append(" </CurrentValues>`r`n") | Out-Null
        $EventMessage.Append('</M365DSCEvent>') | Out-Null
        foreach ($drift in $DriftObject.DriftInfo)
        {
            $Global:AllDrifts.DriftInfo += @{
                PropertyName = $drift.PropertyName
                CurrentValue = $drift.CurrentValue
                DesiredValue = $drift.DesiredValue
            }
        }
        if (-not $NoEventMessage)
        {
            Add-M365DSCEvent -Message $EventMessage.ToString() -EventType 'Drift' -EntryType 'Warning' `
                -EventID 1 -Source $Source
        }
        $Global:CCMCurrentDriftInfo = $DriftObject
    }
    elseif ($includeNonDriftsInformation -eq $true)
    {
        # Include details about non-drifted resources.
        $EventMessage = [System.Text.StringBuilder]::new()
        $EventMessage.Append("<M365DSCEvent>`r`n") | Out-Null
        $EventMessage.Append(" <ConfigurationDrift Source=`"$Source`" />`r`n") | Out-Null
        $EventMessage.Append(" <DesiredValues>`r`n") | Out-Null
        foreach ($Key in $DesiredValues.Keys)
        {
            $Value = $DesiredValues.$Key
            if ([System.String]::IsNullOrEmpty($Value))
            {
                $Value = "`$null"
            }
            $EventMessage.Append(" <Param Name =`"$key`">$Value</Param>`r`n") | Out-Null
        }
        $EventMessage.Append(" </DesiredValues>`r`n") | Out-Null
        $EventMessage.Append('</M365DSCEvent>') | Out-Null
        Add-M365DSCEvent -Message $EventMessage.ToString() -EventType 'NonDrift' -EntryType 'Information' `
            -EventID 2 -Source $Source
    }

    if (Test-IsM365DSCTelemetryEnabled)
    {
        $timeTaken = [System.DateTime]::Now.Subtract($startTime).TotalMilliseconds
        $data = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new()
        $data.Add('Resource', $Source)
        $data.Add('Method', 'Test-M365DSCParameterState')
        $data.Add('TimeTakenMilliseconds', $timeTaken)
        $data.Add('Tenant', $TenantName)
        $data.Add('ParametersCount', $KeyList.Count)

        Add-M365DSCTelemetryEvent -Type 'ResourceTesting' `
            -Data $data
    }
    return $returnValue
}

<#
.SYNOPSIS
    Executes standardized target resource testing for a DSC resource.
 
.DESCRIPTION
    Retrieves current resource state, compares it with desired values, and returns the test result.
    Optionally returns a detailed pass-through object and writes drift details to event logs.
 
.PARAMETER DesiredValues
    Specifies desired parameter values passed to the target resource.
 
.PARAMETER ResourceName
    Specifies the DSC resource name to test.
 
.PARAMETER ExcludedProperties
    Specifies property names to exclude from comparison.
 
.PARAMETER IncludedProperties
    Specifies the explicit property names to compare.
 
.PARAMETER PostProcessing
    Specifies an optional callback to transform compare inputs before evaluation.
 
.PARAMETER PostProcessingArgs
    Specifies arguments passed to the post-processing callback.
 
.PARAMETER PassThru
    Indicates that a detailed result object should be returned instead of only a boolean.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Boolean
    System.Collections.Hashtable
#>

function Test-M365DSCTargetResource
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    [OutputType([System.Collections.Hashtable], ParameterSetName = 'PassThru')]
    param(
        [Parameter()]
        $DesiredValues,

        [Parameter()]
        [System.String]
        $ResourceName,

        [Parameter()]
        [System.String[]]
        $ExcludedProperties,

        [Parameter()]
        [System.String[]]
        $IncludedProperties,

        [Parameter()]
        [System.Func[Hashtable, Hashtable, Hashtable, [Object[]], Tuple[Hashtable, Hashtable, Hashtable]]]
        $PostProcessing,

        [Parameter()]
        [System.Object[]]
        $PostProcessingArgs = @(),

        [Parameter(
            ParameterSetName = 'PassThru'
        )]
        [switch]
        $PassThru
    )

    $Global:AllDrifts = @{
        DriftInfo     = @()
        CurrentValues = @{}
        DesiredValues = @{}
    }
    $Global:PotentialDrifts = @()

    #Ensure the proper dependencies are installed in the current environment.
    Confirm-M365DSCDependencies
    Initialize-M365DSCDllLoader -ErrorAction Stop

    if ($null -eq (Get-Module -Name 'M365DSCCompare'))
    {
        $compareModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'M365DSCCompare.psm1'
        Import-Module -Name $compareModulePath -Force
    }

    # Retrieve the primary keys of the given resource and remove them from the list of values to check.
    $currentPath = $PSScriptRoot
    if (-not [Microsoft365DSC.Cache.CacheManager]::IsSchemaLoaded)
    {
        $schemaPath = Join-Path -Path $currentPath -ChildPath '../SchemaDefinition.json'
        if (-not (Test-Path -Path $schemaPath))
        {
            throw "SchemaDefinition.json not found at expected path: $schemaPath. Ensure that the schema was properly included during module build and that the module is not being run from a non-standard location."
        }
        $schemaContent = [System.IO.File]::ReadAllText($schemaPath) | ConvertFrom-Json
        [Microsoft365DSC.Cache.CacheManager]::LoadSchema($schemaContent)
    }
    $resourceDefinition = [Microsoft365DSC.Utilities.Utilities]::FilterLoadedCimClassesByName("MSFT_$ResourceName")
    $resourceKeys = $resourceDefinition.Parameters | Where-Object -Property Option -EQ 'Key'

    $keyStrings = @()
    foreach ($resourceKey in $resourceKeys)
    {
        $keyName = $resourceKey.Name
        $keyStrings += "$keyName {$($DesiredValues.$keyName)}"
    }
    $finalString = $keyStrings -join ' and '

    $Verbose = $false
    if ($DesiredValues.Verbose -eq $true)
    {
        $Verbose = $true
    }

    Write-Verbose -Message "Testing configuration of the $ResourceName with $finalString" -Verbose:$Verbose

    $CurrentValues = & MSFT_$ResourceName\Get-TargetResource @DesiredValues

    $testTargetResource = Compare-M365DSCResourceState -ResourceName $ResourceName `
        -DesiredValues $DesiredValues `
        -CurrentValues $CurrentValues `
        -ExcludedProperties $ExcludedProperties `
        -IncludedProperties $IncludedProperties `
        -PostProcessing $PostProcessing `
        -PostProcessingArgs $PostProcessingArgs

    if (-not $testTargetResource)
    {
        $TenantName = Get-M365DSCTenantNameFromParameterSet -ParameterSet $DesiredValues
        Write-M365DSCDriftsToEventLog -Drifts $Global:AllDrifts `
            -ResourceName $ResourceName `
            -TenantName $TenantName `
            -CurrentValues $CurrentValues `
            -DesiredValues $DesiredValues `
            -Verbose:$Verbose
    }

    Write-Verbose -Message "Test-M365DSCTargetResource returned $testTargetResource" -Verbose:$Verbose

    if ($PassThru)
    {
        return @{
            ResourceName       = $ResourceName
            CurrentValues      = $CurrentValues
            DesiredValues      = $DesiredValues
            TestTargetResource = $testTargetResource
        }
    }

    return $testTargetResource
}

<#
.SYNOPSIS
    Stores the DSC resource dictionary in module scope.
 
.DESCRIPTION
    Sets the cached dictionary of resource metadata used by other helper functions.
 
.PARAMETER DscResourceDictionary
    Specifies the resource dictionary to cache.
 
.FUNCTIONALITY
    Internal
#>

function Set-M365DSCAllResourcesDictionary
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        $DscResourceDictionary
    )

    $Script:AllM365DSCResources = $DscResourceDictionary
}

<#
.SYNOPSIS
    Returns the cached DSC resource dictionary.
 
.DESCRIPTION
    Returns the module-scoped dictionary containing loaded Microsoft365DSC resource metadata.
 
.FUNCTIONALITY
    Internal
#>

function Get-M365DSCAllResourcesDictionary
{
    [CmdletBinding()]
    param()

    $Script:AllM365DSCResources
}

<#
.SYNOPSIS
    Initializes the cached DSC resource dictionary.
 
.DESCRIPTION
    Loads Microsoft365DSC resource metadata into a module-scoped dictionary when not already initialized.
 
.FUNCTIONALITY
    Internal
#>

function Initialize-M365DSCAllResourcesDictionary
{
    [CmdletBinding()]
    param()

    if ($null -eq $Script:AllM365DSCResources -and -not $Global:IsTestEnvironment)
    {
        $Script:AllM365DSCResources = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new([System.StringComparer]::InvariantCultureIgnoreCase)
        $resources = Get-DscResourceV2 -Module 'Microsoft365DSC'
        foreach ($resource in $resources)
        {
            $Script:AllM365DSCResources.Add($resource.Name, $resource)
        }
    }
}

<#
.SYNOPSIS
    Warns when the current code page is not UTF-8.
 
.DESCRIPTION
    Checks the active code page and emits guidance when UTF-8 is not configured, because non-UTF8 sessions can cause Unicode issues in exported content.
 
.EXAMPLE
    PS> Test-CodePage
 
.FUNCTIONALITY
    Private
#>

function Test-CodePage
{
    if ([System.Text.Encoding]::Default.CodePage -ne 65001)
    {
        Write-Warning -Message 'The code page of the current session is not set to UTF-8. This may cause issues with Unicode characters.
         To change the code page to UTF-8, you have the following options:
         * Using the control panel: intl.cpl --> Administrative --> Change system locale --> Beta: Use Unicode UTF-8 for worldwide language support
         * Using PowerShell: Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage" -Name "ACP" -Value 65001
         After that, you need to restart the PowerShell session.'

    }
}

<#
.SYNOPSIS
    Installs the Microsoft365DSC Dev branch package.
 
.DESCRIPTION
    Downloads the Dev branch archive from GitHub, installs required dependencies, and updates the local Microsoft365DSC module files.
 
.PARAMETER Scope
    Specifies the installation scope used for dependency installation.
 
.EXAMPLE
    Install-M365DSCDevBranch
 
.EXAMPLE
    Install-M365DSCDevBranch -Scope CurrentUser
 
.FUNCTIONALITY
    Public
#>

function Install-M365DSCDevBranch
{
    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('CurrentUser', 'AllUsers')]
        $Scope = 'AllUsers'
    )

    try
    {

        $longPathsEnabled = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem').LongPathsEnabled -eq 1
        if (-not $longPathsEnabled)
        {
            $message = 'Long paths are not enabled on this system. You may encounter issues with the installation of Microsoft365DSC because of long file names.'
            $message += 'To enable long paths, set the registry LongPathsEnabled DWORD entry to 1 in HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem.'
            Write-Warning -Message $message
        }

        #region Download and Extract Dev branch's ZIP
        Write-Host 'Downloading the Zip package...' -NoNewline
        $url = 'https://github.com/microsoft/Microsoft365DSC/archive/Dev.zip'
        $output = "$($env:TEMP)/dev.zip"
        $extractPath = "$($env:TEMP)/O365Dev"
        Write-Host 'Done' -ForegroundColor Green

        Invoke-WebRequest -Uri $url -OutFile $output -UseBasicParsing

        Expand-Archive $output -DestinationPath $extractPath -Force
        #endregion

        #region Install All Dependencies
        $manifest = Import-PowerShellDataFile "$extractPath\Microsoft365DSC-Dev\Modules\Microsoft365DSC\Microsoft365DSC.psd1"
        $dependencies = $manifest.RequiredModules
        if (($PSEdition -eq 'Desktop' -or $IsWindows) -and (-not(([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) -and ($Scope -eq 'AllUsers'))
        {
            throw 'Cannot update the dependencies for Microsoft365DSC. You need to run this command as a local administrator.'
        }

        foreach ($dependency in $dependencies)
        {
            Write-Host "Installing {$($dependency.ModuleName)}..." -NoNewline
            $existingModule = Get-Module $dependency.ModuleName -ListAvailable | Where-Object -Property Version -EQ $dependency.RequiredVersion
            if ($null -eq $existingModule)
            {
                Install-Module $dependency.ModuleName -RequiredVersion $dependency.RequiredVersion -Force -AllowClobber -Scope $Scope | Out-Null
            }
            Import-Module $dependency.ModuleName -Force | Out-Null
            Write-Host 'Done' -ForegroundColor Green
        }
        #endregion

        #region Install M365DSC
        Write-Host 'Updating the Core Microsoft365DSC module...' -NoNewline
        $defaultPath = 'C:\Program Files\WindowsPowerShell\Modules\Microsoft365DSC\'
        $currentVersionPath = $defaultPath + ([Version]$($manifest.ModuleVersion)).ToString()

        Copy-Item "$extractPath\Microsoft365DSC-Dev\Modules\Microsoft365DSC\*" `
            -Destination $defaultPath -Recurse -Force

        Import-Module ($defaultPath + 'Microsoft365DSC.psd1') -Force | Out-Null
        $oldModule = Get-Module 'Microsoft365DSC' | Where-Object -Property ModuleBase -EQ $currentVersionPath
        Remove-Module $oldModule -Force | Out-Null
        if (Test-Path $currentVersionPath)
        {
            try
            {
                Remove-Item $currentVersionPath -Recurse -Confirm:$false -Force `
                    -ErrorAction Stop
            }
            catch
            {
                Write-Verbose -Message $_
            }
        }
        Write-Host 'Done' -ForegroundColor Green
        #endregion
    }
    catch
    {
        New-M365DSCLogEntry -Message 'Error installing Dev Branch:' `
            -Exception $_ `
            -Source $($MyInvocation.MyCommand.Source)
        Write-Error $_
    }
}

<#
.SYNOPSIS
    Retrieves package files from the SharePoint tenant app catalog.
 
.DESCRIPTION
    Connects to SharePoint Online, enumerates app catalog package files, and returns package metadata used by export and validation routines.
 
.PARAMETER Credential
    Specifies delegated credentials used for SharePoint connection.
 
.PARAMETER ApplicationId
    Specifies the application id used for app-based authentication.
 
.PARAMETER TenantId
    Specifies the tenant id or tenant domain used for authentication.
 
.PARAMETER CertificatePath
    Specifies the certificate path used for app-based authentication.
 
.PARAMETER CertificatePassword
    Specifies the certificate password used for app-based authentication.
 
.PARAMETER CertificateThumbprint
    Specifies the certificate thumbprint used for app-based authentication.
 
.PARAMETER ManagedIdentity
    Indicates that managed identity authentication should be used.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable[]
#>

function Get-AllSPOPackages
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable[]])]
    param
    (
        [Parameter()]
        [System.Management.Automation.PSCredential]
        $Credential,

        [Parameter()]
        [System.String]
        $ApplicationId,

        [Parameter()]
        [System.String]
        $TenantId,

        [Parameter()]
        [System.String]
        $CertificatePath,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $CertificatePassword,

        [Parameter()]
        [System.String]
        $CertificateThumbprint,

        [Parameter()]
        [Switch]
        $ManagedIdentity
    )

    try
    {
        $null = New-M365DSCConnection -Workload 'PnP' `
            -InboundParameters $PSBoundParameters

        $tenantAppCatalogUrl = Get-PnPTenantAppCatalogUrl -ErrorAction Stop

        $null = New-M365DSCConnection -Workload 'PnP' `
            -InboundParameters $PSBoundParameters `
            -Url $tenantAppCatalogUrl

        $filesToDownload = @()
        $allFiles = @()
        if ($null -ne $tenantAppCatalogUrl)
        {
            try
            {
                [Array]$spfxFiles = @(Find-PnPFile -List 'AppCatalog' -Match '*.sppkg' -ErrorAction Stop)
                [Array]$appFiles = @(Find-PnPFile -List 'AppCatalog' -Match '*.app' -ErrorAction Stop)

                $allFiles = $spfxFiles + $appFiles

                foreach ($file in $allFiles)
                {
                    $filesToDownload += @{
                        Name  = $file.Name
                        Site  = $tenantAppCatalogUrl
                        Title = $file.Title
                    }
                }
            }
            catch
            {
                New-M365DSCLogEntry -Message $_.Exception.Message `
                    -Exception $_ `
                    -Source $($MyInvocation.MyCommand.Source) `
                    -TenantId $TenantId `
                    -Credential $Credential
            }
        }

        return $filesToDownload
    }
    catch
    {
        Write-Verbose -Message $_
    }

    return $null
}

<#
.SYNOPSIS
    Removes null or empty values from a hashtable.
 
.DESCRIPTION
    Scans a hashtable and removes keys whose values are null or empty strings.
 
.PARAMETER Hash
    Specifies the hashtable to clean.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable
#>

function Remove-NullEntriesFromHashtable
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.COllections.HashTable]
        $Hash
    )

    $keysToRemove = @()
    foreach ($key in $Hash.Keys)
    {
        if ([System.String]::IsNullOrEmpty($Hash.$key))
        {
            $keysToRemove += $key
        }
    }

    foreach ($key in $keysToRemove)
    {
        $Hash.Remove($key) | Out-Null
    }

    return $Hash
}

<#
.SYNOPSIS
    Compares a tenant export against a blueprint configuration.
 
.DESCRIPTION
    Downloads or opens a blueprint file, exports matching tenant resources, and generates a delta report showing drift between blueprint and tenant state.
 
.PARAMETER BluePrintUrl
    Specifies the blueprint URL or local path.
 
.PARAMETER OutputReportPath
    Specifies the output path of the generated report.
 
.PARAMETER Credentials
    Specifies delegated credentials used for authentication.
 
.PARAMETER ApplicationId
    Specifies the application id used for app-based authentication.
 
.PARAMETER TenantId
    Specifies the tenant id or tenant domain used for authentication.
 
.PARAMETER ApplicationSecret
    Specifies the application secret used for app-based authentication.
 
.PARAMETER CertificatePath
    Specifies the certificate path used for app-based authentication.
 
.PARAMETER CertificatePassword
    Specifies the certificate password used for app-based authentication.
 
.PARAMETER CertificateThumbprint
    Specifies the certificate thumbprint used for app-based authentication.
 
.PARAMETER ManagedIdentity
    Indicates that managed identity authentication should be used.
 
.PARAMETER AccessTokens
    Specifies access tokens used for authentication.
 
.PARAMETER HeaderFilePath
    Specifies a custom report header file path.
 
.PARAMETER Type
    Specifies the delta report output type.
 
.PARAMETER ExcludedProperties
    Specifies property names to exclude from comparison.
 
.PARAMETER ExcludedResources
    Specifies resource names to exclude from comparison.
 
.PARAMETER DriftOnly
    Indicates that only drifts should be included in the report.
 
.PARAMETER KeepExport
    Indicates that the temporary export should be retained.
 
.PARAMETER UseVariableSubstitution
    Indicates that variable substitution should be applied during comparison.
 
.PARAMETER SourceConfigurationDataPath
    Specifies source configuration data for variable substitution.
 
.PARAMETER DestinationConfigurationDataPath
    Specifies destination configuration data for variable substitution.
 
.PARAMETER ExcludedSubstitutionProperties
    Specifies properties excluded from substitution.
 
.PARAMETER Parallel
    Indicates that export should run in parallel.
 
.EXAMPLE
    PS> Assert-M365DSCBlueprint -BluePrintUrl 'C:\DS\blueprint.m365' -OutputReportPath 'C:\DSC\BlueprintReport.html'
 
.EXAMPLE
    PS> Assert-M365DSCBlueprint -BluePrintUrl 'C:\DS\blueprint.m365' -OutputReportPath 'C:\DSC\BlueprintReport.html' -Credentials $credentials -HeaderFilePath 'C:\DSC\ReportCustomHeader.html'
 
.EXAMPLE
    PS> Assert-M365DSCBlueprint -BluePrintUrl 'C:\DS\blueprint.m365' -OutputReportPath 'C:\DSC\BlueprintReport.html' -ApplicationId $clientid -TenantId $tenantId -CertificateThumbprint $certthumbprint -HeaderFilePath 'C:\DSC\ReportCustomHeader.html'
 
.EXAMPLE
    PS> Assert-M365DSCBlueprint -BluePrintUrl 'C:\DS\blueprint.m365' -OutputReportPath 'C:\DSC\BlueprintReport.html' -KeepExport $true
 
.FUNCTIONALITY
    Public
 
#>

function Assert-M365DSCBlueprint
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $BluePrintUrl,

        [Parameter(Mandatory = $true)]
        [System.String]
        $OutputReportPath,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $Credentials,

        [Parameter()]
        [System.String]
        $ApplicationId,

        [Parameter()]
        [System.String]
        $TenantId,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $ApplicationSecret,

        [Parameter()]
        [System.String]
        $CertificatePath,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $CertificatePassword,

        [Parameter()]
        [System.String]
        $CertificateThumbprint,

        [Parameter()]
        [Switch]
        $ManagedIdentity,

        [Parameter()]
        [System.String[]]
        $AccessTokens,

        [Parameter()]
        [System.String]
        $HeaderFilePath,

        [Parameter()]
        [System.String]
        [ValidateSet('HTML', 'JSON')]
        $Type = 'HTML',

        [Parameter()]
        [System.String[]]
        $ExcludedProperties,

        [Parameter()]
        [System.String[]]
        $ExcludedResources,

        [Parameter()]
        [System.Boolean]
        $DriftOnly = $true,

        [Parameter()]
        [System.Boolean]
        $KeepExport = $false,

        [Parameter()]
        [Switch]
        $UseVariableSubstitution,

        [Parameter()]
        [System.String]
        $SourceConfigurationDataPath,

        [Parameter()]
        [System.String]
        $DestinationConfigurationDataPath,

        [Parameter()]
        [System.String[]]
        $ExcludedSubstitutionProperties,

        [Parameter()]
        [Switch]
        $Parallel
    )

    #Ensure the proper dependencies are installed in the current environment.
    Confirm-M365DSCDependencies

    #region Telemetry
    $data = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new()
    $data.Add('Event', 'AssertBlueprint')
    $data.Add('BluePrint', $BluePrintUrl)
    Add-M365DSCTelemetryEvent -Data $data
    #endregion

    $TempBluePrintName = 'TempBlueprint_' + (New-Guid).ToString() + '.M365'
    $LocalBluePrintPath = Join-Path -Path $env:TEMP -ChildPath $TempBluePrintName
    try
    {
        # Download the BluePrint locally in a temp location
        Invoke-WebRequest -Uri $BluePrintUrl -OutFile $LocalBluePrintPath -UseBasicParsing
    }
    catch
    {
        # If the download failed, we assume the provided Url was a local path
        # and we try copying the item instead.
        try
        {
            Copy-Item -Path $BluePrintUrl -Destination $LocalBluePrintPath
        }
        catch
        {
            throw $_
        }
    }

    if (Test-Path -Path $LocalBluePrintPath)
    {
        # Parse the content of the BluePrint into an array of PowerShell Objects
        $fileContent = Get-Content $LocalBluePrintPath -Raw
        $startPosition = $fileContent.IndexOf(' -ModuleVersion')
        if ($startPosition -gt 0)
        {
            $endPosition = $fileContent.IndexOf("`r", $startPosition)
            $fileContent = $fileContent.Remove($startPosition, $endPosition - $startPosition)
        }

        try
        {
            $parsedBluePrint = ConvertTo-DSCObject -Content $fileContent
        }
        catch
        {
            throw $_
        }

        # Generate an Array of Resource Types contained in the BluePrint
        $ResourcesInBluePrint = @()
        foreach ($resource in $parsedBluePrint)
        {
            if ($resource.ResourceName -in $ExcludedResources)
            {
                continue
            }
            if ($ResourcesInBluePrint -notcontains $resource.ResourceName)
            {
                $ResourcesInBluePrint += $resource.ResourceName
            }
        }

        if ([String]::IsNullOrEmpty($ResourcesInBluePrint))
        {
            if (![String]::IsNullOrEmpty($ExcludedResources))
            {
                Write-Host 'All resources were excluded from BluePrint, aborting'
            }
            else
            {
                Write-Host 'Malformed BluePrint, aborting'
            }
            break
        }

        Write-Host "Selected BluePrint contains ($($ResourcesInBluePrint.Length)) components to assess."

        # Call the Export-M365DSCConfiguration cmdlet to extract only the resource
        # types contained within the BluePrint;
        Write-Host "Initiating the Export of those ($($ResourcesInBluePrint.Length)) components from the tenant..."
        $TempExportName = 'TempExport_' + (New-Guid).ToString() + '.ps1'
        Export-M365DSCConfiguration -Components $ResourcesInBluePrint `
            -Path $env:TEMP `
            -FileName $TempExportName `
            -Parallel:$Parallel.IsPresent `
            -Credential $Credentials `
            -ApplicationId $ApplicationId `
            -ApplicationSecret $ApplicationSecret `
            -TenantId $TenantId `
            -CertificateThumbprint $CertificateThumbprint `
            -CertificatePath $CertificatePath `
            -CertificatePassword $CertificatePassword `
            -ManagedIdentity:$ManagedIdentity.IsPresent `
            -AccessTokens $AccessTokens

        # Call the New-M365DSCDeltaReport configuration to generate the Delta Report between
        # the BluePrint and the extracted resources;
        $ExportPath = Join-Path -Path $env:TEMP -ChildPath $TempExportName
        $deltaReportParams = @{
            Source                = $ExportPath
            Destination           = $LocalBluePrintPath
            OutputPath            = $OutputReportPath
            DriftOnly             = $DriftOnly
            IsBlueprintAssessment = $true
            HeaderFilePath        = $HeaderFilePath
            Type                  = $Type
            ExcludedProperties    = $ExcludedProperties
            ExcludedResources     = $ExcludedResources
        }

        if ($UseVariableSubstitution)
        {
            $deltaReportParams.UseVariableSubstitution = $true
            if (-not [System.String]::IsNullOrEmpty($SourceConfigurationDataPath))
            {
                $deltaReportParams.SourceConfigurationDataPath = $SourceConfigurationDataPath
            }
            if (-not [System.String]::IsNullOrEmpty($DestinationConfigurationDataPath))
            {
                $deltaReportParams.DestinationConfigurationDataPath = $DestinationConfigurationDataPath
            }
            if ($null -ne $ExcludedSubstitutionProperties -and $ExcludedSubstitutionProperties.Count -gt 0)
            {
                $deltaReportParams.ExcludedSubstitutionProperties = $ExcludedSubstitutionProperties
            }
        }

        New-M365DSCDeltaReport @deltaReportParams

        # Clean up the temporary files
        Remove-Item $LocalBluePrintPath -Force -ErrorAction SilentlyContinue
        if (!$KeepExport)
        {
            Remove-Item $ExportPath -Force -ErrorAction SilentlyContinue
        }
    }
    else
    {
        Write-Error "M365DSC Template Path {$LocalBluePrintPath} does not exist."
    }
}

<#
.SYNOPSIS
    Returns all Microsoft365DSC resource names in the module.
 
.DESCRIPTION
    Enumerates resource module files and returns resource names without MSFT_ prefix.
 
.FUNCTIONALITY
    Public
 
.OUTPUTS
    System.String[]
#>

function Get-M365DSCAllResources
{
    [CmdletBinding()]
    [OutputType([System.String[]])]
    [CmdletBinding()]
    param ()

    if ($null -eq $Script:allResourcesPath)
    {
        $Script:allResourcesPath = Get-M365DSCAllResourcesPath
    }

    return $Script:allResourcesPath.Name -replace 'MSFT_', '' -replace '.psm1', ''
}

<#
.SYNOPSIS
    Returns the Microsoft365DSC DSC resource module files from the DscResources folder.
 
.DESCRIPTION
    Enumerates the Microsoft365DSC DscResources directory and returns the PowerShell module files for all DSC resources.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.IO.FileInfo[]
 
.NOTES
    This helper is used by Get-M365DSCAllResources to discover available resources.
#>

function Get-M365DSCAllResourcesPath
{
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo[]])]
    [CmdletBinding()]
    param ()

    $Script:allResourcesPath = Get-ChildItem -Path ($PSScriptRoot + '/../DscResources/') -Recurse -Filter '*.psm1' -File

    return $Script:allResourcesPath
}

<#
.SYNOPSIS
    Compares resources between two installed Microsoft365DSC versions.
 
.DESCRIPTION
    Resolves two installed module versions and returns added and removed resource names by comparing their DscResources folders.
 
.PARAMETER PreviousVersion
    Specifies the older Microsoft365DSC version to compare.
 
.PARAMETER CurrentVersion
    Specifies the newer Microsoft365DSC version to compare. When omitted, the latest installed version is used.
 
.EXAMPLE
    PS> Get-M365DSCNewResources -PreviousVersion '1.24.501.1' -CurrentVersion '1.24.515.1'
 
.EXAMPLE
    PS> Get-M365DSCNewResources -PreviousVersion '1.24.501.1'
 
.OUTPUTS
    A hashtable with two keys: 'Added' and 'Removed'. Each key contains an array of resource names that were added or removed in the current version compared to the previous version.
 
.FUNCTIONALITY
    Public
#>

function Get-M365DSCResourceDifferences
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $PreviousVersion,

        [Parameter()]
        [System.String]
        $CurrentVersion
    )

    [Array]$installedModules = Get-Module 'Microsoft365DSC' -ListAvailable | Sort-Object -Property Version -Descending

    if ($installedModules.Count -eq 0)
    {
        Write-Error -Message 'No installed versions of Microsoft365DSC were found.'
        return ,@()
    }

    # Resolve current version
    if ([System.String]::IsNullOrEmpty($CurrentVersion))
    {
        $currentModule = $installedModules[0]
    }
    else
    {
        $currentModule = $installedModules | Where-Object -Property Version -EQ $CurrentVersion
    }

    if ($null -eq $currentModule)
    {
        throw "Microsoft365DSC version '$CurrentVersion' is not installed."
    }

    # Resolve previous version
    $previousModule = $installedModules | Where-Object -Property Version -EQ $PreviousVersion
    if ($null -eq $previousModule)
    {
        throw "Microsoft365DSC version '$PreviousVersion' is not installed."
    }

    # Get resources from each version by scanning their DscResources folders
    $currentResourcesPath = Join-Path -Path $currentModule.ModuleBase -ChildPath 'DscResources'
    $previousResourcesPath = Join-Path -Path $previousModule.ModuleBase -ChildPath 'DscResources'

    $currentResources = Get-ChildItem -Path $currentResourcesPath -Recurse -Filter '*.psm1' -File |
        ForEach-Object { $_.Name -replace 'MSFT_', '' -replace '\.psm1', '' }

    $previousResources = Get-ChildItem -Path $previousResourcesPath -Recurse -Filter '*.psm1' -File |
        ForEach-Object { $_.Name -replace 'MSFT_', '' -replace '\.psm1', '' }

    # Return resources present in current but not in previous
    $newResources = $currentResources | Where-Object -FilterScript { $_ -notin $previousResources } | Sort-Object
    $removedResources = $previousResources | Where-Object -FilterScript { $_ -notin $currentResources } | Sort-Object
    return @{
        Added = $newResources
        Removed = $removedResources
    }
}

<#
.Description
This function checks if the specified object has the specified property
 
.Functionality
Internal, Hidden
#>

function Test-M365DSCObjectHasProperty
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true, Position = 1)]
        [Object]
        $Object,

        [Parameter(Mandatory = $true, Position = 2)]
        [String]
        $PropertyName
    )

    if (([bool]($Object.PSobject.Properties.Name -contains $PropertyName)) -eq $true)
    {
        if ($null -ne $Object.$PropertyName)
        {
            return $true
        }
    }
    return $false
}

<#
.SYNOPSIS
    Derives workload names from resource names.
 
.DESCRIPTION
    Maps resource name prefixes to known workload identifiers and returns distinct workload values.
 
.PARAMETER ResourceName
    Specifies resource names to map to workloads.
 
.EXAMPLE
    PS> Get-M365DSCWorkloadForResource -ResourceName AADUser
 
.EXAMPLE
    PS> Get-M365DSCWorkloadForResource -ResourceName @('AADUser', 'AADGroup')
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.String[]
#>

function Get-M365DSCWorkloadForResource
{
    [CmdletBinding()]
    [OutputType([System.String[]])]
    param
    (
        [Parameter(Mandatory = $true, Position = 1)]
        [System.String[]]
        $ResourceName
    )

    $workloads = @()
    foreach ($resource in $ResourceName)
    {
        foreach ($workload in $Script:M365DSCWorkloads)
        {
            if ($resource -like "$($workload)*")
            {
                if ($workloads -notcontains $workload)
                {
                    $workloads += $workload
                    break
                }
            }
        }
    }

    return $workloads | Sort-Object
}

<#
.SYNOPSIS
    Generates Markdown documentation for public Microsoft365DSC cmdlets.
 
.DESCRIPTION
    Reads module help and command metadata, then creates per-cmdlet Markdown files in the docs cmdlets folder.
 
.FUNCTIONALITY
    Internal
#>

function New-M365DSCCmdletDocumentation
{
    param()

    $cmdletDocsRoot = Join-Path -Path $PSScriptRoot -ChildPath '../../../docs/docs/user-guide/cmdlets'

    if ((Test-Path -Path $cmdletDocsRoot) -eq $false)
    {
        $null = New-Item -Path $cmdletDocsRoot -ItemType Directory
    }

    $filesInFolder = Get-ChildItem -Path $cmdletDocsRoot
    if ($filesInFolder.Count -ne 0)
    {
        Remove-Item -Path $filesInFolder.FullName -Confirm:$false
    }

    Write-Host -Object ' '
    Write-Host -Object 'Creating Markdown documentation for M365DSC cmdlets:' -ForegroundColor Gray

    $counter = 0
    foreach ($command in (Get-Module Microsoft365DSC).ExportedCommands.GetEnumerator())
    {
        $commandName = $command.Key
        $helpInfo = Get-Help $commandName
        $functionality = $helpInfo.Functionality -split ', '
        if ('Public' -in $functionality)
        {
            Write-Host -Object " * $commandName " -ForegroundColor Gray -NoNewline

            $output = New-Object -TypeName System.Text.StringBuilder

            $null = $output.AppendLine("# $($commandName)")
            $null = $output.AppendLine()

            $helpInfo = Get-Help -Name $commandName
            if ($helpInfo.description.Count -ne 0)
            {
                $null = $output.AppendLine('## Description')
                $null = $output.AppendLine()
                $null = $output.AppendLine($helpInfo.Description[0].Text)
                $null = $output.AppendLine()
            }

            $cmd = Get-Command -Name $commandName
            if ([String]::IsNullOrEmpty($cmd.OutputType) -eq $false)
            {
                $null = $output.AppendLine('## Output')
                $null = $output.AppendLine()
                $null = $output.AppendLine('This function outputs information as the following type:')
                $null = $output.AppendLine("**$($cmd.OutputType)**")
                $null = $output.AppendLine()
            }
            else
            {
                $null = $output.AppendLine('## Output')
                $null = $output.AppendLine()
                $null = $output.AppendLine('This function does not generate any output.')
                $null = $output.AppendLine()
            }

            $ast = $cmd.ScriptBlock.Ast
            $parameters = $null
            $parameters = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.ParameterAst] }, $true)

            $null = $output.AppendLine('## Parameters')
            $null = $output.AppendLine()
            if ($parameters.Count -gt 0)
            {
                $null = $output.AppendLine('| Parameter | Required | DataType | Default Value | Allowed Values | Description |')
                $null = $output.AppendLine('| --- | --- | --- | --- | --- | --- |')

                $ast = $cmd.ScriptBlock.Ast
                $parameters = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.ParameterAst] }, $true)
                foreach ($parameter in $parameters)
                {
                    $paramName = $parameter.Name.VariablePath.UserPath

                    $paramHelp = $helpInfo.parameters.parameter | Where-Object -Property Name -EQ $paramName
                    $description = ''
                    if ($paramHelp.description.Count -gt 0)
                    {
                        $description = $paramHelp.description[0].Text
                    }
                    $mandatory = $parameter.Attributes.Where({ $_.TypeName.FullName -eq 'Parameter' }).NamedArguments.Where({ $_.ArgumentName -eq 'Mandatory' }).Argument.VariablePath.UserPath
                    if ($null -eq $mandatory)
                    {
                        $mandatory = 'False'
                    }
                    $mandatory = (Get-Culture).TextInfo.ToTitleCase($mandatory.ToLower())

                    $defaultValue = " $($parameter.DefaultValue.Value) "
                    if ($defaultValue -eq ' ')
                    {
                        $defaultValue = ' '
                    }
                    $validateSetValue = " $($parameter.Attributes.Where({$_.TypeName.FullName -eq 'ValidateSet'}).PositionalArguments.Value -join ', ') "
                    if ($validateSetValue -eq ' ')
                    {
                        $validateSetValue = ' '
                    }
                    $description = " $($description.Split("`n") -join ' ') "
                    if ($description -eq ' ')
                    {
                        $description = ' '
                    }
                    $null = $output.AppendLine("| $($paramName) | $($mandatory) | $($parameter.StaticType.Name) |$defaultValue|$validateSetValue|$description|")
                }
                $null = $output.AppendLine()
            }
            else
            {
                $null = $output.AppendLine('This function does not have any input parameters.')
                $null = $output.AppendLine()
            }

            if ($helpInfo.examples.example.Count -ne 0)
            {
                $null = $output.AppendLine('## Examples')
                $null = $output.AppendLine()
                foreach ($example in $helpInfo.examples.example)
                {
                    $null = $output.AppendLine($example.title)
                    $null = $output.AppendLine()
                    $null = $output.AppendLine("``$($example.code)``")
                    $null = $output.AppendLine()
                }
            }

            $savePath = Join-Path -Path $cmdletDocsRoot -ChildPath "$commandName.md"
            $null = Out-File `
                -InputObject ($output.ToString() -replace '\r?\n', "`r`n").TrimEnd("`r`n") `
                -FilePath $savePath `
                -Encoding utf8 `
                -Force:$Force
            Write-Host -Object $Global:M365DSCEmojiGreenCheckmark -ForegroundColor Gray
            $counter++
        }
    }

    Write-Host -Object ' '
    Write-Host -Object "Total number files created: $counter" -ForegroundColor Gray
    Write-Host -Object ' '
}

<#
.Description
This function creates an example from the resource schema, using ReverseDSC code.
 
.Parameter ResourceName
Specifies the resource name for which the example should be generated.
 
.Functionality
Internal, Hidden
#>

function New-M365DSCResourceExample
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName
    )

    $resource = Get-DscResourceV2 -Name $ResourceName
    $params = Get-DSCFakeParameters -ModulePath $resource.Path
    $params.Credential = '$Credscredential'

    if ($params.ContainsKey('ApplicationId'))
    {
        $params.Remove('ApplicationId')
    }

    if ($params.ContainsKey('TenantId'))
    {
        $params.Remove('TenantId')
    }

    if ($params.ContainsKey('ApplicationSecret'))
    {
        $params.Remove('ApplicationSecret')
    }

    if ($params.ContainsKey('CertificateThumbprint'))
    {
        $params.Remove('CertificateThumbprint')
    }

    if ($params.ContainsKey('CertificatePath'))
    {
        $params.Remove('CertificatePath')
    }

    if ($params.ContainsKey('CertificatePassword'))
    {
        $params.Remove('CertificatePassword')
    }

    [string]$userName = 'admin@contoso.onmicrosoft.com'
    [string]$userPassword = 'dummypassword'
    [securestring]$secStringPassword = ConvertTo-SecureString $userPassword -AsPlainText -Force
    [pscredential]$credObject = New-Object System.Management.Automation.PSCredential ($userName, $secStringPassword)

    $resourceExample = Get-M365DSCExportContentForResource -ResourceName $ResourceName -ModulePath $resource.Path -Results $params -ConnectionMode Credentials -Credential $credObject

    $resourceExample = $resourceExample.TrimEnd() -replace ';', ''

    $exampleText = @"
<#
This example is used to test new resources and showcase the usage of new resources being worked on.
It is not meant to use as a production baseline.
#>
 
Configuration Example
{
    param
    (
        [Parameter(Mandatory = `$true)]
        [PSCredential]
        `$Credscredential
    )
    Import-DscResource -ModuleName Microsoft365DSC
 
    node localhost
    {
$resourceExample
    }
}
"@


    return $exampleText
}

<#
.SYNOPSIS
    Creates missing resource examples and removes stale example folders.
 
.DESCRIPTION
    Compares available resources and existing example folders, generates missing examples, and removes examples for resources that no longer exist.
 
.FUNCTIONALITY
    Internal
#>

function New-M365DSCMissingResourcesExample
{
    $location = $PSScriptRoot

    $m365Resources = Get-DscResourceV2 -Module 'Microsoft365DSC' | Select-Object -ExpandProperty Name
    $examplesPath = Join-Path $location -ChildPath '../../../Examples/Resources'
    $examples = Get-ChildItem -Path $examplesPath | Where-Object -Property PsIsContainer -EQ $true | Select-Object -ExpandProperty Name

    [array]$differences = Compare-Object -ReferenceObject $m365Resources -DifferenceObject $examples

    $count = 1
    $total = $differences.Count

    foreach ($difference in $differences)
    {
        Write-Host "[$count/$total] Processing $($difference.InputObject)"
        $path = Join-Path -Path './Examples/Resources' -ChildPath $difference.InputObject
        switch ($difference.SideIndicator)
        {
            '<='
            {
                Write-Host ' - Example missing, generating!'
                $null = New-Item -Path $path -ItemType Directory
                $exampleFile = Join-Path -Path $path -ChildPath '1-Configure.ps1'
                Set-Content -Path $exampleFile -Value (New-M365DSCResourceExample -ResourceName $difference.InputObject)
            }
            '=>'
            {
                Write-Host ' - No resource for existing example, removing!'
                Remove-Item -Path $path -Force -Confirm:$false
            }
        }
        $count++
    }
}

<#
.SYNOPSIS
    Removes authentication parameters from a bound parameter hashtable.
 
.DESCRIPTION
    Removes common authentication and runtime keys from a hashtable before comparison or serialization.
 
.PARAMETER BoundParameters
    Specifies the hashtable to sanitize.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable
#>

function Remove-M365DSCAuthenticationParameter
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $BoundParameters
    )

    $keysToRemove = @(
        'Ensure',
        'Credential',
        'ApplicationId',
        'ApplicationSecret',
        'TenantId',
        'CertificatePassword',
        'CertificatePath',
        'CertificateThumbprint',
        'ManagedIdentity',
        'Verbose',
        'AccessTokens'
    )

    foreach ($key in $keysToRemove)
    {
        if ($BoundParameters.ContainsKey($key))
        {
            $BoundParameters.Remove($key) | Out-Null
        }
    }

    return $BoundParameters
}

<#
.SYNOPSIS
    Masks sensitive authentication values in a hashtable.
 
.DESCRIPTION
    Replaces sensitive authentication-related property values with placeholder text for safe logging and reporting.
 
.PARAMETER BoundParameters
    Specifies the hashtable whose sensitive values should be masked.
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable
#>

function Set-M365DSCAuthenticationParameterMask
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $BoundParameters
    )

    $keysToReplace = @(
        'ApplicationSecret',
        'Credential',
        'CertificatePassword',
        'CertificatePath',
        'CertificateThumbprint',
        'Password'
    )

    foreach ($key in $keysToReplace)
    {
        if ($BoundParameters.ContainsKey($key))
        {
            $BoundParameters[$key] = '***'
        }
    }

    return $BoundParameters
}

<#
.SYNOPSIS
    Detects duplicate primary-key resource instances in configuration content.
 
.DESCRIPTION
    Parses configuration content, computes each resource primary identity, and returns conflicts where duplicate primary keys are found.
 
.PARAMETER ConfigurationContent
    Specifies DSC configuration content to analyze.
 
.EXAMPLE
    PS> Get-M365DSCConfigurationConflict -ConfigurationContent "content"
 
.FUNCTIONALITY
    Public
 
.OUTPUTS
    Array
#>

function Get-M365DSCConfigurationConflict
{
    [CmdletBinding()]
    [OutputType([Array])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ConfigurationContent
    )

    $results = @()
    Write-Verbose -Message "Converting configuration's content into a PowerShell Object using DSCParser"
    $parsedContent = ConvertTo-DSCObject -Content $ConfigurationContent

    $resourcesPrimaryIdentities = @()
    $resourcesInModule = Get-DscResourceV2 -Module 'Microsoft365DSC'
    foreach ($component in $parsedContent)
    {
        $resourceDefinition = $resourcesInModule | Where-Object -Property Name -EQ $component.ResourceName
        [Array]$mandatoryProperties = $resourceDefinition.Properties | Where-Object -Property IsMandatory -EQ $true
        $primaryKeyValues = ''
        foreach ($mandatoryKey in $mandatoryProperties.Name)
        {
            $primaryKeyValues += "$($component.$mandatoryKey)|"
        }
        $entryValue = "[$($component.ResourceName)]$primaryKeyValues"
        if ($resourcesPrimaryIdentities.Contains($entryValue))
        {
            Write-Verbose -Message "Found primary key conflict in resource {$($component.ResourceInstanceName)}"
            $currentEntry = @{
                ResourceName         = $component.ResourceName
                InstanceName         = $component.ResourceInstanceName
                AdditionalProperties = @{}
                Reason               = 'DuplicatePrimaryKey'
            }

            foreach ($mandatoryKey in $mandatoryProperties.Name)
            {
                $currentEntry.AdditionalProperties.Add($mandatoryKey, $component.$mandatoryKey)
            }
            $results += $currentEntry
        }
        else
        {
            $resourcesPrimaryIdentities += $entryValue
        }
    }
    return $results
}

<#
.SYNOPSIS
    Invokes a DSC resource function in a PowerShell 7 session.
 
.DESCRIPTION
    Ensures a PowerShell Core remoting session exists, imports the target resource module, and invokes the selected resource function with provided parameters.
 
.PARAMETER Path
    Specifies the resource module path to import in the Core session.
 
.PARAMETER FunctionName
    Specifies the target resource function to invoke.
 
.PARAMETER Parameters
    Specifies parameters passed to the invoked function.
 
.EXAMPLE
    Invoke-PowerShellCoreResource -Path 'C:\Program Files\...\DscResources\MSFT_Resource\MSFT_Resource.psm1' -FunctionName Test-TargetResource -Parameters @{ Name = 'Value' }
 
.EXAMPLE
    # From inside of a DSC resource
    Invoke-PowerShellCoreResource -Path $PSCommandPath -FunctionName $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    Result of the invoked function.
#>

function Invoke-PowerShellCoreResource
{
    [CmdletBinding()]
    [OutputType([System.Object])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Path', Justification = 'Using statement not detected')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'FunctionName', Justification = 'Using statement not detected')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Parameters', Justification = 'Using statement not detected')]
    param (
        [Parameter(Mandatory = $true)]
        [System.String]$Path,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Get-TargetResource', 'Set-TargetResource', 'Test-TargetResource', 'Export-TargetResource')]
        [System.String]$FunctionName,

        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]$Parameters
    )

    if (-not $script:PSCoreSessionInitialized)
    {
        Initialize-PowerShellCoreSession
    }

    $output = Invoke-Command -Session $PSCoreSession -ScriptBlock {
        Import-Module -Name $using:Path
        & $using:FunctionName @using:Parameters
    }

    return $output
}

<#
.DESCRIPTION
    Initializes a PowerShell Core session for use with Invoke-PowerShellCoreResource.
 
.FUNCTIONALITY
    Private
 
.EXAMPLE
    Initialize-PowerShellCoreSession
#>

function Initialize-PowerShellCoreSession
{
    [CmdletBinding()]
    param ()

    $script:PSCoreSession = New-PSSession -ComputerName localhost -ConfigurationName PowerShell.7 -EnableNetworkAccess
    $lcmConfig = Get-DscLocalConfigurationManager
    Invoke-Command -Session $script:PSCoreSession -ScriptBlock {
        Import-Module -Name Microsoft365DSC -Alias @() -Cmdlet @() -Variable @() -DisableNameChecking -SkipEditionCheck
        Set-M365DSCLCMConfiguration -LCMConfig $using:lcmConfig
    }
    $script:PSCoreSessionInitialized = $true
}

<#
.SYNOPSIS
    Clears deferred host message cache entries.
 
.DESCRIPTION
    Resets the in-memory message cache used by Write-M365DSCHost deferred writes.
 
.FUNCTIONALITY
    Internal
#>

function Clear-M365DSCHostMessageCache
{
    $Script:M365DSCHostMessages = @()
}

<#
.SYNOPSIS
    Writes Microsoft365DSC host output with optional deferred batching.
 
.DESCRIPTION
    Writes messages to host output in interactive sessions and to verbose output in non-interactive sessions.
    Supports deferred message accumulation and explicit commit behavior.
 
.PARAMETER Message
    Specifies the message text to write.
 
.PARAMETER ForegroundColor
    Specifies the foreground color for interactive host output.
 
.PARAMETER DeferWrite
    Indicates that the message should be queued instead of written immediately.
 
.PARAMETER CommitWrite
    Indicates that queued deferred messages should be flushed before writing the current message.
 
.EXAMPLE
    PS> Write-M365DSCHost -Message "This is a message."
 
.FUNCTIONALITY
    Internal
#>

function Write-M365DSCHost
{
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param
    (
        [Parameter(Position = 0)]
        [System.String]
        $Message,

        [Parameter()]
        [ConsoleColor]
        $ForegroundColor = [System.Console]::ForegroundColor,

        [Parameter(ParameterSetName = 'DeferWrite')]
        [switch]
        $DeferWrite,

        [Parameter(ParameterSetName = 'CommitWrite')]
        [switch]
        $CommitWrite
    )

    if ([int]$ForegroundColor -eq -1)
    {
        $ForegroundColor = [System.ConsoleColor]::Gray
    }

    if (-not [System.String]::IsNullOrEmpty($Message))
    {
        if ($null -eq $Script:M365DSCHostMessages)
        {
            $Script:M365DSCHostMessages = @()
        }

        if ($DeferWrite)
        {
            $Script:M365DSCHostMessages += @{
                Message         = $Message
                ForegroundColor = $ForegroundColor
            }
            return
        }

        if ([Environment]::UserInteractive)
        {
            if ($CommitWrite -and $Script:M365DSCHostMessages.Count -gt 0)
            {
                for ($i = 0; $i -lt $Script:M365DSCHostMessages.Count - 1; $i++)
                {
                    Write-Host -Object $Script:M365DSCHostMessages[$i].Message -ForegroundColor $Script:M365DSCHostMessages[$i].ForegroundColor -NoNewline
                }
                Write-Host -Object $Script:M365DSCHostMessages[-1].Message -ForegroundColor $Script:M365DSCHostMessages[-1].ForegroundColor -NoNewline
                $Script:M365DSCHostMessages = @()
            }

            if (-not [System.String]::IsNullOrEmpty($Message))
            {
                Write-Host -Object $Message -ForegroundColor $ForegroundColor
            }
        }
        else
        {
            $outputMessage = ''
            if ($CommitWrite)
            {
                $outputMessage += $Script:M365DSCHostMessages.Message -join ''
                $Script:M365DSCHostMessages = @()
            }
            $finalMessage = $outputMessage + $Message
            if (-not [System.String]::IsNullOrEmpty($Message))
            {
                Write-Verbose -Message $finalMessage -Verbose
            }
        }
    }
}

<#
.SYNOPSIS
    Sends Microsoft Graph batch requests with throttling backoff handling.
 
.DESCRIPTION
    Splits requests into batch payloads, sends them to Graph, detects throttling responses, and retries with reduced batch size when needed.
 
.PARAMETER Requests
    An array of hashtables representing the requests to be sent in the batch.
    A request hashtable should contain the following keys:
    - id: A unique identifier for the request.
    - method: The HTTP method to use (e.g., GET, POST).
    - url: The API endpoint URL.
 
.PARAMETER AsList
    Indicates that results should be returned as a generic list.
 
.PARAMETER ThrottlingDelayInSeconds
    Specifies delay before retrying throttled batches.
 
.PARAMETER BatchRequestSize
    Specifies maximum number of sub-requests per batch call.
 
.EXAMPLE
    $requests = @(
        @{
            id = '1'
            method = 'GET'
            url = '/users'
        }
    )
    Invoke-M365DSCGraphBatchRequest -Requests $requests
 
.OUTPUTS
    System.Collections.Hashtable[]
#>

function Invoke-M365DSCGraphBatchRequest
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable[]])]
    param (
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Hashtable[]]
        $Requests,

        [Parameter()]
        [switch]
        $AsList,

        [Parameter()]
        [System.Int32]
        $ThrottlingDelayInSeconds = 5,

        [Parameter()]
        [System.Int32]
        $BatchRequestSize = 20
    )

    $batchResponses = [System.Collections.Generic.List[System.Collections.Hashtable]]::new()
    $halfBatchSize = [Math]::Ceiling($BatchRequestSize / 2)
    :outer for ($i = 0; $i -lt $Requests.Count; $i += $BatchRequestSize)
    {
        $batchRequestSized = $Requests[$i..([Math]::Min($i + $BatchRequestSize - 1, $Requests.Count - 1))]

        $request = @{
            requests = $batchRequestSized
        }

        Write-Verbose -Message "Sending BATCH Request with $($request.requests.Count) sub-requests (starting at index $i)..."
        $apiResponse = Invoke-MgGraphRequest -Method POST `
            -Uri 'beta/$batch' `
            -Body ($request | ConvertTo-Json -Depth 10) `
            -ErrorAction SilentlyContinue

        :inner foreach ($response in $apiResponse.responses)
        {
            switch ($response.status)
            {
                200 {
                    if ($null -ne $response.body.'@odata.nextLink')
                    {
                        $value = [System.Collections.Generic.List[System.Object]]::new($response.body.value)
                        $nextLink = $response.body.'@odata.nextLink'
                        while ($nextLink)
                        {
                            Write-Verbose -Message "Fetching next page of results from $nextLink..."
                            $nextPageResponse = Invoke-MgGraphRequest -Method GET -Uri $nextLink -ErrorAction SilentlyContinue
                            $value.AddRange($nextPageResponse.value)
                            $nextLink = $nextPageResponse.'@odata.nextLink'
                        }
                        $response.body.value = $value.ToArray()
                    }
                }
                429 {
                    Write-Warning -Message "Throttling encountered, pausing and repeating request..."
                    Start-Sleep -Seconds $ThrottlingDelayInSeconds
                    $BatchRequestSize = [Math]::Max($halfBatchSize, [Math]::Floor($BatchRequestSize / 2))
                    $i = if ($i -ge $BatchRequestSize) { $i - $BatchRequestSize } else { 0 }
                    continue outer
                }
            }
        }

        $batchResponses.AddRange([System.Collections.Hashtable[]]$apiResponse.responses)
    }

    if ($AsList)
    {
        return $batchResponses
    }
    return $batchResponses.ToArray()
}

<#
.SYNOPSIS
    Returns comparison metadata for a resource.
 
.DESCRIPTION
    Loads and caches comparison metadata from ComparisonMetadata.json and returns metadata for the requested resource.
 
.PARAMETER ResourceName
    Specifies the resource name to retrieve metadata for.
 
.EXAMPLE
    PS> Get-M365DSCResourceComparisonMetadata -ResourceName 'AADRoleAssignmentScheduleRequest'
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable
#>

function Get-M365DSCResourceComparisonMetadata
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName
    )

    if ($null -eq $Script:M365DSCComparisonMetadata)
    {
        $metadataPath = Join-Path -Path $PSScriptRoot -ChildPath '../ComparisonMetadata.json'
        if (Test-Path -Path $metadataPath)
        {
            try
            {
                $metadataContent = [System.IO.File]::ReadAllText($metadataPath) | ConvertFrom-Json
                $Script:M365DSCComparisonMetadata = @{}
                foreach ($resource in $metadataContent.Resources.PSObject.Properties)
                {
                    $Script:M365DSCComparisonMetadata[$resource.Name] = @{
                        HasCustomComparison = $resource.Value.HasCustomComparison
                        Description         = $resource.Value.Description
                    }
                }
            }
            catch
            {
                Write-Warning -Message "Failed to load comparison metadata from $metadataPath : $_"
                $Script:M365DSCComparisonMetadata = @{}
            }
        }
        else
        {
            Write-Verbose -Message "Comparison metadata file not found at $metadataPath"
            $Script:M365DSCComparisonMetadata = @{}
        }
    }

    if ($Script:M365DSCComparisonMetadata.ContainsKey($ResourceName))
    {
        return $Script:M365DSCComparisonMetadata[$ResourceName]
    }

    return @{
        HasCustomComparison = $false
    }
}

<#
.SYNOPSIS
    Retrieves custom comparison parameters for a resource.
 
.DESCRIPTION
    Loads the resource module when needed, invokes Get-CompareParameters when available, and caches returned compare parameters.
 
.PARAMETER ResourceName
    Specifies the resource name to retrieve compare parameters for.
 
.EXAMPLE
    PS> Get-M365DSCResourceComparisonParameters -ResourceName 'AADRoleAssignmentScheduleRequest'
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable
#>

function Get-M365DSCResourceComparisonParameters
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName
    )

    $compareParameters = @{}

    try
    {
        # Check if this resource has custom comparison logic
        $metadata = Get-M365DSCResourceComparisonMetadata -ResourceName $ResourceName

        if (-not $metadata.HasCustomComparison)
        {
            Write-Verbose -Message "Resource $ResourceName does not have custom comparison logic."
            return $compareParameters
        }

        # Import the resource module if not already loaded
        $moduleName = "MSFT_$ResourceName"
        $module = Get-Module -Name $moduleName
        $moduleConfig = Get-M365DSCModuleConfiguration

        if ($null -eq $module)
        {
            $resourceModulePath = Join-Path -Path $PSScriptRoot -ChildPath "../DscResources/$moduleName/$moduleName.psm1"
            if (Test-Path -Path $resourceModulePath)
            {
                $previousValue = $moduleConfig.skipModuleDependencyValidation
                if (-not $metadata.RequiresModuleCheck)
                {
                    Set-M365DSCModuleConfiguration -Key 'skipModuleDependencyValidation' -Value $true
                }
                Import-Module -Name $resourceModulePath -Force -Global -Function Get-CompareParameters -Alias @() -Cmdlet @() -Variable @() -DisableNameChecking
                Set-M365DSCModuleConfiguration -Key 'skipModuleDependencyValidation' -Value $previousValue
                Write-Verbose -Message "Imported module $moduleName from $resourceModulePath"
            }
            else
            {
                Write-Warning -Message "Resource module not found at $resourceModulePath"
                return $compareParameters
            }
        }

        if ($null -eq $Script:CompareParametersCache)
        {
            $Script:CompareParametersCache = @{}
        }

        if ($Script:CompareParametersCache.ContainsKey($ResourceName))
        {
            return $Script:CompareParametersCache[$ResourceName]
        }

        # Check if the Get-CompareParameters function exists
        $getCompareParamsCommand = Get-Command -Name "$moduleName\Get-CompareParameters" -ErrorAction SilentlyContinue

        if ($null -eq $getCompareParamsCommand)
        {
            Write-Warning -Message "Resource $ResourceName is marked as having custom comparison, but Get-CompareParameters function not found."
            return $compareParameters
        }

        # Invoke the Get-CompareParameters function
        $compareParameters = & "$moduleName\Get-CompareParameters"

        # Cache the retrieved parameters
        $Script:CompareParametersCache[$ResourceName] = $compareParameters
    }
    catch
    {
        Write-Warning -Message "Failed to retrieve comparison parameters for $ResourceName : $_"
    }

    return $compareParameters
}

<#
.SYNOPSIS
    Resolves a group display name from its group id.
 
.DESCRIPTION
    Queries Microsoft Graph for a group by id and returns its display name.
 
.PARAMETER GroupId
    Specifies the group id to resolve.
 
.OUTPUTS
    System.String
#>

function Get-M365DSCGroupDisplayNameById
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $GroupId
    )

    try
    {
        $group = Get-MgGroup -GroupId $GroupId -Property DisplayName -ErrorAction Stop
        return $group.DisplayName
    }
    catch
    {
        $message = "Could not find a group with id $($GroupId). Skipping group display name resolution for this id."
        New-M365DSCLogEntry -Message $message `
            -Exception $_ `
            -Source $($MyInvocation.MyCommand.Source) `
            -TenantId $TenantId `
            -Credential $Credential
    }
}

<#
.SYNOPSIS
    Resolves a group id from its display name.
 
.DESCRIPTION
    Queries Microsoft Graph for a group by display name and returns its id.
 
.PARAMETER GroupDisplayName
    Specifies the group display name to resolve.
 
.OUTPUTS
    System.String
#>

function Get-M365DSCGroupIdByDisplayName
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $GroupDisplayName
    )

    try
    {
        $group = Get-MgGroup -Filter "displayName eq '$GroupDisplayName'" -Property Id -ErrorAction Stop
        return $group.Id
    }
    catch
    {
        $message = "Could not find a group with display name $($GroupDisplayName). Skipping group ID resolution for this display name."
        New-M365DSCLogEntry -Message $message `
            -Exception $_ `
            -Source $($MyInvocation.MyCommand.Source) `
            -TenantId $TenantId `
            -Credential $Credential
    }
}

<#
.SYNOPSIS
    Resolves a user principal name from user id.
 
.DESCRIPTION
    Queries Microsoft Graph for a user by id and returns UserPrincipalName.
 
.PARAMETER UserId
    Specifies the user id to resolve.
 
.OUTPUTS
    System.String
#>

function Get-M365DSCUserPrincipalNameById
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $UserId
    )

    try
    {
        $user = Get-MgUser -UserId $UserId -Property UserPrincipalName -ErrorAction Stop
        return $user.UserPrincipalName
    }
    catch
    {
        $message = "Could not find a user with id $($UserId). Skipping user principal name resolution for this id."
        New-M365DSCLogEntry -Message $message `
            -Exception $_ `
            -Source $($MyInvocation.MyCommand.Source) `
            -TenantId $TenantId `
            -Credential $Credential
    }
}

<#
.SYNOPSIS
    Resolves a user id from user principal name.
 
.DESCRIPTION
    Queries Microsoft Graph for a user by principal name and returns the user id.
 
.PARAMETER UserPrincipalName
    Specifies the user principal name to resolve.
 
.OUTPUTS
    System.String
#>

function Get-M365DSCUserIdByPrincipalName
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $UserPrincipalName
    )

    try
    {
        $user = Get-MgUser -UserId $UserPrincipalName -Property Id -ErrorAction Stop
        return $user.Id
    }
    catch
    {
        $message = "Could not find a user with principal name $($UserPrincipalName). Skipping user ID resolution for this principal name."
        New-M365DSCLogEntry -Message $message `
            -Exception $_ `
            -Source $($MyInvocation.MyCommand.Source) `
            -TenantId $TenantId `
            -Credential $Credential
    }
}

<#
.SYNOPSIS
    Rewrites authentication target identities to directory object ids.
 
.DESCRIPTION
    Updates target entries by resolving group display names and user principal names to their corresponding directory object ids.
 
.PARAMETER Targets
    Specifies authentication target objects to normalize.
#>

function Update-M365DSCAuthenticationTargets
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [AllowNull()]
        [System.Object[]]
        $Targets
    )

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

    foreach ($target in $targets)
    {
        if ($target.ContainsKey('Id') -and $target.ContainsKey('TargetType'))
        {
            if ($target.Id -eq '0000000-0000-0000-0000-000000000000' -or $target.Id -eq 'all_users' `
                -or $target.Id -match '^[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}$')
            {
                continue
            }

            if ($target.TargetType -eq 'Group')
            {
                $groupId = Get-M365DSCGroupIdByDisplayName -GroupDisplayName $($target.Id -replace "'", "''")
                if ($null -ne $groupId)
                {
                    $target.Id = $groupId
                }
            }
            elseif ($target.TargetType -eq 'User')
            {
                $userId = Get-M365DSCUserIdByPrincipalName -UserPrincipalName $($target.Id -replace "'", "''")
                if ($null -ne $userId)
                {
                    $target.Id = $userId
                }
            }
        }
    }
}

<#
.SYNOPSIS
    Sends a push notification using configured global endpoint settings.
 
.DESCRIPTION
    Sends a POST request to the configured push notification endpoint with optional global body and header overrides.
 
.PARAMETER Body
    Specifies the request body sent to the endpoint.
 
.EXAMPLE
    PS> Send-M365DSCPushNotification -Body "This is a test"
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    $null
#>

function Send-M365DSCPushNotification
{
    [CmdletBinding()]
    [OutputType($null)]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Body
    )

    if (-not [System.String]::IsNullOrEmpty($Global:M365DSCPushNotificationsURI))
    {
        if (-not [System.String]::IsNullOrEmpty($Global:M365DSCPushNotificationsBody))
        {
            $Body = $Global:M365DSCPushNotificationsBody
        }
        $postRequest = @{
            Method      = "Post"
            URI         = $Global:M365DSCPushNotificationsURI
            Body        = $Body
            ErrorAction = "SilentlyContinue"
        }
        if (-not [System.String]::IsNullOrEmpty($Global:M365DSCPushNotificationsHeaders))
        {
            $postRequest.Add("Headers", $Global:M365DSCPushNotificationsHeaders)
        }
        $null = Invoke-RestMethod @postRequest
    }
}

Export-ModuleMember -Function @(
    'Assert-M365DSCBlueprint',
    'Clear-M365DSCHostMessageCache',
    'Confirm-ImportedCmdletIsAvailable',
    'Convert-M365DscHashtableToString',
    'Get-AllSPOPackages',
    'Get-M365DSCAllResources',
    'Get-M365DSCAllResourcesPath',
    'Get-M365DSCAllResourcesDictionary',
    'Get-M365DSCArrayFromProperty',
    'Get-M365DSCAuthenticationMode',
    'Get-M365DSCConfigurationConflict',
    'Get-M365DSCExportContentForResource',
    'Get-M365DSCGroupDisplayNameById',
    'Get-M365DSCGroupIdByDisplayName',
    'Get-M365DSCResourceDifferences',
    'Get-M365DSCResourceComparisonMetadata',
    'Get-M365DSCResourceComparisonParameters',
    'Get-M365DSCUserIdByPrincipalName',
    'Get-M365DSCUserPrincipalNameById',
    'Get-M365DSCWorkloadForResource',
    'Get-TeamByName',
    'Initialize-M365DSCAllResourcesDictionary',
    'Install-M365DSCDevBranch',
    'Invoke-M365DSCGraphBatchRequest',
    'Invoke-PowerShellCoreResource',
    'New-M365DSCCmdletDocumentation',
    'New-M365DSCMissingResourcesExample',
    'Remove-M365DSCAuthenticationParameter',
    'Remove-NullEntriesFromHashtable',
    'Set-M365DSCAuthenticationParameterMask',
    'Send-M365DSCPushNotification',
    'Set-M365DSCAllResourcesDictionary',
    'Test-CodePage',
    'Test-M365DSCParameterState',
    'Test-M365DSCTargetResource',
    'Update-M365DSCAuthenticationTargets',
    'Write-M365DSCHost'
)