Classes/_Shared.psm1

# GENERATED FILE - do not edit.
# Produced by Utilities/Build-Microsoft365DSC.ps1 from DscResources/_Base.

using namespace System
using namespace System.Collections
using namespace System.Collections.Concurrent
using namespace System.Collections.Generic
using namespace System.Management.Automation
using namespace System.Management.Automation.Runspaces
using namespace System.Reflection

<#
    Shared foundation for all Microsoft365DSC class-based resources.
 
    This file is SOURCE ONLY. Utilities/Build-Microsoft365DSC.ps1 emits it verbatim into
    Modules/Microsoft365DSC/Classes/_Shared.psm1, which every generated Part<NN>.psm1 opens with
    `using module`. It is never imported on its own: PowerShell classes do not cross module
    boundaries, and DSC only parses RootModule plus one level of NestedModules when looking for
    [DscResource()] classes.
 
    IMPORTANT - this file must never gain top-level executable code.
 
    Every converted resource opens with `using module ..\_Base\M365DSCResourceBase.psm1` so that it
    parses cleanly in an editor instead of showing TypeNotFound on the base class. `using module`
    IMPORTS the module at PARSE time, which means an editor executes this file every time anyone
    opens a resource. Class and enum definitions are inert and safe. Anything else - a command
    call, an assignment, a Write-Host - would run in the contributor's editor, unprompted, on every
    file open. Put such code in one of the Modules/*.psm1 helpers instead.
 
    It exists to give class-based resources back three things that function-based resources got
    for free:
 
      1. $PSBoundParameters -> $this.GetBoundParameters()
      2. $MyInvocation.MyCommand -> $this.GetResourceName()
      3. a per-resource $Script: scope. The generated root module has ONE module scope shared by
         all resources, so the former $Script:exportedInstance / $Script:* caches would
         cross-contaminate. They become $this.ExportedInstance / $this.ResourceCache.
#>


# Per-property reflection and validation metadata, built once per derived type.
class M365DSCPropertyMeta
{
    [PropertyInfo] $Property

    [Type] $PropertyType

    [FieldInfo] $BackingField

    [bool] $IsSchema

    [bool] $IsRequired

    [bool] $IsComplex

    [bool] $HasValidateSet

    [bool] $TreatsEmptyStringAsNull

    [bool] $HasEnumeratedValidation

    [Attribute[]] $Validators

    M365DSCPropertyMeta([PropertyInfo] $Property)
    {
        $this.Property = $Property
        $this.PropertyType = $Property.PropertyType
        $this.BackingField = $Property.DeclaringType.GetField(
            "<$($Property.Name)>k__BackingField",
            [BindingFlags]::Instance -bor [BindingFlags]::NonPublic)
        $this.IsComplex = [M365DSCResourceBase]::IsComplexClassType($Property.PropertyType)

        $list = [List[Attribute]]::new()
        foreach ($attribute in $Property.GetCustomAttributes($true))
        {
            if ($attribute -is [DscPropertyAttribute])
            {
                $this.IsSchema = $true
                $this.IsRequired = $attribute.Key -or $attribute.Mandatory
                continue
            }

            if ($attribute -is [ValidateEnumeratedArgumentsAttribute])
            {
                $this.HasEnumeratedValidation = $true
            }

            if ($attribute -is [ValidateSetAttribute])
            {
                $this.HasValidateSet = $true
                $this.TreatsEmptyStringAsNull = $true
            }
            elseif ($attribute -is [ValidatePatternAttribute] -or
                ($attribute -is [ValidateLengthAttribute] -and $attribute.MinLength -gt 0))
            {
                $this.TreatsEmptyStringAsNull = $true
            }

            if ($attribute -is [ValidateSetAttribute] -or
                $attribute -is [ValidateRangeAttribute] -or
                $attribute -is [ValidateScriptAttribute] -or
                $attribute -is [ValidateNotNullOrEmptyAttribute] -or
                $attribute -is [ValidateLengthAttribute] -or
                $attribute -is [ValidatePatternAttribute])
            {
                $list.Add($attribute)
            }
        }

        $this.Validators = $list.ToArray()
    }
}

# Per-derived-type reflection cache.
class M365DSCResourceInfo
{
    [Type] $Type

    [Dictionary[String, PropertyInfo]] $Properties

    [Dictionary[String, PropertyInfo]] $NonSchemaProperties

    [Dictionary[String, M365DSCPropertyMeta]] $Meta

    [String[]] $RequiredProperties

    hidden static [System.UInt32] $SerializationDepth = 25

    hidden static [void] RegisterSerializationDepth([Type] $Type)
    {
        $element = $Type
        if ($null -ne $element -and $element.IsArray)
        {
            $element = $element.GetElementType()
        }

        if ($null -eq $element -or -not $element.IsClass -or $element.Name -notlike 'MSFT_*')
        {
            return
        }

        if (-not [M365DSCResourceBase]::GetRunspaceTypeCache('DepthRegistered').TryAdd($element, $true))
        {
            return
        }

        $typeData = [TypeData]::new($element.Name)
        $typeData.SerializationDepth = [M365DSCResourceInfo]::SerializationDepth
        Update-TypeData -TypeData $typeData -Force -ErrorAction Stop

        foreach ($property in $element.GetProperties())
        {
            [M365DSCResourceInfo]::RegisterSerializationDepth($property.PropertyType)
        }
    }

    M365DSCResourceInfo([Type] $Type)
    {
        $this.Type = $Type
        $this.Properties = [Dictionary[String, PropertyInfo]]::new([StringComparer]::OrdinalIgnoreCase)
        $this.NonSchemaProperties = [Dictionary[String, PropertyInfo]]::new([StringComparer]::OrdinalIgnoreCase)
        $this.Meta = [Dictionary[String, M365DSCPropertyMeta]]::new([StringComparer]::OrdinalIgnoreCase)
        $required = [List[String]]::new()

        # Members declared on the base class are infrastructure, not resource schema.
        $baseMembers = [HashSet[String]]::new([String[]] [M365DSCResourceBase].GetProperties().Name, [StringComparer]::OrdinalIgnoreCase)
        $typeData = [TypeData]::new($Type.Name)

        foreach ($property in $Type.GetProperties())
        {
            if ($baseMembers.Contains($property.Name) -or -not $property.CanWrite)
            {
                continue
            }

            $propertyMeta = [M365DSCPropertyMeta]::new($property)
            $this.Meta[$property.Name] = $propertyMeta

            if (-not $propertyMeta.IsSchema)
            {
                $this.NonSchemaProperties[$property.Name] = $property
                continue
            }

            $this.Properties[$property.Name] = $property
            if ($propertyMeta.IsRequired)
            {
                $required.Add($property.Name)
            }

            [M365DSCResourceInfo]::RegisterSerializationDepth($property.PropertyType)
        }

        $this.RequiredProperties = $required.ToArray()

        if ($this.Properties.Count -gt 0)
        {
            $typeData.SerializationDepth = [M365DSCResourceInfo]::SerializationDepth
            Update-TypeData -TypeData $typeData -Force -ErrorAction Stop
        }
    }
}

class M365DSCResourceBase
{
    #region Infrastructure state

    <#
        Property state lives in the CLR properties, because that is where DSC reads and writes it.
        There is no extended type system layer over them: the LCM reads the instance returned by
        Get() after disposing the pipeline that ran it, and a ScriptProperty getter needs a runspace.
 
        Assignment therefore does not validate. _SetProperty and FromHashtable do.
    #>

    # Underscore-prefixed on purpose. A derived class cannot redeclare a member the base already
    # defines, and resource schemas do use ordinary words as property names - the schema scan found
    # MSFT_SCHeaderPattern.Values - so the internal members stay out of that namespace.

    # Replaces $Script:exportedInstance, which cannot stay script-scoped once all
    # resources share one module scope.
    hidden [System.Object] $ExportedInstance

    # Replaces the assorted per-resource $Script:* caches ($Script:RoleDefinitions,
    # $Script:AllSchedules, $Script:exportedGroups, ...).
    hidden [Hashtable] $ResourceCache = @{}

    # Each runspace compiles its own class types. A PowerShell class instance references only the session
    # state it was created in.
    hidden static [System.Runtime.CompilerServices.ConditionalWeakTable[Runspace, ConcurrentDictionary[String, ConcurrentDictionary[Type, System.Object]]]] $_runspaceTypeCaches = `
        [System.Runtime.CompilerServices.ConditionalWeakTable[Runspace, ConcurrentDictionary[String, ConcurrentDictionary[Type, System.Object]]]]::new()

    hidden static [ConcurrentDictionary[String, ConcurrentDictionary[Type, System.Object]]] $_processTypeCaches = `
        [ConcurrentDictionary[String, ConcurrentDictionary[Type, System.Object]]]::new()

    hidden static [ConcurrentDictionary[Type, System.Object]] GetRunspaceTypeCache([System.String] $Name)
    {
        $caches = [M365DSCResourceBase]::_processTypeCaches
        $runspace = [Runspace]::DefaultRunspace
        if ($null -ne $runspace -and -not [M365DSCResourceBase]::_runspaceTypeCaches.TryGetValue($runspace, [ref] $caches))
        {
            $caches = [ConcurrentDictionary[String, ConcurrentDictionary[Type, System.Object]]]::new()
            [M365DSCResourceBase]::_runspaceTypeCaches.Add($runspace, $caches)
        }

        $cache = $null
        if (-not $caches.TryGetValue($Name, [ref] $cache))
        {
            $cache = $caches.GetOrAdd($Name, [ConcurrentDictionary[Type, System.Object]]::new())
        }

        return $cache
    }

    hidden [M365DSCResourceInfo] $_info

    hidden [Hashtable] $_snapshot = @{}

    hidden [bool] $_seeded

    # Resource-name to type map, populated by each generated Part<NN>.psm1 at import time.
    hidden static [ConcurrentDictionary[String, Type]] $_registry = `
        [ConcurrentDictionary[String, Type]]::new([StringComparer]::OrdinalIgnoreCase)

    # Resolves the type registry to every runspace to prevent deadlocks because
    # of static method calls between the different runspaces being marshalled.
    hidden static [System.Runtime.CompilerServices.ConditionalWeakTable[Runspace, ConcurrentDictionary[String, Type]]] $_registryByRunspace = `
        [System.Runtime.CompilerServices.ConditionalWeakTable[Runspace, ConcurrentDictionary[String, Type]]]::new()

    # Which part module declares each resource.
    hidden static [ConcurrentDictionary[String, String]] $_moduleByResource = `
        [ConcurrentDictionary[String, String]]::new([StringComparer]::OrdinalIgnoreCase)

    static [void] Register([Type] $Type, [System.String] $ModuleName)
    {
        $runspace = [Runspace]::DefaultRunspace
        if ($null -ne $runspace)
        {
            $runspaceRegistry = $null
            if (-not [M365DSCResourceBase]::_registryByRunspace.TryGetValue($runspace, [ref] $runspaceRegistry))
            {
                $runspaceRegistry = [ConcurrentDictionary[String, Type]]::new([StringComparer]::OrdinalIgnoreCase)
                [M365DSCResourceBase]::_registryByRunspace.Add($runspace, $runspaceRegistry)
            }
            $runspaceRegistry[$Type.Name] = $Type
        }

        [M365DSCResourceBase]::_registry[$Type.Name] = $Type
        [M365DSCResourceBase]::_moduleByResource[$Type.Name] = $ModuleName
    }

    static [System.String] ResolveModuleName([System.String] $Name)
    {
        $moduleName = $null
        [void] [M365DSCResourceBase]::_moduleByResource.TryGetValue($Name, [ref] $moduleName)
        return $moduleName
    }

    static [Type] Resolve([System.String] $Name)
    {
        $type = $null
        $runspaceRegistry = $null
        $runspace = [Runspace]::DefaultRunspace
        if ($null -ne $runspace -and
            [M365DSCResourceBase]::_registryByRunspace.TryGetValue($runspace, [ref] $runspaceRegistry) -and
            $runspaceRegistry.TryGetValue($Name, [ref] $type))
        {
            return $type
        }

        [void] [M365DSCResourceBase]::_registry.TryGetValue($Name, [ref] $type)
        return $type
    }

    static [String[]] GetRegisteredNames()
    {
        return [String[]] [M365DSCResourceBase]::_registry.Keys
    }

    #endregion

    M365DSCResourceBase()
    {
        $type = $this.GetType()

        $initialized = [M365DSCResourceBase]::GetRunspaceTypeCache('Initialized')
        $info = $null
        if (-not $initialized.TryGetValue($type, [ref] $info))
        {
            $info = [M365DSCResourceInfo]::new($type)
            $initialized[$type] = $info
        }

        $this._info = $info
    }

    # Graph caps an $expand'ed collection at 20 entries. Returns $null when the caller has to fetch the
    # navigation property itself, and the items otherwise.
    hidden [System.Object] ResolveExpandedNavigation([System.Object] $Instance, [System.String] $Name)
    {
        if ($null -eq $Instance)
        {
            return $null
        }

        $value = $Instance.$Name
        if ($null -eq $value)
        {
            return $null
        }

        $items = @($value)
        if ($items.Count -ge 20)
        {
            return $null
        }

        return $items
    }

    #region Property plumbing

    hidden [PropertyInfo] _FindProperty([System.String] $Name)
    {
        $propertyInfo = $this._info.Properties[$Name]
        if ($null -eq $propertyInfo)
        {
            $propertyInfo = $this._info.NonSchemaProperties[$Name]
        }

        return $propertyInfo
    }

    # Reads the real CLR property.
    hidden [System.Object] _GetProperty([System.String] $Name)
    {
        $propertyInfo = $this._FindProperty($Name)
        if ($null -eq $propertyInfo)
        {
            return $null
        }

        return $propertyInfo.GetValue($this)
    }

    # Validates, then writes THROUGH to the real CLR property.
    hidden [void] _SetProperty([System.String] $Name, [System.Object] $Value)
    {
        $meta = $null
        if (-not $this._info.Meta.TryGetValue($Name, [ref] $meta) -or -not $meta.IsSchema)
        {
            throw [ArgumentException]::new("Unknown property '$Name' on resource '$($this.GetResourceName())'.", $Name)
        }

        if (-not $this._seeded -and $meta.IsRequired)
        {
            $this.SeedSnapshotIfPopulated()
        }

        $this.ValidateProperty($meta, $Name, $Value)

        if ($meta.IsComplex)
        {
            $Value = [M365DSCResourceBase]::SanitizeComplexValue($Value, $meta.PropertyType)
        }

        if ($null -eq $Value)
        {
            $this.SetNullValue($meta)
            return
        }

        if ($meta.TreatsEmptyStringAsNull -and $Value -is [System.String] -and $Value -eq '')
        {
            $this.SetNullValue($meta)
            return
        }

        if ($meta.HasEnumeratedValidation -and $Value -isnot [System.String] -and
            $Value -is [System.Collections.IEnumerable] -and @($Value).Count -eq 0)
        {
            $this.SetNullValue($meta)
            return
        }

        $converted = $Value -as $meta.PropertyType
        if ($null -eq $converted)
        {
            throw [InvalidOperationException]::new(
                "Cannot assign property '$Name' on resource '$($this.GetResourceName())': " +
                "a value of type '$($Value.GetType().FullName)' does not convert to " +
                "'$($meta.PropertyType.FullName)'." +
                $this.DescribeConversionFailure($Value, $meta.PropertyType))
        }

        $meta.Property.SetValue($this, $converted)
        $this._snapshot[$meta.Property.Name] = $converted
    }

    hidden [void] SetNullValue([M365DSCPropertyMeta] $Meta)
    {
        $value = $null
        if ($Meta.PropertyType.IsValueType -and
            $null -eq [Nullable]::GetUnderlyingType($Meta.PropertyType))
        {
            $value = [Activator]::CreateInstance($Meta.PropertyType)
        }

        if ($null -ne $Meta.BackingField)
        {
            $Meta.BackingField.SetValue($this, $value)
        }
        else
        {
            $Meta.Property.SetValue($this, $value)
        }

        if (-not $Meta.IsSchema)
        {
            return
        }

        if ($null -eq $value)
        {
            $this._snapshot.Remove($Meta.Property.Name)
        }
        else
        {
            $this._snapshot[$Meta.Property.Name] = $value
        }
    }

    hidden [void] ClearNonSchemaProperties()
    {
        foreach ($meta in $this._info.Meta.Values)
        {
            if (-not $meta.IsSchema)
            {
                $this.SetNullValue($meta)
            }
        }
    }

    # Detects an instance that DSC populated by reflection and seeds the snapshot from the CLR state.
    hidden [void] SeedSnapshotIfPopulated()
    {
        foreach ($name in $this._info.RequiredProperties)
        {
            if ($this._snapshot.ContainsKey($name) -or $null -eq $this._info.Properties[$name].GetValue($this))
            {
                continue
            }

            $this._snapshot = $this.ReadBoundParameters()
            $this._seeded = $true
            return
        }
    }

    hidden [Hashtable] ReadBoundParameters()
    {
        $result = @{}
        foreach ($entry in $this._info.Properties.GetEnumerator())
        {
            $value = $entry.Value.GetValue($this)
            if ($null -ne $value)
            {
                $result[$entry.Key] = $value
            }
        }

        return $result
    }

    # True when the property targets an embedded complex class (or an array of one).
    hidden static [System.Boolean] IsComplexClassType([Type] $Type)
    {
        $element = if ($Type.IsArray) { $Type.GetElementType() } else { $Type }
        return ($element.IsClass -and $element.Name -like 'MSFT_*')
    }

    hidden static [System.Object] GetComplexPropertyMetadata([Type] $Type)
    {
        $metadata = $null
        $complexMetadata = [M365DSCResourceBase]::GetRunspaceTypeCache('ComplexMetadata')
        if ($complexMetadata.TryGetValue($Type, [ref] $metadata))
        {
            return $metadata
        }

        $table = [Dictionary[String, System.Object]]::new([StringComparer]::OrdinalIgnoreCase)
        foreach ($property in $Type.GetProperties())
        {
            if (-not $property.CanWrite)
            {
                continue
            }

            $table[$property.Name] = [PSCustomObject] @{
                HasValidateSet = @($property.GetCustomAttributes([ValidateSetAttribute], $true)).Count -gt 0
                IsComplex      = [M365DSCResourceBase]::IsComplexClassType($property.PropertyType)
                Type           = $property.PropertyType
            }
        }

        $complexMetadata[$Type] = $table
        return $table
    }

    # Check and fix all values for each property recursively
    hidden static [System.Object] SanitizeComplexValue([System.Object] $Value, [Type] $TargetType)
    {
        if ($null -eq $Value)
        {
            return $null
        }

        if ($TargetType.IsArray)
        {
            $element = $TargetType.GetElementType()

            if ($Value -is [System.String])
            {
                # Would enumerate as chars; let the cast fail exactly as today.
                return $Value
            }

            if ($Value -is [IDictionary] -or $Value -isnot [IEnumerable])
            {
                # Scalar-to-array coercion: sanitize the single element, PowerShell wraps it.
                return [M365DSCResourceBase]::SanitizeComplexValue($Value, $element)
            }

            $result = [List[System.Object]]::new()
            foreach ($item in $Value)
            {
                $result.Add([M365DSCResourceBase]::SanitizeComplexValue($item, $element))
            }
            return $result.ToArray()
        }

        if ($TargetType.IsInstanceOfType($Value))
        {
            return $Value
        }

        $entries = [Ordered] @{}
        if ($Value -is [IDictionary])
        {
            foreach ($key in @($Value.Keys))
            {
                $entries[[System.String] $key] = $Value[$key]
            }
        }
        elseif ($Value.PSObject.BaseObject -is [System.Management.Automation.PSCustomObject])
        {
            # Includes Deserialized.MSFT_* instances from the PS 5.1 -> 7 dispatch, which carry
            # every property. Flattening to a hashtable means only the surviving keys are assigned.
            foreach ($property in $Value.PSObject.Properties)
            {
                $entries[$property.Name] = $property.Value
            }
        }
        else
        {
            # Unrecognized shape: pass through unchanged, worst case equals today's behavior.
            return $Value
        }

        $metadata = [M365DSCResourceBase]::GetComplexPropertyMetadata($TargetType)
        $clean = @{}
        foreach ($key in $entries.Keys)
        {
            $item = $entries[$key]

            $meta = $null
            if ($metadata.TryGetValue($key, [ref] $meta))
            {
                if ($meta.HasValidateSet -and
                    ($null -eq $item -or ($item -is [System.String] -and $item -eq '')))
                {
                    continue
                }

                if ($meta.IsComplex -and $null -ne $item)
                {
                    $item = [M365DSCResourceBase]::SanitizeComplexValue($item, $meta.Type)
                }
            }

            # Unknown keys stay: they still fail the cast and get reported, semantics preserved.
            $clean[$key] = $item
        }

        return $clean
    }

    # Narrows a failed conversion down to the element and member that caused it.
    hidden [System.String] DescribeConversionFailure([System.Object] $Value, [Type] $Type)
    {
        $elementType = if ($Type.IsArray) { $Type.GetElementType() } else { $Type }

        $index = -1
        foreach ($item in @($Value))
        {
            $index++

            if ($null -eq $item -or $null -ne ($item -as $elementType))
            {
                continue
            }

            $where = if ($Type.IsArray) { " Element [$index]" } else { ' The value' }

            if ($item -isnot [System.Collections.IDictionary])
            {
                return "$where of type '$($item.GetType().FullName)' does not convert to '$($elementType.FullName)'."
            }

            $known = @($elementType.GetProperties() | ForEach-Object { $_.Name })
            $unknown = @($item.Keys | Where-Object { $_ -notin $known })
            if ($unknown.Count -gt 0)
            {
                return "$where carries member(s) not declared on '$($elementType.Name)': $($unknown -join ', ')."
            }

            foreach ($key in $item.Keys)
            {
                $member = $elementType.GetProperty($key)
                if ($null -eq $member)
                {
                    continue
                }

                # An out-of-set string converts to [string] just fine, so the -as probe below
                # cannot name it. Check the ValidateSet explicitly.
                $validateSet = @($member.GetCustomAttributes([System.Management.Automation.ValidateSetAttribute], $true)) |
                    Select-Object -First 1
                if ($null -ne $validateSet -and $null -ne $item[$key])
                {
                    foreach ($single in @($item[$key]))
                    {
                        if ($null -ne $single -and
                            -not ($single -is [System.String] -and $single -eq '') -and
                            $validateSet.ValidValues -notcontains $single)
                        {
                            return "$where member '$key' value '$single' is not in the valid set for " +
                                "'$($elementType.Name).$key': $($validateSet.ValidValues -join ', ')."
                        }
                    }
                }

                if ($null -ne $item[$key] -and $null -eq ($item[$key] -as $member.PropertyType))
                {
                    # Recursive conversion failure description
                    return "$where member '$key' of type '$($item[$key].GetType().FullName)' does not convert to '$($member.PropertyType.FullName)'." +
                        $this.DescribeConversionFailure($item[$key], $member.PropertyType)
                }
            }

            return "$where does not convert to '$($elementType.Name)' and no single member explains it."
        }

        return ''
    }

    hidden [void] ValidateProperty([M365DSCPropertyMeta] $Meta, [System.String] $Name, [System.Object] $Value)
    {
        if ($null -eq $Value)
        {
            return
        }

        foreach ($attribute in $Meta.Validators)
        {
            if ($attribute -is [System.Management.Automation.ValidateSetAttribute])
            {
                foreach ($item in @($Value))
                {
                    if ($item -is [System.String] -and $item -eq '')
                    {
                        continue
                    }

                    if ($attribute.ValidValues -notcontains $item)
                    {
                        throw [ArgumentException]::new(
                            "The value '$item' is not valid for property '$Name'. Valid values are: $($attribute.ValidValues -join ', ').",
                            $Name)
                    }
                }
            }
            elseif ($attribute -is [System.Management.Automation.ValidateRangeAttribute])
            {
                foreach ($item in @($Value))
                {
                    if ($item -lt $attribute.MinRange -or $item -gt $attribute.MaxRange)
                    {
                        throw [ArgumentOutOfRangeException]::new(
                            $Name,
                            $item,
                            "The value '$item' is outside the valid range for property '$Name' ($($attribute.MinRange) to $($attribute.MaxRange)).")
                    }
                }
            }
            elseif ($attribute -is [System.Management.Automation.ValidateScriptAttribute])
            {
                foreach ($item in @($Value))
                {
                    if (-not (& $attribute.ScriptBlock $item))
                    {
                        throw [ArgumentException]::new("The value '$item' is not valid for property '$Name'.", $Name)
                    }
                }
            }
            elseif ($attribute -is [System.Management.Automation.ValidateNotNullOrEmptyAttribute])
            {
                if ($Value -is [System.String] -and [System.String]::IsNullOrEmpty($Value))
                {
                    throw [ArgumentException]::new("Property '$Name' may not be null or empty.", $Name)
                }
            }
            elseif ($attribute -is [System.Management.Automation.ValidateLengthAttribute])
            {
                foreach ($item in @($Value))
                {
                    if ($item -isnot [System.String] -or $item -eq '')
                    {
                        continue
                    }

                    if ($item.Length -lt $attribute.MinLength -or $item.Length -gt $attribute.MaxLength)
                    {
                        throw [ArgumentException]::new(
                            "The value '$item' is not valid for property '$Name': its length must be between $($attribute.MinLength) and $($attribute.MaxLength) characters.",
                            $Name)
                    }
                }
            }
            elseif ($attribute -is [System.Management.Automation.ValidatePatternAttribute])
            {
                foreach ($item in @($Value))
                {
                    if ($item -isnot [System.String] -or $item -eq '')
                    {
                        continue
                    }

                    if (-not [System.Text.RegularExpressions.Regex]::IsMatch($item, $attribute.RegexPattern, $attribute.Options))
                    {
                        throw [ArgumentException]::new(
                            "The value '$item' is not valid for property '$Name': it does not match the pattern '$($attribute.RegexPattern)'.",
                            $Name)
                    }
                }
            }
        }
    }

    #endregion

    #region $PSBoundParameters replacement

    [Hashtable] GetBoundParameters()
    {
        if (-not $this._seeded)
        {
            $this.SeedSnapshotIfPopulated()
            $this._seeded = $true
        }

        return $this._snapshot.Clone()
    }

    [Hashtable] GetAllParameters()
    {
        $result = $this.GetBoundParameters()
        foreach ($name in $this._info.NonSchemaProperties.Keys)
        {
            $value = $this._GetProperty($name)
            if ($null -ne $value)
            {
                $result[$name] = $value
            }
        }
        return $result
    }

    [Hashtable] ToHashtable()
    {
        return $this.GetBoundParameters()
    }

    [void] FromHashtable([Hashtable] $Source)
    {
        if ($null -eq $Source)
        {
            return
        }

        foreach ($entry in $Source.GetEnumerator())
        {
            $meta = $null
            if (-not $this._info.Meta.TryGetValue($entry.Key, [ref] $meta))
            {
                continue
            }

            if ($meta.IsSchema)
            {
                $this._SetProperty($meta.Property.Name, $entry.Value)
            }
            else
            {
                $this.($entry.Key) = $entry.Value
            }
        }
    }

    #endregion

    #region Identity

    # Replaces $MyInvocation.MyCommand.ModuleName.Replace('MSFT_', '') and
    # $MyInvocation.MyCommand.Source
    [System.String] GetResourceName()
    {
        return $this.GetType().Name
    }

    # Replaces $PSScriptRoot, used by every Export-TargetResource as
    # Get-M365DSCExportContentForResource -ModulePath $PSScriptRoot.
    [System.String] GetModulePath()
    {
        $module = Get-Module -Name 'Microsoft365DSC'
        if ($null -eq $module)
        {
            $module = Get-Module -Name 'Microsoft365DSC' -ListAvailable | Select-Object -First 1
        }
        return $module.ModuleBase
    }

    #endregion

    #region Shared resource plumbing

    # Replaces the copy-pasted #region Telemetry block in all 535 resources.
    [void] AddTelemetry([System.String] $MethodName)
    {
        $data = Format-M365DSCTelemetryParameters -ResourceName $this.GetResourceName() `
            -CommandName $MethodName `
            -Parameters $this.GetBoundParameters()
        Add-M365DSCTelemetryEvent -Data $data
    }

    [System.String] Connect([System.String] $Workload)
    {
        return (New-M365DSCConnection -Workload $Workload -InboundParameters $this.GetAllParameters())
    }

    [System.String] Connect([System.String] $Workload, [System.String] $Url)
    {
        return (New-M365DSCConnection -Workload $Workload -InboundParameters $this.GetAllParameters() -Url $Url)
    }

    # Replaces the copy-pasted New-M365DSCLogEntry call in every catch block.
    [void] LogError([System.Object] $Exception, [System.String] $Message)
    {
        New-M365DSCLogEntry -Message $Message `
            -Exception $Exception `
            -Source $this.GetResourceName() `
            -TenantId $this._GetProperty('TenantId') `
            -Credential $this._GetProperty('Credential')
    }


    # Emits the standard deprecation warning for every listed parameter the user actually bound.
    # TODO: callers remove their parameters (and this call) at the next breaking change release.
    hidden [void] WarnDeprecated([System.String[]] $ParameterNames)
    {
        $bound = $this.GetBoundParameters()
        foreach ($name in $ParameterNames)
        {
            if ($bound.ContainsKey($name))
            {
                Write-Warning -Message "The parameter '$name' is deprecated. It will be removed in the next breaking change release."
            }
        }
    }

    [System.String] DecodeTextPayload([System.Object] $Value)
    {
        if ($null -eq $Value -or $Value -isnot [System.String] -or [System.String]::IsNullOrEmpty($Value))
        {
            return [System.String] $Value
        }

        try
        {
            return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value))
        }
        catch
        {
            return [System.String] $Value
        }
    }

    [System.String] EncodeTextPayload([System.Object] $Value)
    {
        if ($null -eq $Value -or $Value -isnot [System.String] -or [System.String]::IsNullOrEmpty($Value))
        {
            return [System.String] $Value
        }

        return [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value))
    }

    # Serialises a nullable boolean into a JSON literal
    [System.String] BoolToJson([System.Object] $Value)
    {
        if ($null -eq $Value)
        {
            return 'false'
        }

        return ([System.Boolean] $Value).ToString().ToLower()
    }

    [System.String] NumberToJson([System.Object] $Value)
    {
        if ($null -eq $Value)
        {
            return 'null'
        }

        return [System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture)
    }

    [void] RemoveForeignSubtypeProperties([System.Object] $Node, [System.Collections.Hashtable] $AllowedByType)
    {
        if ($Node -is [System.Collections.IDictionary])
        {
            $odataType = $Node['@odata.type']
            if ($null -ne $odataType -and $AllowedByType.ContainsKey($odataType))
            {
                foreach ($key in @($Node.Keys))
                {
                    if ($key -eq '@odata.type')
                    {
                        continue
                    }

                    if ($key -notin $AllowedByType.$odataType -or
                        ($Node.$key -is [System.String] -and [System.String]::IsNullOrWhiteSpace($Node.$key)))
                    {
                        $Node.Remove($key)
                    }
                }
            }

            foreach ($value in @($Node.Values))
            {
                $this.RemoveForeignSubtypeProperties($value, $AllowedByType)
            }
        }
        elseif ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [System.String])
        {
            foreach ($item in $Node)
            {
                $this.RemoveForeignSubtypeProperties($item, $AllowedByType)
            }
        }
    }

    #endregion

    #region Default DSC method implementations

    # Default Test() for every resource. Splats GetCompareParameters() so a resource that only
    # needs custom comparison parameters overrides that method and keeps this Test() untouched.
    # Argument order is load-bearing: -DesiredValues must be evaluated BEFORE -CurrentValues so the
    # desired snapshot is taken before Get() runs.
    [bool] Test()
    {
        $previousVerbosePreference = Set-M365DSCVerboseScope
        try
        {
            $this.AddTelemetry('Test')

            $compareParameters = $this.GetCompareParameters()
            return (Test-M365DSCTargetResource -DesiredValues $this.GetBoundParameters() `
                    -ResourceName $this.GetResourceName() `
                    @compareParameters `
                    -CurrentValues $this.Get().ToHashtable())
        }
        finally
        {
            $null = Set-M365DSCVerboseScope -Restore $previousVerbosePreference
        }
    }

    # Overridden by resources that need custom comparison parameters
    # (ExcludedProperties / IncludedProperties / PostProcessing / PostProcessingArgs).
    [Hashtable] GetCompareParameters()
    {
        return @{}
    }

    # True when the PostProcessing scriptblock runs for report generation (New-M365DSCDeltaReport)
    # rather than for Test(). Reporting compares two configuration files and has no guaranteed
    # workload connection, so callbacks that call a cmdlet must return their values untouched.
    static [bool] IsReportContext([System.Object[]] $PostProcessingArgs)
    {
        foreach ($argument in $PostProcessingArgs)
        {
            if ($argument -is [System.Collections.Hashtable] -and $argument['IsReport'] -eq $true)
            {
                return $true
            }
        }

        return $false
    }

    # Every schema property of the derived type. Replaces
    # $MyInvocation.MyCommand.Parameters.GetEnumerator()
    [System.String[]] GetSchemaPropertyNames()
    {
        return [System.String[]] $this._info.Properties.Keys
    }

    # Declared type of one schema property, or $null when the derived type has no such property.
    [Type] GetSchemaPropertyType([System.String] $Name)
    {
        $propertyInfo = $this._info.Properties[$Name]
        if ($null -eq $propertyInfo)
        {
            return $null
        }

        return $propertyInfo.PropertyType
    }

    [Hashtable] GetSettingsCatalogCompareParameters()
    {
        return $this.GetSettingsCatalogCompareParameters(@())
    }

    # Shared comparison shim for the settings-catalog resources.
    [Hashtable] GetSettingsCatalogCompareParameters([System.String[]] $ExcludedProperties)
    {
        $result = @{
            PostProcessing     = {
                param($DesiredValues, $CurrentValues, $ValuesToCheck, $PostProcessingArgs)
                foreach ($name in $PostProcessingArgs[0])
                {
                    $currentValue = $CurrentValues[$name]
                    $desiredValue = $DesiredValues[$name]

                    if ($currentValue -is [System.Collections.ICollection] -and $currentValue.Count -eq 0)
                    {
                        $currentValue = $null
                    }
                    if ($desiredValue -is [System.Collections.ICollection] -and $desiredValue.Count -eq 0)
                    {
                        $desiredValue = $null
                    }

                    if ($null -ne $currentValue -or $null -ne $desiredValue)
                    {
                        $ValuesToCheck[$name] = $null
                        if (-not $DesiredValues.ContainsKey($name))
                        {
                            $DesiredValues.Add($name, $null)
                        }
                    }
                }

                return [System.Tuple[Hashtable, Hashtable, Hashtable]]::new($DesiredValues, $CurrentValues, $ValuesToCheck)
            }
            # Prevent array unrolling
            PostProcessingArgs = @(, $this.GetSchemaPropertyNames())
        }

        if ($ExcludedProperties.Count -gt 0)
        {
            $result.ExcludedProperties = $ExcludedProperties
        }

        return $result
    }

    [Hashtable] GetForExport([Hashtable] $Values)
    {
        $this.FromHashtable($Values)
        return $this.Get().ToHashtable()
    }

    #endregion

    #region Windows PowerShell 5.1 dispatch

    [bool] RequiresPowerShellCore()
    {
        return ($global:PSVersionTable.PSEdition -ne 'Core')
    }

    [System.Object] InvokeInPowerShellCore([System.String] $MethodName)
    {
        $previousVerbosePreference = Set-M365DSCVerboseScope
        try
        {
            $output = Invoke-M365DSCClassResourceInPowerShellCore -ClassName $this.GetResourceName() `
                -MethodName $MethodName `
                -Parameters $this.GetAllParameters()

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

            return $output
        }
        finally
        {
            $null = Set-M365DSCVerboseScope -Restore $previousVerbosePreference
        }
    }

    #endregion
}


<#
    Entry points for class-based resources.
 
    This file is SOURCE ONLY. Utilities/Build-Microsoft365DSC.ps1 appends it to the generated
    _Shared.psm1, immediately after M365DSCResourceBase.
 
    It has to sit in that file specifically. It cannot live in M365DSCUtil.psm1 or any other
    hand-written nested module, because those have their own session state and cannot see class
    types at all. And it cannot resolve types with `$ResourceName -as [System.Type]`, because under
    the split layout the resource classes are spread across Part<NN>.psm1 - each its own module -
    so no single scope sees them all. Resolution goes through the static registry on
    M365DSCResourceBase, which every part populates at import.
 
    Everything in the codebase that needs to reach a resource class goes through here:
 
      - Modules/M365DSCReverse.psm1, which must call .Export() per resource and is itself a nested
        module with its own session state.
      - The PowerShell 7 side of the Windows PowerShell 5.1 dispatch
        (Invoke-M365DSCClassResourceInPowerShellCore, M365DSCUtil.psm1).
 
    These functions must NOT be added to the manifest's FunctionsToExport.
#>


<#
.SYNOPSIS
    Creates an instance of a class-based Microsoft365DSC resource.
 
.DESCRIPTION
    Resolves the resource name to its class type and returns a populated instance. This exists
    because PowerShell classes do not cross module boundaries: callers elsewhere in the module -
    let alone outside it - cannot write [AADGroup]::new().
 
.PARAMETER ResourceName
    Specifies the resource name, which is also the class name, e.g. 'AADGroup'.
 
.PARAMETER Property
    Specifies the property values to assign. Only the keys present here are marked as explicitly
    set, which is what makes GetBoundParameters() behave like $PSBoundParameters did.
 
.EXAMPLE
    $instance = New-M365DSCResourceInstance -ResourceName 'AADGroup' -Property @{ DisplayName = 'Contoso' }
 
.FUNCTIONALITY
    Public
 
.OUTPUTS
    System.Object
#>

function New-M365DSCResourceInstance
{
    [CmdletBinding()]
    [OutputType([System.Object])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName,

        [Parameter()]
        [System.Collections.Hashtable]
        $Property = @{}
    )

    $type = [M365DSCResourceBase]::Resolve($ResourceName)
    if ($null -eq $type)
    {
        throw "Unknown Microsoft365DSC resource '$ResourceName'."
    }

    $instance = $type::new()
    $instance.FromHashtable($Property)

    return $instance
}

<#
.SYNOPSIS
    Indicates whether a class-based resource declares a given property.
 
.DESCRIPTION
    The class-based replacement for
    (Get-Command 'Export-TargetResource').Parameters.Keys.Contains('Filter'). Export support for
    -Filter and -SubscriptionId varies per resource, and the reverse engine has to ask before
    passing them.
 
.PARAMETER ResourceName
    Specifies the resource name, e.g. 'AADGroup'.
 
.PARAMETER PropertyName
    Specifies the property to look for, e.g. 'Filter'.
 
.EXAMPLE
    Test-M365DSCResourceProperty -ResourceName 'AADGroup' -PropertyName 'Filter'
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Boolean
#>

function Test-M365DSCResourceProperty
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName,

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

    $type = [M365DSCResourceBase]::Resolve($ResourceName)
    if ($null -eq $type)
    {
        return $false
    }

    return ($null -ne $type.GetProperty($PropertyName))
}

<#
.SYNOPSIS
    Returns the name of the part module that declares a resource class.
 
.DESCRIPTION
    Exists for the unit tests. Pester scopes a mock to a module, and a mock scoped to the parent
    module does not reach a class method executing in a nested Classes/Part<NN>.psm1 - the call
    returns the real value and Should -Invoke counts zero. Tests therefore have to name the part
    they mock into, and the part a resource lands in depends on bucket count and alphabetical
    order, so it cannot be hardcoded.
 
    Commands reached through a BASE-class method (Connect, AddTelemetry, LogError) or through a
    helper declared in _Shared.psm1 execute in '_Shared', not in the part - those need mocking
    against '_Shared' instead.
 
.PARAMETER ResourceName
    Specifies the resource name, e.g. 'AADGroup'.
 
.EXAMPLE
    InModuleScope -ModuleName (Get-M365DSCResourceModuleName -ResourceName 'AADGroup') { ... }
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.String
#>

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

    return [M365DSCResourceBase]::ResolveModuleName($ResourceName)
}

<#
.SYNOPSIS
    Returns the mandatory key properties declared by a class-based resource.
 
.DESCRIPTION
    Used by the export engine to pick the instance name for an exported resource block.
 
    Reads the class itself rather than Get-M365DSCResourcesDictionary. The dictionary is
    populated from Get-DscResourceV2 and is deliberately left empty in some hosts - notably under
    $Global:IsTestEnvironment - which left the export engine with $null keys and no usable fallback
    once MSFT_<Name>.psm1 stopped existing. The attributes on the class are always there.
 
.PARAMETER ResourceName
    Specifies the resource name, e.g. 'AADGroup'.
 
.EXAMPLE
    Get-M365DSCResourceMandatoryKey -ResourceName 'AADGroup'
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.String[]
#>

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

    $type = [M365DSCResourceBase]::Resolve($ResourceName)
    if ($null -eq $type)
    {
        return [System.String[]] @()
    }

    $keys = [System.Collections.Generic.List[System.String]]::new()
    foreach ($property in $type.GetProperties())
    {
        $attribute = @($property.GetCustomAttributes([System.Management.Automation.DscPropertyAttribute], $true))
        if ($attribute.Count -eq 0)
        {
            continue
        }

        if ($attribute[0].Key -or $attribute[0].Mandatory)
        {
            $keys.Add($property.Name)
        }
    }

    return $keys.ToArray()
}

<#
.SYNOPSIS
    Returns the custom comparison parameters declared by a class-based resource.
 
.DESCRIPTION
    The class-based replacement for importing MSFT_<Resource>.psm1 and invoking its
    Get-CompareParameters function. Used by the delta report so that reporting compares the same
    way Test() does.
 
    Instantiates the resource with no properties set, so overrides that read instance state get
    their defaults. Unknown names return an empty hashtable rather than throwing: the report walks
    whatever resource names a blueprint contains, including deprecated ones.
 
.PARAMETER ResourceName
    Specifies the resource name, e.g. 'AADGroup'.
 
.EXAMPLE
    Get-M365DSCResourceCompareParameters -ResourceName 'AADApplication'
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Collections.Hashtable
#>

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

    $type = [M365DSCResourceBase]::Resolve($ResourceName)
    if ($null -eq $type)
    {
        return @{}
    }

    return $type::new().GetCompareParameters()
}

<#
.SYNOPSIS
    Invokes a method on a class-based Microsoft365DSC resource.
 
.DESCRIPTION
    The single entry point used to drive a resource from outside the root module, and the remote
    half of the Windows PowerShell 5.1 dispatch.
 
.PARAMETER ResourceName
    Specifies the resource name, which is also the class name, e.g. 'AADGroup'.
 
.PARAMETER MethodName
    Specifies the method to invoke.
 
.PARAMETER Parameters
    Specifies the property values to assign before invoking.
 
.EXAMPLE
    Invoke-M365DSCResourceMethod -ResourceName 'AADGroup' -MethodName 'Test' -Parameters @{ DisplayName = 'Contoso' }
 
.FUNCTIONALITY
    Internal
 
.OUTPUTS
    System.Object
#>

function Invoke-M365DSCResourceMethod
{
    [CmdletBinding()]
    [OutputType([System.Object])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName,

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

        [Parameter()]
        [System.Collections.Hashtable]
        $Parameters = @{}
    )

    $type = [M365DSCResourceBase]::Resolve($ResourceName)
    if ($null -eq $type)
    {
        throw "Unknown Microsoft365DSC resource '$ResourceName'."
    }

    $known = @{}
    foreach ($entry in $Parameters.GetEnumerator())
    {
        if ($null -ne $type.GetProperty($entry.Key))
        {
            $known[$entry.Key] = $entry.Value
        }
    }

    $instance = New-M365DSCResourceInstance -ResourceName $ResourceName -Property $known

    $previousVerbosePreference = Set-M365DSCVerboseScope
    try
    {
        $result = $instance.$MethodName()
    }
    finally
    {
        $null = Set-M365DSCVerboseScope -Restore $previousVerbosePreference
    }

    if ($result -is [M365DSCResourceBase])
    {
        $result = $result.ToHashtable()
    }

    return $result
}