Source/Classes/ObjectParser.ps1

<#
.SYNOPSIS
    Class to support Object Graph Tools
.DESCRIPTION
    This class provides general properties and method to recursively
    iterate through to PowerShell Object Graph nodes.
 
    For details, see:
 
    * [PowerShell Object Parser][1] for details on the `[PSNode]` properties and methods.
    * [Extended-Dot-Notation][2] for details on path selectors.
 
.LINK
    [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ObjectParser.md "PowerShell Object Parser"
    [2]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Xdn.md "Extended Dot Notation"
#>


using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Management.Automation
using namespace System.Management.Automation.Language

function Use-ClassAccessors {
<#
.SYNOPSIS
    Implements class getter and setter accessors.
 
.DESCRIPTION
    The [Use-ClassAccessors][1] cmdlet updates script property of a class from the getter and setter methods.
    Which are also known as [accessors or mutator methods][2].
 
    The getter and setter methods should use the following syntax:
 
    ### getter syntax
 
        [<type>] get_<property name>() {
            return <variable>
        }
 
    or:
 
        [Object] get_<property name>() {
            return ,[<Type>]<variable>
        }
    ### setter syntax
 
        set_<property name>(<variable>) {
            <code>
        }
 
    > [!NOTE]
    > A **setter** accessor requires a **getter** accessor to implement the related property.
 
    > [!NOTE]
    > In most cases, you might want to hide the getter and setter methods using the [`hidden` keyword][3]
    > on the getter and setter methods.
 
.EXAMPLE
    # Using class accessors
 
    The following example defines a getter and setter for a `value` property
    and a _readonly_ property for the type of the type of the contained value.
 
        Install-Script -Name Use-ClassAccessors
 
        Class ExampleClass {
            hidden $_Value
            hidden [Object] get_Value() {
                return $this._Value
            }
            hidden set_Value($Value) {
                $this._Value = $Value
            }
            hidden [Type]get_Type() {
                if ($Null -eq $this.Value) { return $Null }
                else { return $this._Value.GetType() }
            }
            hidden static ExampleClass() { Use-ClassAccessors }
        }
 
        $Example = [ExampleClass]::new()
 
        $Example.Value = 42 # Set value to 42
        $Example.Value # Returns 42
        $Example.Type # Returns [Int] type info
        $Example.Type = 'Something' # Throws readonly error
 
.PARAMETER Class
 
    Specifies the class from which the accessor need to be initialized.
    Default: The class from which this function is invoked (by its static initializer).
 
.PARAMETER Property
 
    Filters the property that requires to be (re)initialized.
    Default: All properties in the given class
 
.PARAMETER Force
 
    Indicates that the cmdlet reloads the specified accessors,
    even if the accessors already have been defined for the concerned class.
 
.LINK
    [1]: https://github.com/iRon7/Use-ClassAccessors "Online Help"
    [2]: https://en.wikipedia.org/wiki/Mutator_method "Mutator method"
    [3]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#hidden-keyword "Hidden keyword in classes"
#>

    param(
        [Parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Class,

        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Property,

        [switch]$Force
    )

    process {
        $ClassNames =
            if ($Class) { $Class }
            else {
                $Caller = (Get-PSCallStack)[1]
                if ($Caller.FunctionName -ne '<ScriptBlock>') {
                    $Caller.FunctionName
                }
                elseif ($Caller.ScriptName) {
                    $Ast = [System.Management.Automation.Language.Parser]::ParseFile($Caller.ScriptName, [ref]$Null, [ref]$Null)
                    $Ast.EndBlock.Statements.where{ $_.IsClass }.Name
                }
            }
        foreach ($ClassName in $ClassNames) {
            $TargetType = $ClassName -as [Type]
            if (-not $TargetType) { Write-Warning "Class not found: $ClassName" }
            $TypeData = Get-TypeData -TypeName $ClassName
            $Members = if ($TypeData -and $TypeData.Members) { $TypeData.Members.get_Keys() } else { @() }
            $Methods =
                if ($Property) {
                    $TargetType.GetMethod("get_$Property")
                    $TargetType.GetMethod("set_$Property")
                }
                else {
                    $targetType.GetMethods().where{ ($_.Name -Like 'get_*' -or  $_.Name -Like 'set_*') -and $_.Name -NotLike '???__*' }
                }
            $Accessors = @{}
            foreach ($Method in $Methods) {
                $Member = $Method.Name.SubString(4)
                if (-not $Force -and $Member -in $Members) { continue }
                $Parameters = $Method.GetParameters()
                if ($Method.Name -Like 'get_*') {
                    if ($Parameters.Count -eq 0) {
                        if ($Method.ReturnType.IsArray) {
                            $Expression = @"
`$TargetType = '$ClassName' -as [Type]
`$Method = `$TargetType.GetMethod('$($Method.Name)')
`$Invoke = `$Method.Invoke(`$this, `$Null)
`$Output = `$Invoke -as '$($Method.ReturnType.FullName)'
if (@(`$Invoke).Count -gt 1) { `$Output } else { ,`$Output }
"@

                        }
                        else {
                            $Expression = @"
`$TargetType = '$ClassName' -as [Type]
`$Method = `$TargetType.GetMethod('$($Method.Name)')
`$Method.Invoke(`$this, `$Null) -as '$($Method.ReturnType.FullName)'
"@

                        }
                        if (-not $Accessors.Contains($Member)) { $Accessors[$Member] = @{} }
                        $Accessors[$Member].Value = [ScriptBlock]::Create($Expression)
                    }
                    else { Write-Warning "The getter '$($Method.Name)' is skipped as it is not parameter-less." }
                }
                elseif ($Method.Name -Like 'set_*') {
                    if ($Parameters.Count -eq 1) {
                        $Expression = @"
`$TargetType = '$ClassName' -as [Type]
`$Method = `$TargetType.GetMethod('$($Method.Name)')
`$Method.Invoke(`$this, `$Args)
"@

                        if (-not $Accessors.Contains($Member)) { $Accessors[$Member] = @{} }
                        $Accessors[$Member].SecondValue = [ScriptBlock]::Create($Expression)
                    }
                    else { Write-Warning "The setter '$($Method.Name)' is skipped as it does not have a single parameter" }
                }
            }
            foreach ($MemberName in $Accessors.get_Keys()) {
                $TypeData = $Accessors[$MemberName]
                if ($TypeData.Contains('Value')) {
                    $TypeData.TypeName   = $ClassName
                    $TypeData.MemberType = 'ScriptProperty'
                    $TypeData.MemberName = $MemberName
                    $TypeData.Force      = $Force
                    Update-TypeData @TypeData
                }
                else { Write-Warning "'[$ClassName].set_$MemberName()' accessor requires a '[$ClassName].get_$MemberName()' accessor." }
            }
        }
    }
}

function Set-View {
<#
.SYNOPSIS
    Sets the default view output
 
.LINK
    https://stackoverflow.com/questions/77752014/how-to-type-convert-a-derived-class
 
#>

    param(
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Class,

        [Parameter(Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [ScriptBlock]$ScriptBlock
    )

    process {
        $ClassNames =
            if ($Class) { $Class }
            else {
                $Caller = (Get-PSCallStack)[1]
                if ($Caller.FunctionName -ne '<ScriptBlock>') {
                    $Caller.FunctionName
                }
                elseif ($Caller.ScriptName) {
                    $Ast = [System.Management.Automation.Language.Parser]::ParseFile($Caller.ScriptName, [ref]$Null, [ref]$Null)
                    $Ast.EndBlock.Statements.where{ $_.IsClass }.Name
                }
            }

        foreach ($ClassName in $ClassNames) {
            $FormatData = @"
                <Configuration>
                <ViewDefinitions>
                    <View>
                    <Name>$ClassName</Name>
                    <OutOfBand />
                    <ViewSelectedBy>
                        <TypeName>$ClassName</TypeName>
                    </ViewSelectedBy>
                        <CustomControl>
                        <CustomEntries>
                            <CustomEntry>
                            <CustomItem>
                                <ExpressionBinding>
                                <ScriptBlock>
                                    <![CDATA[$($ScriptBlock.ToString())]]>
                                </ScriptBlock>
                                </ExpressionBinding>
                            </CustomItem>
                            </CustomEntry>
                        </CustomEntries>
                        </CustomControl>
                    </View>
                    </ViewDefinitions>
                </Configuration>
"@

            $TempFile = [IO.Path]::GetTempPath() + "$ClassName.ps1xml"
            Out-File -InputObject $FormatData -LiteralPath $TempFile -Encoding ASCII
            Update-FormatData -PrependPath $TempFile
        }
    }
}

Class PSLanguageType {
    hidden static $_TypeCache = [Dictionary[String,Bool]]::new()
    hidden Static PSLanguageType() {
        [PSLanguageType]::_TypeCache['System.Management.Automation.PSCustomObject'] = $True # https://github.com/PowerShell/PowerShell/issues/20767
    }
    [Bool]IsRestricted($TypeName) {
        if ($Null -eq $TypeName) { return $True } # Warning: a $Null is considered a restricted "type"!
        $Type = $TypeName -as [Type]
        if ($Null -eq $Type) { Throw 'Unknown type name: $TypeName' }
        $TypeName = $Type.FullName
        return $TypeName -in 'bool', 'array', 'hashtable'
    }
    [Bool]IsConstrained($TypeName) { # https://stackoverflow.com/a/64806919/1701026
        if ($Null -eq $TypeName) { return $True } # Warning: a $Null is considered a constrained "type"!
        $Type = $TypeName -as [Type]
        if ($Null -eq $Type) { Throw 'Unknown type name: $TypeName' }
        $TypeName = $Type.FullName
        if (-not [PSLanguageType]::_TypeCache.ContainsKey($TypeName)) {
            [PSLanguageType]::_TypeCache[$TypeName] = try {
                $ConstrainedSession = [PowerShell]::Create()
                $ConstrainedSession.RunSpace.SessionStateProxy.LanguageMode = 'Constrained'
                $ConstrainedSession.AddScript("[$TypeName]0").Invoke().Count -ne 0 -or
                $ConstrainedSession.Streams.Error[0].FullyQualifiedErrorId -ne 'ConversionSupportedOnlyToCoreTypes'
            } catch { $False }
        }
        return [PSLanguageType]::_TypeCache[$TypeName]
    }
}

New-Variable -Scope Global -Name PSLanguageType -Value ([PSLanguageType]::new()) -Force

Class PSInstance {
    static [Object]Create($Object) {
        if ($Null -eq $Object) { return $Null }
        elseif ($Object -is [String]) {
            $String = if ($Object.StartsWith('[') -and $Object.EndsWith(']')) { $Object.SubString(1, ($Object.Length - 2)) } else { $Object }
            Switch -Regex ($String) {
                '^((System\.)?String)?$'                                         { return '' }
                '^(System\.)?Array$'                                             { return ,@() }
                '^(System\.)?Object\[\]$'                                        { return ,@() }
                '^((System\.)?Collections\.Hashtable\.)?hashtable$'              { return @{} }
                '^((System\.)?Management\.Automation\.)?ScriptBlock$'            { return {} }
                '^((System\.)?Collections\.Specialized\.)?Ordered(Dictionary)?$' { return [Ordered]@{} }
                '^((System\.)?Management\.Automation\.)?PS(Custom)?Object$'      { return [PSCustomObject]@{} }
            }
            $Type = $String -as [Type]
            if (-not $Type) { Throw "Unknown type: [$Object]" }
        }
        elseif ($Object -is [Type]) {
            $Type = $Object.UnderlyingSystemType
            if     ("$Type" -eq 'string')      { Return '' }
            elseif ("$Type" -eq 'array')       { Return ,@() }
            elseif ("$Type" -eq 'scriptblock') { Return {} }
        }
        else {
            if     ($Object -is [Object[]])       { Return ,@() }
            elseif ($Object -is [ScriptBlock])    { Return {} }
            elseif ($Object -is [PSCustomObject]) { Return [PSCustomObject]::new() }
            $Type = $Object.GetType()
        }
        try { return [Activator]::CreateInstance($Type) } catch { throw $_ }
    }

}

# ____ ____ ____ _ _ _
# | _ \/ ___/ ___| ___ _ __(_) __ _| (_)_______
# | |_) \___ \___ \ / _ \ '__| |/ _` | | |_ / _ \
# | __/ ___) |__) | __/ | | | (_| | | |/ / __/
# |_| |____/____/ \___|_| |_|\__,_|_|_/___\___|

Class PSKeyExpression {
    hidden static [Regex]$UnquoteMatch = '^[\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}_][\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}\p{Nd}_]*$' # https://stackoverflow.com/questions/62754771/unquoted-key-rules-and-best-practices
    hidden $Key
    hidden [PSLanguageMode]$LanguageMode = 'Restricted'
    hidden [Bool]$Compress
    hidden [Int]$MaximumLength

    PSKeyExpression($Key)                                                 { $this.Key = $Key }
    PSKeyExpression($Key, [PSLanguageMode]$LanguageMode)                  { $this.Key = $Key; $this.LanguageMode = $LanguageMode }
    PSKeyExpression($Key, [PSLanguageMode]$LanguageMode, [Bool]$Compress) { $this.Key = $Key; $this.LanguageMode = $LanguageMode; $this.Compress = $Compress }
    PSKeyExpression($Key, [int]$MaximumLength)                            { $this.Key = $Key; $this.MaximumLength = $MaximumLength }

    [String]ToString() {
        $Name = $this.Key
        if ($Name -is [byte]  -or $Name -is [int16]  -or $Name -is [int32]  -or $Name -is [int64]  -or
            $Name -is [sbyte] -or $Name -is [uint16] -or $Name -is [uint32] -or $Name -is [uint64] -or
            $Name -is [float] -or $Name -is [double] -or $Name -is [decimal]) {
            if ($this.MaximumLength -and $Name.Length -gt $this.MaximumLength) {
                return "$Name".SubString(0, ($this.MaximumLength - 3)) + '...'
            }
            return $Name
        }
        if ($this.MaximumLength) { $Name = "$Name" }
        if ($Name -is [String]) {
            if ($Name -cMatch [PSKeyExpression]::UnquoteMatch) {
                if ($this.MaximumLength -and $Name.Length -gt $this.MaximumLength) {
                    return $Name.SubString(0, ($this.MaximumLength - 3)) + '...'
                }
                return $Name
            }
            $Name = $Name.Replace("'", "''")
            if ($this.MaximumLength -and $this.MaximumLength -and $Name.Length -gt $this.MaximumLength - 2) {
                $Name = $Name.SubString(0, ($this.MaximumLength - 5)) + '...'
            }
            return "'$Name'"
        }
        else {
            $Node = [PSNode]::ParseInput($Name, 2) # There is no way (yet) to expand keys more than 2 levels
            return  [PSSerialize]::new($Node, $this.LanguageMode, -$this.Compress)
        }

    }
}

Class PSSerialize {
    hidden static [String[]]$Parameters = 'LanguageMode', 'Explicit', 'FullTypeName', 'HighFidelity', 'Indent', 'ExpandSingleton'

    hidden [PSLanguageMode]$LanguageMode = 'Restricted'
    hidden [Int]$ExpandDepth = [Int]::MaxValue
    hidden [Bool]$Explicit
    hidden [Bool]$FullTypeName
    hidden [bool]$HighFidelity
    hidden [String]$Indent = ' '
    hidden [Bool]$ExpandSingleton

    hidden static [Dictionary[String,Bool]]$IsConstrainedType = [Dictionary[String,Bool]]::new()
    hidden static [Dictionary[String,Bool]]$HasStringConstructor = [Dictionary[String,Bool]]::new()

    # The dictionary below defines the round trip property. Unless the `-HighFidelity` switch is set,
    # the serialization will stop (even it concerns a `PSCollectionNode`) when the specific property
    # type is reached.
    # * An empty string will return the string representation of the object: `"<Object>"`
    # * Any other string will return the string representation of the object property: `"$(<Object>.<Property>)"`
    # * A ScriptBlock will be invoked and the result will be used for the object value

    hidden static $RoundTripProperty = @{
        'Microsoft.Management.Infrastructure.CimInstance'                     = ''
        'Microsoft.Management.Infrastructure.CimSession'                      = 'ComputerName'
        'Microsoft.PowerShell.Commands.ModuleSpecification'                   = 'Name'
        'System.DateTime'                                                     = { $($Input).ToString('o') }
        'System.DirectoryServices.DirectoryEntry'                             = 'Path'
        'System.DirectoryServices.DirectorySearcher'                          = 'Filter'
        'System.Globalization.CultureInfo'                                    = 'Name'
        'Microsoft.PowerShell.VistaCultureInfo'                               = 'Name'
        'System.Management.Automation.AliasAttribute'                         = 'AliasNames'
        'System.Management.Automation.ArgumentCompleterAttribute'             = 'ScriptBlock'
        'System.Management.Automation.ConfirmImpact'                          = ''
        'System.Management.Automation.DSCResourceRunAsCredential'             = ''
        'System.Management.Automation.ExperimentAction'                       = ''
        'System.Management.Automation.OutputTypeAttribute'                    = 'Type'
        'System.Management.Automation.PSCredential'                           = { ,@($($Input).UserName, @("(""$($($Input).Password | ConvertFrom-SecureString)""", '|', 'ConvertTo-SecureString)')) }
        'System.Management.Automation.PSListModifier'                         = 'Replace'
        'System.Management.Automation.PSReference'                            = 'Value'
        'System.Management.Automation.PSTypeNameAttribute'                    = 'PSTypeName'
        'System.Management.Automation.RemotingCapability'                     = ''
        'System.Management.Automation.ScriptBlock'                            = 'Ast'
        'System.Management.Automation.SemanticVersion'                        = ''
        'System.Management.Automation.ValidatePatternAttribute'               = 'RegexPattern'
        'System.Management.Automation.ValidateScriptAttribute'                = 'ScriptBlock'
        'System.Management.Automation.ValidateSetAttribute'                   = 'ValidValues'
        'System.Management.Automation.WildcardPattern'                        = { $($Input).ToWql().Replace('%', '*').Replace('_', '?').Replace('[*]', '%').Replace('[?]', '_') }
        'Microsoft.Management.Infrastructure.CimType'                         = ''
        'System.Management.ManagementClass'                                   = 'Path'
        'System.Management.ManagementObject'                                  = 'Path'
        'System.Management.ManagementObjectSearcher'                          = { $($Input).Query.QueryString }
        'System.Net.IPAddress'                                                = 'IPAddressToString'
        'System.Net.IPEndPoint'                                               = { $($Input).Address.Address; $($Input).Port }
        'System.Net.Mail.MailAddress'                                         = 'Address'
        'System.Net.NetworkInformation.PhysicalAddress'                       = ''
        'System.Security.Cryptography.X509Certificates.X500DistinguishedName' = 'Name'
        'System.Security.SecureString'                                        = { ,[string[]]("(""$($Input | ConvertFrom-SecureString)""", '|', 'ConvertTo-SecureString)') }
        'System.Text.RegularExpressions.Regex'                                = ''
        'System.Uri'                                                          = 'OriginalString'
        'System.Version'                                                      = ''
        'System.Void'                                                         = $Null
    }
    hidden [System.Text.StringBuilder]$StringBuilder = [System.Text.StringBuilder]::new()
    hidden [Int]$Offset = 0
    hidden [Int]$LineNumber = 1

    PSSerialize($Object) { $this.Serialize($Object) }
    PSSerialize($Object, $LanguageMode, $ExpandDepth) {
        $this.LanguageMode = $LanguageMode
        $this.ExpandDepth = $ExpandDepth
        $this.Serialize($Object)
    }
    PSSerialize(
        $Object,
        $LanguageMode    = 'Restricted',
        $ExpandDepth     = [Int]::MaxValue,
        $Explicit        = $False,
        $FullTypeName    = $False,
        $HighFidelity    = $False,
        $ExpandSingleton = $False,
        $Indent          = ' '
    ) {
        $this.LanguageMode    = $LanguageMode
        $this.ExpandDepth     = $ExpandDepth
        $this.Explicit        = $Explicit
        $this.FullTypeName    = $FullTypeName
        $this.HighFidelity    = $HighFidelity
        $this.ExpandSingleton = $ExpandSingleton
        $this.Indent          = $Indent
        $this.Serialize($Object)
    }
    PSSerialize($Object, [HashTable]$Parameters) {
        foreach ($Name in $Parameters.get_Keys()) { # https://github.com/PowerShell/PowerShell/issues/13307
            if ($Name -notin [PSSerialize]::Parameters) { Throw "Unknown parameter: $Name." }
            $this.GetType().GetProperty($Name).SetValue($this, $Parameters[$Name])
        }
        $this.Serialize($Object)
    }

    [String]Serialize($Object) {
        if ($this.LanguageMode -eq 'NoLanguage') { Throw 'The language mode "NoLanguage" is not supported.' }
        if (-not ('ConstrainedLanguage', 'FullLanguage' -eq $this.LanguageMode)) {
            if ($this.FullTypeName) { Write-Warning 'The FullTypeName switch requires Constrained - or FullLanguage mode.' }
            if ($this.Explicit)     { Write-Warning 'The Explicit switch requires Constrained - or FullLanguage mode.' }
        }
        if ($Object -is [PSNode]) { $Node = $Object } else { $Node = [PSNode]::ParseInput($Object) }
        $this.Iterate($Node)
        return $this.StringBuilder.ToString()
    }

    hidden Iterate([PSNode]$Node) {
        $Value = $Node.Value
        if ($Null -eq $Value) {
            $this.StringBuilder.Append('$Null')
            return
        }
        $Type = $Node.ValueType
        $TypeName = "$Type"
        $TypeInitializer =
            if ($Null -ne $Type -and (
                $this.LanguageMode -eq 'Full' -or (
                        $this.LanguageMode -eq 'Constrained' -and
                        $Global:PSLanguageType.IsConstrained($Type) -and (
                            $this.Explicit -or -not (
                                $Type.IsPrimitive      -or
                                $Value -is [String]    -or
                                $Value -is [Object[]]  -or
                                $Value -is [Hashtable]
                            )
                        )
                    )
                )
                ) {
                    if ($this.FullTypeName) {
                        if ($Type.FullName -eq 'System.Management.Automation.PSCustomObject' ) { '[System.Management.Automation.PSObject]' } # https://github.com/PowerShell/PowerShell/issues/2295
                        else { "[$($Type.FullName)]" }
                    }
                    elseif ($TypeName -eq 'System.Object[]') { "[array]" }
                    elseif ($TypeName -eq 'System.Management.Automation.PSCustomObject') { "[PSCustomObject]" }
                    else { "[$TypeName]" }
                }
        if ($TypeInitializer) { $this.StringBuilder.Append($TypeInitializer) }

        if ($Node -is [PSLeafNode] -or (-not $this.HighFidelity -and [PSSerialize]::RoundTripProperty.Contains($Node.ValueType.FullName))) {
            $Expression =
                if ([PSSerialize]::RoundTripProperty.Contains($Node.ValueType.FullName)) {
                    $Property = [PSSerialize]::RoundTripProperty[$Node.ValueType.FullName]
                        if ($Null -eq $Property)          { $Null }
                    elseif ($Property -is [String])       { if ($Property) { ,$Value.$Property } else { "$Value" } }
                    elseif ($Property -is [ScriptBlock] ) { Invoke-Command $Property -InputObject $Value }
                    elseif ($Property -is [HashTable])    { if ($this.LanguageMode -eq 'Restricted') { $Null } else { @{} } }
                    elseif ($Property -is [Array])        { @($Property.foreach{ $Value.$_ }) }
                    else { Throw "Unknown round trip property type: $($Property.GetType())."}
                }
                elseif ($Type.IsPrimitive)                        { $Value }
                elseif (-not $Type.GetConstructors())             { "$TypeName" }
                elseif ($Type.GetMethod('ToString', [Type[]]@())) { $Value.ToString() }
                elseif ($Value -is [Collections.ICollection])     { ,$Value }
                else                                              { $Value } # Handle compression

            if     ($Null -eq $Expression)         { $Expression = '$Null' }
            elseif ($Expression -is [Bool])        { $Expression = "`$$Value" }
            elseif ($Expression -is [Char])        { $Expression = "'$Value'" }
            elseif ($Expression -is [ScriptBlock]) { $Expression = $Expression.Ast.Extent.Text }
            elseif ($Expression -is [HashTable])   { $Expression = '@{}' }
            elseif ($Expression -is [Array]) {
                $Space = if ($this.ExpandDepth -ge 0) { ' ' }
                $New = if ($TypeInitializer) { '::new(' } else { '@(' }
                $Expression = $New + ($Expression.foreach{
                    if ($Null -eq $_)  { '$Null' }
                    elseif ($_.GetType().IsPrimitive) { "$_" }
                    elseif ($_ -is [Array]) { $_ -Join $Space }
                    else { "'$_'" }
                } -Join ",$Space") + ')'
            }
            elseif ($Type -and $Type.IsPrimitive) { }
            else {
                if ($Expression -isnot [String]) { $Expression = "$Expression" }
                $Expression = if ($Expression.Contains("`n")) {
                    "@'" + [Environment]::NewLine + "$Expression".Replace("'", "''") + [Environment]::NewLine + "'@"
                }
                else {
                    "'" + "$Expression".Replace("'", "''") + "'"
                }
            }

            $this.StringBuilder.Append($Expression)
        }
        elseif ($Node -is [PSListNode]) {
            $this.StringBuilder.Append('@(')
            $this.Offset++
            $StartLine = $this.LineNumber
            $Index = 0
            $ChildNodes = $Node.get_ChildNodes()
            $ExpandSingle = $this.ExpandSingleton -or $ChildNodes.Count -gt 1 -or ($ChildNodes.Count -eq 1 -and $ChildNodes[0] -isnot [PSLeafNode])
            $ChildNodes.foreach{
                if ($Index++) {
                    $this.StringBuilder.Append(',')
                    $this.NewWord()
                }
                elseif ($ExpandSingle) { $this.NewWord('') }
                $this.Iterate($_)
            }
            $this.Offset--
            if ($this.LineNumber -gt $StartLine) { $this.NewWord('') }
            $this.StringBuilder.Append(')')
        }
        else { # if ($Node -is [PSMapNode]) {
            $ChildNodes = $Node.get_ChildNodes()
            if ($ChildNodes) {
                $this.StringBuilder.Append('@{')
                $this.Offset++
                $StartLine = $this.LineNumber
                $Index = 0
                $ExpandSingle = $this.ExpandSingleton -or $ChildNodes.Count -gt 1 -or $ChildNodes[0] -isnot [PSLeafNode]
                $ChildNodes.foreach{
                    if ($Index++) {
                        $Separator = if ($this.ExpandDepth -ge 0) { '; ' } else { ';' }
                        $this.NewWord($Separator)
                    }
                    elseif ($this.ExpandDepth -ge 0) {
                        if ($ExpandSingle) { $this.NewWord() } else { $this.StringBuilder.Append(' ') }
                    }
                    $Key = [PSKeyExpression]::new($_.Name, $this.LanguageMode, ($this.ExpandDepth -lt 0))
                    $this.StringBuilder.Append($Key)
                    if ($this.ExpandDepth -ge 0) { $this.StringBuilder.Append(' = ') } else { $this.StringBuilder.Append('=') }
                    $this.Iterate($_)
                }
                $this.Offset--
                if ($this.LineNumber -gt $StartLine) { $this.NewWord() }
                elseif ($this.ExpandDepth -ge 0) { $this.StringBuilder.Append(' ') }
                $this.StringBuilder.Append('}')
            }
            elseif ($Node -is [PSObjectNode] -and $TypeInitializer) { $this.StringBuilder.Append('::new()') }
            else { $this.StringBuilder.Append('@{}') }
        }
    }

    hidden NewWord() { $this.NewWord(' ') }
    hidden NewWord([String]$Separator) {
        if ($this.Offset -le $this.ExpandDepth) {
            $this.StringBuilder.AppendLine()
            for($i = $this.Offset; $i -gt 0; $i--) {
                $this.StringBuilder.Append($this.Indent)
            }
            $this.LineNumber++
        }
        else {
            $this.StringBuilder.Append($Separator)
        }
    }

    [String] ToString() {
        return $this.StringBuilder.ToString()
    }
 }

 Class PSStringify : PSSerialize {
    hidden static [int]$MaximumListCount    = 3
    hidden static [int]$MaximumMapCount     = 3
    hidden static [int]$MaximumStringLength = 48
    hidden static [int]$MaximumKeyLength    = 12
    hidden static [int]$MaximumValueLength  = 16

    PSStringify($Object) { $this.Serialize($Object) }
 }

# ____ ____ ____ _ _ _
# | _ \/ ___|| _ \ ___ ___ ___ _ __(_) __ _| (_)_______
# | |_) \___ \| | | |/ _ \/ __|/ _ \ '__| |/ _` | | |_ / _ \
# | __/ ___) | |_| | __/\__ \ __/ | | | (_| | | |/ / __/
# |_| |____/|____/ \___||___/\___|_| |_|\__,_|_|_/___\___|

Class PSDeserialize {
    hidden static [String[]]$Parameters = 'LanguageMode', 'ArrayType', 'HashTableType'
    hidden static PSDeserialize() { Use-ClassAccessors }

    hidden $_Object
    [PSLanguageMode]$LanguageMode = 'Restricted'
    [Type]$ArrayType     = 'Array'     -as [Type]
    [Type]$HashTableType = 'HashTable' -as [Type]
    [String] $Expression

    PSDeserialize([String]$Expression) { $this.Expression = $Expression }
    PSDeserialize(
        $Expression,
        $LanguageMode  = 'Restricted',
        $ArrayType     = $Null,
        $HashTableType = $Null
    ) {
        if ($this.LanguageMode -eq 'NoLanguage') { Throw 'The language mode "NoLanguage" is not supported.' }
        $this.Expression    = $Expression
        $this.LanguageMode  = $LanguageMode
        if ($Null -ne $ArrayType)     { $this.ArrayType     = $ArrayType }
        if ($Null -ne $HashTableType) { $this.HashTableType = $HashTableType }
    }

    hidden [Object] get_Object() {
        if ($Null -eq $this._Object) {
            $Ast = [System.Management.Automation.Language.Parser]::ParseInput($this.Expression, [ref]$null, [ref]$Null)
            $this._Object = $this.ParseAst([Ast]$Ast)
        }
        return $this._Object
    }

    hidden [Object] ParseAst([Ast]$Ast) {
        # Write-Host 'Ast type:' "$($Ast.getType())"
        $Type = $Null
        if ($Ast -is [ConvertExpressionAst]) {
            $FullTypeName = $Ast.Type.TypeName.FullName
            if (
                $this.LanguageMode -eq 'Full' -or (
                    $this.LanguageMode -eq 'Constrained' -and
                    $Global:PSLanguageType.IsConstrained($FullTypeName)
                )
            ) {
                try { $Type = $FullTypeName -as [Type] } catch { write-error $_ }
            }
            $Ast = $Ast.Child
        }
        if ($Ast -is [ScriptBlockAst]) {
            $List = [List[Object]]::new()
            if ($Null -ne $Ast.BeginBlock)   { $Ast.BeginBlock.Statements.ForEach{ $List.Add($this.ParseAst($_)) } }
            if ($Null -ne $Ast.ProcessBlock) { $Ast.ProcessBlock.Statements.ForEach{ $List.Add($this.ParseAst($_)) } }
            if ($Null -ne $Ast.EndBlock)     { $Ast.EndBlock.Statements.ForEach{ $List.Add($this.ParseAst($_)) } }
            if ($List.Count -eq 1) { return $List[0] } else { return @($List) }
        }
        elseif ($Ast -is [PipelineAst]) {
            $Elements = $Ast.PipelineElements
            if (-not $Elements.Count)  { return @() }
            elseif ($Elements -is [CommandAst]) {
                return $Null #85 ConvertFrom-Expression: convert function/cmdlet calls to Objects
            }
            elseif ($Elements.Expression.Count -eq 1) { return $this.ParseAst($Elements.Expression[0]) }
            else { return $Elements.Expression.Foreach{ $this.ParseAst($_) } }
        }
        elseif ($Ast -is [ArrayLiteralAst] -or $Ast -is [ArrayExpressionAst]) {
            if (-not $Type -or 'System.Object[]', 'System.Array' -eq $Type.FullName) { $Type = $this.ArrayType }
            if ($Ast -is [ArrayLiteralAst]) { $Value = $Ast.Elements.foreach{ $this.ParseAst($_) } }
            else { $Value = $Ast.SubExpression.Statements.foreach{ $this.ParseAst($_) } }
            if ('System.Object[]', 'System.Array' -eq $Type.FullName) {
                if ($Value -isnot [Array]) { $Value = @($Value) } # Prevent single item array unrolls
            }
            else { $Value = $Value -as $Type }
            return $Value
        }
        elseif ($Ast -is [HashtableAst]) {
            if (-not $Type -or $Type.FullName -eq 'System.Collections.Hashtable') { $Type = $this.HashTableType }
            $IsPSCustomObject = "$Type" -in
                'PSCustomObject',
                'System.Management.Automation.PSCustomObject',
                'psobject',
                'System.Management.Automation.PSObject'
            if ($Type.FullName -eq 'System.Collections.Hashtable') { $Map = @{} } # Case insensitive
            elseif ($IsPSCustomObject) { $Map = [Ordered]@{} }
            else { $Map = New-Object -Type $Type }
            $Ast.KeyValuePairs.foreach{
                if ( $Map -is [Collections.IDictionary]) { $Map.Add($_.Item1.Value, $this.ParseAst($_.Item2)) }
                else { $Map."$($_.Item1.Value)" = $this.ParseAst($_.Item2) }
            }
            if ($IsPSCustomObject) { return [PSCustomObject]$Map } else { return $Map }
        }
        elseif ($Ast -is [ConstantExpressionAst]) {
            if ($Type) { $Value = $Ast.Value -as $Type } else { $Value = $Ast.Value }
            return $Value
        }
        elseif ($Ast -is [VariableExpressionAst]) {
            $Value = switch ($Ast.VariablePath.UserPath) {
                Null        { $Null }
                True        { $True }
                False       { $False }
                PSCulture   { (Get-Culture).ToString() }
                PSUICulture { (Get-UICulture).ToString() }
                Default     { $Ast.Extent.Text }
            }
            return $Value
        }
        else { return $Null }
    }
}

# __ __ _
# \ \/ /__| |_ __
# \ // _` | '_ \
# / \ (_| | | | |
# /_/\_\__,_|_| |_|

enum XdnType { Root; Ancestor; Index; Child; Descendant; Equals; Error = 99 }

enum XdnColorName { Reset; Regular; Literal; WildCard; Operator; Error = 99 }

class XdnColor {
    Static [String]$Regular
    Static [String]$Literal
    Static [String]$Wildcard
    Static [String]$Extended
    Static [String]$Operator
    Static [String]$Error
    Static [String]$Reset

    static XdnColor() {
        $PSReadLineOption = try { Get-PSReadLineOption -ErrorAction SilentlyContinue } catch { $Null }
        [XdnColor]::Reset    = [char]0x1b + '[39m'
        [XdnColor]::Regular  = $PSReadLineOption.VariableColor
        [XdnColor]::WildCard = $PSReadLineOption.EmphasisColor
        [XdnColor]::Extended = $PSReadLineOption.StringColor
        [XdnColor]::Operator = $PSReadLineOption.CommandColor
        [XdnColor]::Error    = $PSReadLineOption.ErrorColor
    }
}

class XdnName {
    hidden [Bool]$_Literal
    hidden $_IsVerbatim
    hidden $_ContainsWildcard
    hidden $_Value

    hidden Initialize($Value, $Literal) {
        $this._Value = $Value
        if ($Null -ne $Literal) { $this._Literal = $Literal } else { $this._Literal = $this.IsVerbatim() }
        if ($this._Literal) {
            $XdnName = [XdnName]::new()
            $XdnName._ContainsWildcard = $False
         }
        else {
            $XdnName = [XdnName]::new()
            $XdnName._ContainsWildcard = $null
        }

    }
    XdnName() {}
    XdnName($Value)                    { $this.Initialize($Value, $null) }
    XdnName($Value, [Bool]$Literal)    { $this.Initialize($Value, $Literal) }
    static [XdnName]Literal($Value)    { return [XdnName]::new($Value, $true) }
    static [XdnName]Expression($Value) { return [XdnName]::new($Value, $false) }

    [Bool] IsVerbatim() {
        if ($Null -eq $this._IsVerbatim) {
            $this._IsVerbatim = $this._Value -is [String] -and $this._Value -Match '^[\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}_][\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}\p{Nd}_]*$' # https://stackoverflow.com/questions/62754771/unquoted-key-rules-and-best-practices
        }
        return $this._IsVerbatim
    }

    [Bool] ContainsWildcard() {
        if ($Null -eq $this._ContainsWildcard) {
            $this._ContainsWildcard = $this._Value -is [String] -and $this._Value -Match '(?<=([^`]|^)(``)*)[\?\*]'
        }
        return $this._ContainsWildcard
    }

    [Bool] Equals($Object) {
        if ($this._Literal)               { return $this._Value -eq $Object }
        elseif ($this.ContainsWildcard()) { return $Object -Like $this._Value }
        else                              { return $this._Value -eq $Object }
    }

    [String] ToString($Colored) {
        $Color = if ($Colored) {
            if ($this._Literal)               { [XdnColor]::Regular }
            elseif (-not $this.IsVerbatim())  { [XdnColor]::Extended }
            elseif ($this.ContainsWildcard()) { [XdnColor]::Wildcard }
            else                              { [XdnColor]::Regular }
        }
        $String =
            if ($this._Literal) { "'" + "$($this._Value)".Replace("'", "''") + "'" }
            else { "$($this._Value)" -replace '(?<!([^`]|^)(``)*)[\.\[\~\=\/]', '`${0}' } # Escape any Xdn operator (that isn't yet escaped)
        $Reset = if ($Colored) { [XdnColor]::Reset }
        return $Color + $String + $Reset
    }
    [String] ToString()        { return $this.ToString($False) }
    [String] ToColoredString() { return $this.ToString($True) }

    static XdnName() {
        Set-View { $_.ToString($True) }
    }
}
class XdnPath {
    hidden static $_PSReadLineOption

    hidden $_Entries = [List[KeyValuePair[XdnType, Object]]]::new()

    hidden [Object]get_Entries() { return ,$this._Entries }

    XdnPath ([String]$Path)                 { $this.FromString($Path, $False) }
    XdnPath ([String]$Path, [Bool]$Literal) { $this.FromString($Path, $Literal) }
    XdnPath ([PSNodePath]$Path) {
        foreach ($Node in $Path.Nodes) {
            Switch ($Node.NodeOrigin) {
                Root { $this.Add('Root',  $Null) }
                List { $this.Add('Index', $Node.Name) }
                Map  { $this.Add('Child', [XdnName]$Node.Name) }
            }
        }
    }

    hidden AddError($Value) {
        $this._Entries.Add([KeyValuePair[XdnType, Object]]::new('Error', $Value))
    }

    Add ($EntryType, $Value) {
        if ($EntryType -eq '/') {
            if ($this._Entries.Count -eq 0) { $this.AddError($Value) }
            elseif ($this._Entries[-1].Key -NotIn 'Child', 'Descendant', 'Equals') { $this.AddError($Value) }
            else {
                $EntryValue = $this._Entries[-1].Value
                if ($EntryValue -IsNot [IList]) { $EntryValue = [List[Object]]$EntryValue }
                $EntryValue.Add($Value)
                $this._Entries[-1] = [KeyValuePair[XdnType, Object]]::new($this._Entries[-1].Key, $EntryValue)
            }
        }
        else {
            $XdnType = Switch ($EntryType) { '.' { 'Child' } '~' { 'Descendant' } '=' { 'Equals' } default { $EntryType } }
            if ($XdnType -in [XdnType].GetEnumNames()) {
                $this._Entries.Add([KeyValuePair[XdnType, Object]]::new($XdnType, $Value))
            } else { $this.AddError($Value) }

        }
    }

    hidden FromString ([String]$Path, [Bool]$Literal) {
        $XdnOperator = $Null
        if (-not $this._Entries.Count) {
            $IsRoot = if ($Literal) { $Path -NotMatch '^\.' } else { $Path -NotMatch '^(?<=([^`]|^)(``)*)\.' }
            if ($IsRoot) {
                $this.Add('Root', $Null)
                $XdnOperator = 'Child'
            }
        }
        $Length  = [Int]::MaxValue
        while ($Path) {
            if ($Path.Length -ge $Length) { break }
            $Length = $Path.Length
            if ($Path[0] -in "'", '"') {
                if (-not $XdnOperator) { $XdnOperator = 'Child' }
                $Ast = [Parser]::ParseInput($Path, [ref]$Null, [ref]$Null)
                $StringAst = $Ast.EndBlock.Statements.Find({ $args[0] -is [StringConstantExpressionAst] }, $False)
                if ($Null -ne $StringAst) {
                    $this.Add($XdnOperator, [XdnName]::Literal($StringAst[0].Value))
                    $Path = $Path.SubString($StringAst[0].Extent.EndOffset)
                }
                else { # Probably a quoting error
                    $this.Add($XdnOperator, [XdnName]::Literal($Path, $True))
                    $Path = $Null
                }
            }
            else {
                $Match = if ($Literal) { [regex]::Match($Path, '[\.\[]') } else { [regex]::Match($Path, '(?<=([^`]|^)(``)*)[\.\[\~\=\/]') }
                $Match = [regex]::Match($Path, '(?<=([^`]|^)(``)*)[\.\[\~\=\/]')
                if ($Match.Success -and $Match.Index -eq 0) { # Operator
                    $IndexEnd  = if ($Match.Value -eq '[') { $Path.IndexOf(']') }
                    $Ancestors = if ($Match.Value -eq '.' -and $Path -Match '^\.\.+') { $Matches[0].Length - 1 }
                    if ($IndexEnd -gt 0) {
                        $Index = $Path.SubString(1, ($IndexEnd - 1))
                        $CommandAst = [Parser]::ParseInput($Index, [ref]$Null, [ref]$Null).EndBlock.Statements.PipelineElements
                        if ($CommandAst -is [CommandExpressionAst]) { $Index = $CommandAst.expression.Value }
                        $this.Add('Index', $Index)
                        $Path = $Path.SubString(($IndexEnd + 1))
                        $XdnOperator = $Null
                    }
                    elseif ($Ancestors) {
                        $this.Add('Ancestor', $Ancestors)
                        $Path = $Path.Substring($Ancestors + 1)
                        $XdnOperator = 'Child'
                    }
                    elseif ($Match.Value -in '.', '~', '=', '/' -and $Match.Value -ne $XdnOperator) {
                        $XdnOperator = $Match.Value
                        $Path = $Path.Substring(1)
                    }
                    else {
                        $XdnOperator = 'Error'
                        $this.Add($XdnOperator, $Match.Value)
                        $Path = $Path.Substring(1)
                    }
                }
                elseif ($Match.Success) {
                    if (-not $XdnOperator) { $XdnOperator = 'Child' }
                    $Name = $Path.SubString(0, $Match.Index)
                    $Value = if ($Literal) { [XdnName]::Literal($Name) } else { [XdnName]::Expression($Name) }
                    $this.Add($XdnOperator, $Value)
                    $Path = $Path.SubString($Match.Index)
                    $XdnOperator = $Null
                }
                else {
                    $Value = if ($Literal) { [XdnName]::Literal($Path) } else { [XdnName]::Expression($Path)}
                    $this.Add($XdnOperator, $Value)
                    $Path = $Null
                }
            }
        }
    }

    [String] ToString([String]$VariableName, [Bool]$Colored) {
        $RegularColor  = if ($Colored) { [XdnColor]::Regular }
        $OperatorColor = if ($Colored) { [XdnColor]::Operator }
        $ErrorColor    = if ($Colored) { [XdnColor]::Error }
        $ResetColor    = if ($Colored) { [char]0x1b + '[39m' }

        $Path = [System.Text.StringBuilder]::new()
        $PreviousEntry = $Null
        foreach ($Entry in $this._Entries) {
            $Value = $Entry.Value
            $Append = Switch ($Entry.Key) {
                Root        { "$OperatorColor$VariableName$ResetColor" }
                Ancestor    { "$OperatorColor$('.' * $Value)$ResetColor" }
                Index       {
                                $Dot = if (-not $PreviousEntry -or $PreviousEntry.Key -eq 'Ancestor') { "$OperatorColor." }
                                if ([int]::TryParse($Value, [Ref]$Null)) { "$Dot$RegularColor[$Value]$ResetColor" }
                                else { "$ErrorColor[$Value]$ResetColor" }
                            }
                Child       { "$RegularColor.$(@($Value).foreach{ $_.ToString($Colored) } -Join ""$OperatorColor/"")" }
                Descendant  { "$OperatorColor~$(@($Value).foreach{ $_.ToString($Colored) } -Join ""$OperatorColor/"")" }
                Equals      { "$OperatorColor=$(@($Value).foreach{ $_.ToString($Colored) } -Join ""$OperatorColor/"")" }
                Default     { "$ErrorColor$($Value)$ResetColor" }
            }
            $Path.Append($Append)
            $PreviousEntry = $Entry
        }
        return $Path.ToString()
    }
    [String] ToString()                             { return $this.ToString($Null        , $False)}
    [String] ToString([String]$VariableName)        { return $this.ToString($VariableName, $False)}
    [String] ToColoredString()                      { return $this.ToString($Null,         $True)}
    [String] ToColoredString([String]$VariableName) { return $this.ToString($VariableName, $True)}

    static XdnPath() {
        Use-ClassAccessors
        Set-View { $_.ToColoredString('<Root>') }
    }
}

enum PSNodeStructure { Leaf; List; Map }
enum PSNodeOrigin { Root; List; Map }

Class PSNodePath {
    hidden [PSNode[]]$Nodes
    hidden [String]$_String

    hidden PSNodePath($Nodes) { $this.Nodes = [PSNode[]]$Nodes }

    static [String] op_Addition([PSNodePath]$Path, [String]$String) {
        return "$Path" + $String
    }

    [Bool] Equals([Object]$Path) {
        if ($Path -is [PSNodePath]) {
            if ($this.Nodes.Count -ne $Path.Nodes.Count) { return $false }
            $Index = 0
            foreach( $Node in $this.Nodes) {
                if ($Node.NodeOrigin -ne $Path.Nodes[$Index].NodeOrigin -or
                    $Node.Name       -ne $Path.Nodes[$Index].Name
                ) { return $false }
                $Index++
            }
            return $true
        }
        elseif ($Path -is [String]) {
            return $this.ToString() -eq $Path
        }
        return $false
    }

    [String] ToString() {
        if ($Null -eq $this._String) {
            $Count = $this.Nodes.Count
            $this._String = if ($Count -gt 1) { $this.Nodes[-2].Path.ToString() }
            $Node = $this.Nodes[-1]
            $this._String += # Copy the new path into the current node
                if ($Node.NodeOrigin -eq 'List') {
                    "[$($Node._Name)]"
                }
                elseif ($Node.NodeOrigin -eq 'Map') {
                    $KeyExpression = [PSKeyExpression]$Node._Name
                    if ($Count -le 2) { $KeyExpression } else { ".$KeyExpression" }
                }
        }
        return $this._String
    }

}

# ____ ____ _ _ _
# | _ \/ ___|| \ | | ___ __| | ___
# | |_) \___ \| \| |/ _ \ / _` |/ _ \
# | __/ ___) | |\ | (_) | (_| | __/
# |_| |____/|_| \_|\___/ \__,_|\___|

Class PSNode : IComparable {
    hidden static PSNode() { Use-ClassAccessors }

    static [int]$DefaultMaxDepth = 20

    hidden $_Name
    [Int]$Depth
    hidden $_Value
    hidden [Int]$_MaxDepth = [PSNode]::DefaultMaxDepth
    [PSNode]$ParentNode
    [PSNode]$RootNode = $this
    # hidden [PSNodePath]$_Path
    # hidden [String]$_PathName
    hidden [Dictionary[String,Object]]$Cache = [Dictionary[String,Object]]::new()
    hidden [DateTime]$MaxDepthWarningTime            # Warn ones per item branch

    static [PSNode] ParseInput($Object, $MaxDepth) {
        $Node =
            if ($Object -is [PSNode]) { $Object }
            else {
                if     ($Null -eq $Object)                                  { [PSLeafNode]::new($Object) }
                elseif ($Object -is [Management.Automation.PSCustomObject]) { [PSObjectNode]::new($Object) }
                elseif ($Object -is [Collections.IDictionary])              { [PSDictionaryNode]::new($Object) }
                elseif ($Object -is [Specialized.StringDictionary])         { [PSDictionaryNode]::new($Object) }
                elseif ($Object -is [Collections.ICollection])              { [PSListNode]::new($Object) }
                elseif ($Object -is [ValueType])                            { [PSLeafNode]::new($Object) }
                elseif ($Object -is [String])                               { [PSLeafNode]::new($Object) }
                elseif ($Object -is [ScriptBlock])                          { [PSLeafNode]::new($Object) }
                elseif ($Object.PSObject.Properties)                        { [PSObjectNode]::new($Object) }
                else                                                        { [PSLeafNode]::new($Object) }
            }
        $Node.RootNode = $Node
        if ($MaxDepth -gt 0) { $Node._MaxDepth = $MaxDepth }
        return $Node
    }

    static [PSNode] ParseInput($Object) { return [PSNode]::parseInput($Object, 0) }

    static [int] Compare($Left, $Right) {
        return [ObjectComparer]::new().Compare($Left, $Right)
    }
    static [int] Compare($Left, $Right, [String[]]$PrimaryKey) {
        return [ObjectComparer]::new($PrimaryKey, 0, [CultureInfo]::CurrentCulture).Compare($Left, $Right)
    }
    static [int] Compare($Left, $Right, [String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) {
        return [ObjectComparer]::new($PrimaryKey, $ObjectComparison, [CultureInfo]::CurrentCulture).Compare($Left, $Right)
    }
    static [int] Compare($Left, $Right, [String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison, [CultureInfo]$CultureInfo) {
       return [ObjectComparer]::new($PrimaryKey, $ObjectComparison, $CultureInfo).Compare($Left, $Right)
    }

    hidden [object] get_Value() { return ,$this._Value }

    hidden set_Value($Value) {
        $this.Cache.Clear()
        $this._Value = $Value
        if ($Null -ne $this.ParentNode) { $this.ParentNode.SetItem($this._Name,  $Value) }
        if ($this.GetType() -ne [PSNode]::ParseInput($Value).GetType()) { # The root node is of type PSNode (always false)
            Write-Warning "The supplied value has a different PSNode type than the existing $($this.Path). Use .ParentNode.SetItem() method and reload its child item(s)."
        }
    }

    hidden [Object] get_Name() { return ,$this._Name }

    hidden [Object] get_MaxDepth() { return $this.RootNode._MaxDepth }

    hidden set_MaxDepth($MaxDepth) {
        if (-not $this.ChildType) {
            $this._MaxDepth = $MaxDepth
        }
        else {
            Throw 'The MaxDepth can only be set at the root node: [PSNode].RootNode.MaxDepth = <Maximum Depth>'
        }
    }

    hidden [PSNodeStructure] get_NodeStructure()  {
        if ($this -is [PSListNode]) { return 'List' } elseif ($this -is [PSMapNode]) { return 'Map' } else { return 'Leaf' }
    }

    hidden [PSNodeOrigin] get_NodeOrigin()  {
        if ($this.ParentNode -is [PSListNode]) { return 'List' } elseif ($this.ParentNode -is [PSMapNode]) { return 'Map' } else { return 'Root' }
    }

    hidden [Type] get_ValueType() {
        if ($Null -eq $this._Value) { return $Null }
        else { return $this._Value.getType() }
    }

    [Int]GetHashCode() { return $this.GetHashCode($false) } # Ignore the case of a string value

    hidden [PSNode] Append($Object) {
        $Node = [PSNode]::ParseInput($Object)
        $Node.Depth       = $this.Depth + 1
        $Node.RootNode    = [PSNode]$this.RootNode
        $Node.ParentNode  = $this
        return $Node
    }

    hidden [Object] get_Path() {
        if (-not $this.Cache.ContainsKey('Path')) {
            if ($this.ParentNode) {
                $this.Cache['Path'] = [PSNodePath]($this.ParentNode.get_Path().Nodes + $this)
            }
            else {
                $this.Cache['Path'] = [PSNodePath]$this
            }
        }
        return $this.Cache['Path']
    }

    [String] GetPathName($VariableName) {
        $PathName = $this.get_Path().ToString()
        if ($PathName -and $PathName.StartsWith('.') ) {
            return "$VariableName$PathName"
        }
        else {
            return "$VariableName.$PathName"
        }
    }

    [String] GetPathName() { return $this.get_Path().ToString() }

    [Bool] Equals($Object)  {  # https://learn.microsoft.com/dotnet/api/system.globalization.compareoptions
        if ($Object -is [PSNode]) { $Node = $Object }
        else { $Node = [PSNode]::ParseInput($Object) }
        $ObjectComparer = [ObjectComparer]::new()
        return $ObjectComparer.IsEqual($this, $Node)
    }

    [int] CompareTo($Object)  {
        if ($Object -is [PSNode]) { $Node = $Object }
        else { $Node = [PSNode]::ParseInput($Object) }
        $ObjectComparer = [ObjectComparer]::new()
        return $ObjectComparer.Compare($this, $Node)
    }

    hidden CollectNodes($NodeTable, [XdnPath]$Path, [Int]$PathIndex) {
        $Entry = $Path._Entries[$PathIndex]
        $NextIndex = if ($PathIndex -lt $Path._Entries.Count -1) { $PathIndex + 1 }
        $NextEntry = if ($NextIndex) { $Path._Entries[$NextIndex] }
        $Equals    = if ($NextEntry -and $NextEntry.Key -eq 'Equals') {
            $NextEntry.Value
            $NextIndex = if ($NextIndex -lt $Path._Entries.Count -1) { $NextIndex + 1 }
        }
        switch ($Entry.Key) {
            Root {
                $Node = $this.RootNode
                if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) }
                else { $NodeTable[$Node.getPathName()] = $Node }
            }
            Ancestor {
                $Node = $this
                for($i = $Entry.Value; $i -gt 0 -and $Node.ParentNode; $i--) { $Node = $Node.ParentNode }
                if ($i -eq 0) { # else: reached root boundary
                    if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) }
                    else { $NodeTable[$Node.getPathName()] = $Node }
                }
            }
            Index {
                if ($this -is [PSListNode] -and [Int]::TryParse($Entry.Value, [Ref]$Null)) {
                    $Node = $this.GetChildNode([Int]$Entry.Value)
                    if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) }
                    else { $NodeTable[$Node.getPathName()] = $Node }
                }
            }
            Default { # Child, Descendant
                if ($this -is [PSListNode]) { # Member access enumeration
                    foreach ($Node in $this.get_ChildNodes()) {
                        $Node.CollectNodes($NodeTable, $Path, $PathIndex)
                    }
                }
                elseif ($this -is [PSMapNode]) {
                    $Found = $False
                    $ChildNodes = $this.get_ChildNodes()
                    foreach ($Node in $ChildNodes) {
                        if ($Entry.Value -eq $Node.Name -and (-not $Equals -or ($Node -is [PSLeafNode] -and $Equals -eq $Node._Value))) {
                            $Found = $True
                            if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) }
                            else { $NodeTable[$Node.getPathName()] = $Node }
                        }
                    }
                    if (-not $Found -and $Entry.Key -eq 'Descendant') {
                        foreach ($Node in $ChildNodes) {
                            $Node.CollectNodes($NodeTable, $Path, $PathIndex)
                        }
                    }
                }
            }
        }
    }

    [Object] GetNode([XdnPath]$Path) {
        $NodeTable = [system.collections.generic.dictionary[String, PSNode]]::new() # Case sensitive (case insensitive map nodes use the same name)
        $this.CollectNodes($NodeTable, $Path, 0)
        if ($NodeTable.Count -eq 0) { return @() }
        if ($NodeTable.Count -eq 1) { return $NodeTable[$NodeTable.Keys] }
        else                        { return [PSNode[]]$NodeTable.Values }
    }

    [String] ToExpression() { return [PSSerialize]$this }
}

Class PSLeafNode : PSNode {

    hidden PSLeafNode($Object) {
        if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object }
    }

    [Int]GetHashCode($CaseSensitive) {
        if ($Null -eq $this._Value) { return '$Null'.GetHashCode() }
        if ($CaseSensitive) { return $this._Value.GetHashCode() }
        else {
            if ($this._Value -is [String]) { return $this._Value.ToUpper().GetHashCode() } # Windows PowerShell doesn't have a System.HashCode struct
            else { return $this._Value.GetHashCode() }
        }
    }

    [string]ToString() { return $this.ToString($false) }
    [string]ToString([Bool]$IsChild) {
        # $MaximumStringLength = if ($IsChild) { [PSNode]::MaximumNodeStringLength } else { [PSNode]::MaximumChildStringLength }
        return $Null

    }
}

Class PSCollectionNode : PSNode {
    hidden static PSCollectionNode() { Use-ClassAccessors }
    hidden [Dictionary[bool,int]]$_HashCode # Unlike the value HashCode, the default (bool = $false) node HashCode is case insensitive
    hidden [Dictionary[bool,int]]$_ReferenceHashCode # if changed, recalculate the (bool = case sensitive) node's HashCode

    hidden [bool]MaxDepthReached() {
        # Check whether the max depth has been reached.
        # Warn if it has, but suppress the warning if
        # it took less then 5 seconds since the last
        # time it reached the max depth.
        $MaxDepthReached = $this.Depth -ge $this.RootNode._MaxDepth
        if ($MaxDepthReached) {
            if (([Datetime]::Now - $this.RootNode.MaxDepthWarningTime).TotalSeconds -gt 5) {
                Write-Warning "$($this.Path) reached the maximum depth of $($this.RootNode._MaxDepth)."
            }
            $this.RootNode.MaxDepthWarningTime = [Datetime]::Now
        }
        return $MaxDepthReached
    }

    hidden WarnSelector ([PSCollectionNode]$Node, [String]$Name) {
        if ($Node -is [PSListNode]) {
            $SelectionName  = "'$Name'"
            $CollectionType = 'list'
        }
        else {
            $SelectionName  = "[$Name]"
            $CollectionType = 'list'
        }
        Write-Warning "Expected $SelectionName to be a $CollectionType selector for: <Object>$($Node.Path)"
    }

    hidden [List[Ast]] GetAstSelectors ($Ast) {
        $List = [List[Ast]]::new()
        if ($Ast -isnot [Ast]) {
            $Ast = [Parser]::ParseInput("`$_$Ast", [ref]$Null, [ref]$Null)
            $Ast = $Ast.EndBlock.Statements.PipeLineElements.Expression
        }
        if ($Ast -is [IndexExpressionAst]) {
            $List.AddRange($this.GetAstSelectors($Ast.Target))
            $List.Add($Ast)
        }
        elseif ($Ast -is [MemberExpressionAst]) {
            $List.AddRange($this.GetAstSelectors($Ast.Expression))
            $List.Add($Ast)
        }
        elseif ($Ast.Extent.Text -ne '$_') {
            Throw "Parse error: $($Ast.Extent.Text)"
        }
        return $List
    }

    [List[PSNode]]GetNodeList($Levels, [PSNodeOrigin]$NodeOrigin, [Bool]$Leaf) {
        $NodeList = [List[PSNode]]::new()
        $this.CollectChildNodes($NodeList, $Levels, $NodeOrigin, $Leaf)
        return $NodeList
    }
    [List[PSNode]]GetNodeList()                                       { return $this.GetNodeList(0, 0, $False) }
    [List[PSNode]]GetNodeList([Int]$Levels)                           { return $this.GetNodeList($Levels, 0, $False) }
    [List[PSNode]]GetNodeList([PSNodeOrigin]$NodeOrigin, [Bool]$Leaf) { return $this.GetNodeList(0, $NodeOrigin, $Leaf) }

    hidden [PSNode[]]get_ChildNodes() {
        # There is no link between the 'ChildNodes' cache and the 'GetChildNode()' function
        # because the comparer of the contained value collection is unknown and
        # it is too expensive and ambiguous to linear search for the actual/original key name.
        # see: https://stackoverflow.com/a/78656228/1701026
        if (-not $this.Cache.ContainsKey('ChildNodes')) { $this.Cache['ChildNodes'] = [PSNode[]]$this.GetNodeList(0, 0, $False) }
        return $this.Cache['ChildNodes']
    }

    #hidden [PSNode[]]get_ChildNodes() { return $this.GetNodeList(0, 0, $False) }
    hidden [PSNode[]]get_ListChildNodes()  { return $this.GetNodeList(0, 'List', $False) }
    hidden [PSNode[]]get_MapChildNodes()   { return $this.GetNodeList(0, 'Map',  $False) }
    hidden [PSNode[]]get_DescendantNodes() { return $this.GetNodeList(-1, 0, $False) }
    hidden [PSNode[]]get_LeafNodes()       { return $this.GetNodeList(-1, 0, $True) }
    # hidden [PSNode]_($Name) { return $this.GetChildNode($Name) } # CLI Shorthand ("alias") for GetChildNode (don't use in scripts)
    # hidden [Object]Get($Path) { return $this.GetDescendantNode($Path) } # CLI Shorthand ("alias") for GetDescendantNode (don't use in scripts)

    Sort() { $this.Sort($Null, 0) }
    Sort([ObjectComparison]$ObjectComparison) { $this.Sort($Null, $ObjectComparison) }
    Sort([String[]]$PrimaryKey) { $this.Sort($PrimaryKey, 0) }
    Sort([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.Sort($PrimaryKey, $ObjectComparison) }
    Sort([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) {
        # As the child nodes are sorted first, we just do a side-by-side node compare:
        $ObjectComparison = $ObjectComparison -bor [ObjectComparison]'MatchMapOrder'
        $ObjectComparison = $ObjectComparison -band (-1 - [ObjectComparison]'IgnoreListOrder')
        $PSListNodeComparer = [PSListNodeComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison }
        $PSMapNodeComparer = [PSMapNodeComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison }
        $this.SortRecurse($PSListNodeComparer, $PSMapNodeComparer)
    }

    hidden SortRecurse([PSListNodeComparer]$PSListNodeComparer, [PSMapNodeComparer]$PSMapNodeComparer) {
        $NodeList = $this.GetNodeList()
        foreach ($Node in $NodeList) {
            if ($Node -is [PSCollectionNode]) { $Node.SortRecurse($PSListNodeComparer, $PSMapNodeComparer) }
        }
        if ($this -is [PSListNode]) {
            $NodeList.Sort($PSListNodeComparer)
            if ($NodeList.Count) { $this._Value = @($NodeList.Value) } else { $this._Value = @() }
        }
        else { # if ($Node -is [PSMapNode])
            $NodeList.Sort($PSMapNodeComparer)
            $Properties = [System.Collections.Specialized.OrderedDictionary]::new([StringComparer]::Ordinal)
            foreach($ChildNode in $NodeList) { $Properties[[Object]$ChildNode.Name] = $ChildNode.Value } # [Object] forces a key rather than an index (ArgumentOutOfRangeException)
            if ($this -is [PSObjectNode]) { $this._Value = [PSCustomObject]$Properties } else { $this._Value = $Properties }
        }
    }
}

Class PSListNode : PSCollectionNode {
    hidden static PSListNode() { Use-ClassAccessors }

    hidden PSListNode($Object) {
        if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object }
    }

    hidden [Object]get_Count() {
        return $this._Value.get_Count()
    }

    hidden [Object]get_Names() {
        if ($this._Value.Length) { return ,@(0..($this._Value.Length - 1)) }
        return ,@()
    }

    hidden [Object]get_Values() {
        return ,@($this._Value)
    }

    [Bool]Contains($Index) {
       return $Index -ge 0 -and $Index -lt $this.get_Count()
    }

    [Object]GetItem($Index) {
            return $this._Value[$Index]
    }

    SetItem($Index, $Value) {
        $this._Value[$Index] = $Value
    }

    hidden CollectChildNodes($NodeList, [Int]$Levels, [PSNodeOrigin]$NodeOrigin, [Bool]$Leaf) {
        if (-not $this.MaxDepthReached()) {
            for ($Index = 0; $Index -lt $this._Value.get_Count(); $Index++) {
                $Node = $this.Append($this._Value[$Index])
                $Node._Name = $Index
                if ($NodeOrigin -in 0, 'List' -and (-not $Leaf -or $Node -is [PSLeafNode])) { $NodeList.Add($Node) }
                if ($Node -is [PSCollectionNode] -and ($Levels -ne 0 -or $NodeOrigin -eq 'Map')) { # $NodeOrigin -eq 'Map' --> Member Access Enumeration
                    $Levels_1 = if ($Levels -gt 0) { $Levels - 1 } else { $Levels }
                    $Node.CollectChildNodes($NodeList, $Levels_1, $NodeOrigin, $Leaf)
                }
            }
        }
    }

    [Object]GetChildNode([Int]$Index) {
        if ($this.MaxDepthReached()) { return $Null }
        $Count = $this._Value.get_Count()
        if ($Index -lt -$Count -or $Index -ge $Count) {
            throw "The <Object>$($this.Path) doesn't contain a child index: $Index"
        }
        $Node = $this.Append($this._Value[$Index])
        $Node._Name = $Index
        return $Node
    }

    [Int]GetHashCode($CaseSensitive) {
        # The hash of a list node is equal if all items match the order and the case.
        # The primary keys and the list type are not relevant
        if ($null -eq $this._HashCode) {
            $this._HashCode = [Dictionary[bool,int]]::new()
            $this._ReferenceHashCode = [Dictionary[bool,int]]::new()
        }
        $ReferenceHashCode = $This._value.GetHashCode()
        if (-not $this._ReferenceHashCode.ContainsKey($CaseSensitive) -or $this._ReferenceHashCode[$CaseSensitive] -ne $ReferenceHashCode) {
            $this._ReferenceHashCode[$CaseSensitive] = $ReferenceHashCode
            $HashCode = '@()'.GetHashCode() # Empty lists have a common hash that is not 0
            $Index = 0
            foreach ($Node in $this.GetNodeList()) {
                $HashCode = $HashCode -bxor "$Index.$($Node.GetHashCode($CaseSensitive))".GetHashCode()
                $index++
            }
            $this._HashCode[$CaseSensitive] = $HashCode
        }
        return $this._HashCode[$CaseSensitive]
    }
}

Class PSMapNode : PSCollectionNode {
    hidden static PSMapNode() { Use-ClassAccessors }

    [Int]GetHashCode($CaseSensitive) {
        # The hash of a map node is equal if all names and items match the order and the case.
        # The map type is not relevant
        if ($null -eq $this._HashCode) {
            $this._HashCode = [Dictionary[bool,int]]::new()
            $this._ReferenceHashCode = [Dictionary[bool,int]]::new()
        }
        $ReferenceHashCode = $This._value.GetHashCode()
        if (-not $this._ReferenceHashCode.ContainsKey($CaseSensitive) -or $this._ReferenceHashCode[$CaseSensitive] -ne $ReferenceHashCode) {
            $this._ReferenceHashCode[$CaseSensitive] = $ReferenceHashCode
            $HashCode = '@{}'.GetHashCode() # Empty maps have a common hash that is not 0
            $Index = 0
            foreach ($Node in $this.GetNodeList()) {
                $Name = if ($CaseSensitive) { $Node._Name } else { $Node._Name.ToUpper() }
                $HashCode = $HashCode -bxor "$Index.$Name=$($Node.GetHashCode())".GetHashCode()
                $Index++
            }
            $this._HashCode[$CaseSensitive] = $HashCode
        }
        return $this._HashCode[$CaseSensitive]
    }
}

Class PSDictionaryNode : PSMapNode {
    hidden static PSDictionaryNode() { Use-ClassAccessors }

    hidden PSDictionaryNode($Object) {
        if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object }
    }

    hidden [Object]get_Count() {
        return $this._Value.get_Count()
    }

    hidden [Object]get_Names() {
        return ,$this._Value.get_Keys()
    }

    hidden [Object]get_Values() {
        return ,$this._Value.get_Values()
    }

    [Bool]Contains($Key) {
        return $this._Value.Contains($Key)
    }

    [Object]GetItem($Key) {
        return $this._Value[$Key]
    }

    SetItem($Key, $Value) {
        $this._Value[$Key] = $Value
    }

    hidden CollectChildNodes($NodeList, [Int]$Levels, [PSNodeOrigin]$NodeOrigin, [Bool]$Leaf) {
        if (-not $this.MaxDepthReached()) {
            foreach($Key in $this._Value.get_Keys()) {
                $Node = $this.Append($this._Value[$Key])
                $Node._Name = $Key
                if ($NodeOrigin -in 0, 'Map' -and (-not $Leaf -or $Node -is [PSLeafNode])) { $NodeList.Add($Node) }
                if ($Node -is [PSCollectionNode] -and ($Levels -ne 0 -or $NodeOrigin -eq 'List')) {
                    $Levels_1 = if ($Levels -gt 0) { $Levels - 1 } else { $Levels }
                    $Node.CollectChildNodes($NodeList, $Levels_1, $NodeOrigin, $Leaf)
                }
            }
        }
    }

    [Object]GetChildNode($Key) {
        if ($this.MaxDepthReached()) { return $Null }
        if (-not $this._Value.Contains($Key)) { Throw "The <Object>$($this.Path) doesn't contain a child named: $Key" }
        $Node = $this.Append($this._Value[$Key])
        $Node._Name = $Key
        return $Node
    }
}

Class PSObjectNode : PSMapNode {
    hidden static PSObjectNode() { Use-ClassAccessors }

    hidden PSObjectNode($Object) {
        if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object }
    }

    hidden [Object]get_Count() {
        return @($this._Value.PSObject.Properties).get_Count()
    }

    hidden [Object]get_Names() {
        return ,$this._Value.PSObject.Properties.Name
    }

    hidden [Object]get_Values() {
        return ,$this._Value.PSObject.Properties.Value
    }

    [Bool]Contains($Name) {
        return $this._Value.PSObject.Properties[$Name]
    }

    [Object]GetItem($Name) {
        return $this._Value.PSObject.Properties[$Name].Value
    }

    SetItem($Name, $Value) {
        $this._Value.PSObject.Properties[$Name].Value = $Value
    }

    hidden CollectChildNodes($NodeList, [Int]$Levels, [PSNodeOrigin]$NodeOrigin, [Bool]$Leaf) {
        if (-not $this.MaxDepthReached()) {
            foreach($Property in $this._Value.PSObject.Properties) {
                if ($Property.Value -is [Reflection.MemberInfo]) { continue }
                $Node = $this.Append($Property.Value)
                $Node._Name = $Property.Name
                if ($NodeOrigin -in 0, 'Map' -and (-not $Leaf -or $Node -is [PSLeafNode])) { $NodeList.Add($Node) }
                if ($Node -is [PSCollectionNode] -and ($Levels -ne 0 -or $NodeOrigin -eq 'List')) {
                    $Levels_1 = if ($Levels -gt 0) { $Levels - 1 } else { $Levels }
                    $Node.CollectChildNodes($NodeList, $Levels_1, $NodeOrigin, $Leaf)
                }
            }
        }
    }

    [Object]GetChildNode([String]$Name) {
        if ($this.MaxDepthReached()) { return $Null }
        if ($Name -NotIn $this._Value.PSObject.Properties.Name) {
            Throw "The <Object>$($this.Path) doesn't contain a child named: $Name"
        }
        $Node = $this.Append($this._Value.PSObject.Properties[$Name].Value)
        $Node._Name = $Name
        return $Node
    }
}


Update-TypeData -TypeName PSNode -DefaultDisplayPropertySet Path, Name, Depth, Value -Force


# ___ _ _ _ ____
# / _ \| |__ (_) ___ ___| |_ / ___|___ _ __ ___ _ __ __ _ _ __ ___ _ __
# | | | | '_ \| |/ _ \/ __| __| | / _ \| '_ ` _ \| '_ \ / _` | '__/ _ \ '__|
# | |_| | |_) | | __/ (__| |_| |__| (_) | | | | | | |_) | (_| | | | __/ |
# \___/|_.__// |\___|\___|\__|\____\___/|_| |_| |_| .__/ \__,_|_| \___|_|
# |__/ |_|

enum ObjectCompareMode {
    Equals  # https://learn.microsoft.com/dotnet/api/system.object.equals
    Compare # https://learn.microsoft.com/dotnet/api/system.string.compareto
    Report  # Returns a report with discrepancies
}

[Flags()] enum ObjectComparison { MatchCase = 1; MatchType = 2; IgnoreListOrder = 4; MatchMapOrder = 8; Descending = 128 }

class ObjectComparer {

    # Report properties (column names)
    [String]$Name1 = 'Reference'
    [String]$Name2 = 'InputObject'
    [String]$Issue = 'Discrepancy'

    [String[]]$PrimaryKey
    [ObjectComparison]$ObjectComparison

    [Collections.Generic.List[Object]]$Differences

    ObjectComparer () {}
    ObjectComparer ([String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey }
    ObjectComparer ([ObjectComparison]$ObjectComparison) { $this.ObjectComparison = $ObjectComparison }
    ObjectComparer ([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison }
    ObjectComparer ([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison }

    [bool] IsEqual ($Object1, $Object2) { return $this.Compare($Object1, $Object2, 'Equals') }
    [int] Compare  ($Object1, $Object2) { return $this.Compare($Object1, $Object2, 'Compare') }
    [Object] Report ($Object1, $Object2) {
        $this.Differences = [Collections.Generic.List[Object]]::new()
        $null = $this.Compare($Object1, $Object2, 'Report')
        return $this.Differences
    }

    [Object] Compare($Object1, $Object2, [ObjectCompareMode]$Mode) {
        if ($Object1 -is [PSNode]) { $Node1 = $Object1 } else { $Node1 = [PSNode]::ParseInput($Object1) }
        if ($Object2 -is [PSNode]) { $Node2 = $Object2 } else { $Node2 = [PSNode]::ParseInput($Object2) }
        return $this.CompareRecurse($Node1, $Node2, $Mode)
    }

    hidden [Object] CompareRecurse([PSNode]$Node1, [PSNode]$Node2, [ObjectCompareMode]$Mode) {
        $Comparison = $this.ObjectComparison
        $MatchCase = $Comparison -band 'MatchCase'
        $EqualType = $true

        if ($Mode -ne 'Compare') { # $Mode -ne 'Compare'
            if ($MatchCase -and $Node1.ValueType -ne $Node2.ValueType) {
                if ($Mode -eq 'Equals') { return $false } else { # if ($Mode -eq 'Report')
                    $this.Differences.Add([PSCustomObject]@{
                        Path        = $Node2.Path
                        $this.Issue = 'Type'
                        $this.Name1 = $Node1.ValueType
                        $this.Name2 = $Node2.ValueType
                    })
                }
            }
            if ($Node1 -is [PSCollectionNode] -and $Node2 -is [PSCollectionNode] -and $Node1.Count -ne $Node2.Count) {
                if ($Mode -eq 'Equals') { return $false } else { # if ($Mode -eq 'Report')
                    $this.Differences.Add([PSCustomObject]@{
                        Path        = $Node2.Path
                        $this.Issue = 'Size'
                        $this.Name1 = $Node1.Count
                        $this.Name2 = $Node2.Count
                    })
                }
            }
        }

        if ($Node1 -is [PSLeafNode] -and $Node2 -is [PSLeafNode]) {
            $Eq = if ($MatchCase) { $Node1.Value -ceq $Node2.Value } else { $Node1.Value -eq $Node2.Value }
            Switch ($Mode) {
                Equals    { return $Eq }
                Compare {
                    if ($Eq) { return 1 - $EqualType } # different types results in 1 (-gt)
                    else {
                        $Greater = if ($MatchCase) { $Node1.Value -cgt $Node2.Value } else { $Node1.Value -gt $Node2.Value }
                        if ($Greater -xor $Comparison -band 'Descending') { return 1 } else { return -1 }
                    }
                }
                default {
                    if (-not $Eq) {
                        $this.Differences.Add([PSCustomObject]@{
                            Path        = $Node2.Path
                            $this.Issue = 'Value'
                            $this.Name1 = $Node1.Value
                            $this.Name2 = $Node2.Value
                        })
                    }
                }
            }
        }
        elseif ($Node1 -is [PSListNode] -and $Node2 -is [PSListNode]) {
            $MatchOrder = -not ($Comparison -band 'IgnoreListOrder')
            # if ($Node1.GetHashCode($MatchCase) -eq $Node2.GetHashCode($MatchCase)) {
            # if ($Mode -eq 'Equals') { return $true } else { return 0 } # Report mode doesn't care about the output
            # }
            $Items1 = $Node1.ChildNodes
            $Items2 = $Node2.ChildNodes
            if ($Items1.Count) { $Indices1 = [Collections.Generic.List[Int]]$Items1.Name } else { $Indices1 = @() }
            if ($Items2.Count) { $Indices2 = [Collections.Generic.List[Int]]$Items2.Name } else { $Indices2 = @() }
            if ($this.PrimaryKey) {
                $Maps2 = [Collections.Generic.List[Int]]$Items2.where{ $_ -is [PSMapNode] }.Name
                if ($Maps2.Count) {
                    $Maps1 = [Collections.Generic.List[Int]]$Items1.where{ $_ -is [PSMapNode] }.Name
                    if ($Maps1.Count) {
                        foreach ($Key in $this.PrimaryKey) {
                            foreach($Index2 in @($Maps2)) {
                                $Item2 = $Items2[$Index2]
                                foreach ($Index1 in $Maps1) {
                                    $Item1 = $Items1[$Index1]
                                    if ($Item1.GetItem($Key) -eq $Item2.GetItem($Key)) {
                                        if ($this.CompareRecurse($Item1, $Item2, 'Equals')) {
                                            $null = $Maps2.Remove($Index2)
                                            $Null = $Maps1.Remove($Index1)
                                            $null = $Indices2.Remove($Index2)
                                            $Null = $Indices1.Remove($Index1)
                                            break # Only match the first primary key
                                        }
                                    }
                                }
                            }
                        }
                        # in case of any single maps leftover without primary keys
                        if($Maps2.Count -eq 1 -and $Maps1.Count -eq 1) {
                            $Item2 = $Items2[$Maps2[0]]
                            $Item1 = $Items1[$Maps1[0]]
                            $Compare = $this.CompareRecurse($Item1, $Item2, $Mode)
                            Switch ($Mode) {
                                Equals  { if (-not $Compare) { return $Compare } }
                                Compare { if ($Compare)      { return $Compare } }
                                Default {
                                    $Maps2.Clear()
                                    $Maps1.Clear()
                                    $null = $Indices2.Remove($Maps2[0])
                                    $Null = $Indices1.Remove($Maps1[0])
                                }
                            }
                        }
                    }
                }
            }
            if (-not $MatchOrder) { # remove the equal nodes from the lists
                foreach($Index2 in @($Indices2)) {
                    $Item2 = $Items2[$Index2]
                    foreach ($Index1 in $Indices1) {
                        $Item1 = $Items1[$Index1]
                        if ($this.CompareRecurse($Item1, $Item2, 'Equals')) {
                            $null = $Indices2.Remove($Index2)
                            $Null = $Indices1.Remove($Index1)
                            break # Only match a single node
                        }
                    }
                }
            }
            for ($i = 0; $i -lt [math]::max($Indices2.Count, $Indices1.Count); $i++) {
                $Index1 = if ($i -lt $Indices1.Count) { $Indices1[$i] }
                $Index2 = if ($i -lt $Indices2.Count) { $Indices2[$i] }
                $Item1  = if ($Null -ne $Index1) { $Items1[$Index1] }
                $Item2  = if ($Null -ne $Index2) { $Items2[$Index2] }
                if ($Null -eq $Item1) {
                    Switch ($Mode) {
                        Equals  { return $false }
                        Compare { return -1 } # None existing items can't be ordered
                        default {
                            $this.Differences.Add([PSCustomObject]@{
                                Path        = $Node2.Path + "[$Index2]"
                                $this.Issue = 'Exists'
                                $this.Name1 = $Null
                                $this.Name2 = if ($Item2 -is [PSLeafNode]) { "$($Item2.Value)" } else { "[$($Item2.ValueType)]" }
                            })
                        }
                    }
                }
                elseif ($Null -eq $Item2) {
                    Switch ($Mode) {
                        Equals  { return $false }
                        Compare { return 1 } # None existing items can't be ordered
                        default {
                            $this.Differences.Add([PSCustomObject]@{
                                Path        = $Node1.Path + "[$Index1]"
                                $this.Issue = 'Exists'
                                $this.Name1 = if ($Item1 -is [PSLeafNode]) { "$($Item1.Value)" } else { "[$($Item1.ValueType)]" }
                                $this.Name2 = $Null
                            })
                        }
                    }
                }
                else {
                    $Compare = $this.CompareRecurse($Item1, $Item2, $Mode)
                    if (($Mode -eq 'Equals' -and -not $Compare) -or ($Mode -eq 'Compare' -and $Compare)) { return $Compare }
                }
            }
            if ($Mode -eq 'Equals') { return $true } elseif ($Mode -eq 'Compare') { return 0 } else { return $null }
        }
        elseif ($Node1 -is [PSMapNode] -and $Node2 -is [PSMapNode]) {
            $MatchOrder = [Bool]($Comparison -band 'MatchMapOrder')
            if ($MatchOrder -and $Node1._Value -isnot [HashTable] -and $Node2._Value -isnot [HashTable]) {
                $Items2 = $Node2.ChildNodes
                $Index = 0
                foreach ($Item1 in $Node1.ChildNodes) {
                    if ($Index -lt $Items2.Count) { $Item2 = $Items2[$Index++] } else { break }
                    $EqualName = if ($MatchCase) { $Item1.Name -ceq $Item2.Name } else { $Item1.Name -eq $Item2.Name }
                    if ($EqualName) {
                        $Compare = $this.CompareRecurse($Item1, $Item2, $Mode)
                        if (($Mode -eq 'Equals' -and -not $Compare) -or ($Mode -eq 'Compare' -and $Compare)) { return $Compare }
                    }
                    else {
                        Switch ($Mode) {
                            Equals  { return $false }
                            Compare {} # The order depends on the child name and value
                            default {
                                $this.Differences.Add([PSCustomObject]@{
                                    Path        = $Item1.Path
                                    $this.Issue = 'Name'
                                    $this.Name1 = $Item1.Name
                                    $this.Name2 = $Item2.Name
                                })
                            }
                        }
                    }
                }
            }
            else {
                $Found = [HashTable]::new() # (Case sensitive)
                foreach ($Item2 in $Node2.ChildNodes) {
                    if ($Node1.Contains($Item2.Name)) {
                        $Item1 = $Node1.GetChildNode($Item2.Name) # Left defines the comparer
                        $Found[$Item1.Name] = $true
                        $Compare = $this.CompareRecurse($Item1, $Item2, $Mode)
                        if (($Mode -eq 'Equals' -and -not $Compare) -or ($Mode -eq 'Compare' -and $Compare)) { return $Compare }
                    }
                    else {
                        Switch ($Mode) {
                            Equals  { return $false }
                            Compare { return -1 }
                            default {
                                $this.Differences.Add([PSCustomObject]@{
                                    Path        = $Item2.Path
                                    $this.Issue = 'Exists'
                                    $this.Name1 = $false
                                    $this.Name2 = $true
                                })
                            }
                        }
                    }
                }
                $Node1.Names.foreach{
                    if (-not $Found.Contains($_)) {
                        Switch ($Mode) {
                            Equals  { return $false }
                            Compare { return 1 }
                            default {
                                $this.Differences.Add([PSCustomObject]@{
                                    Path        = $Node1.GetChildNode($_).Path
                                    $this.Issue = 'Exists'
                                    $this.Name1 = $true
                                    $this.Name2 = $false
                                })
                            }
                        }
                    }
                }
            }
            if ($Mode -eq 'Equals') { return $true } elseif ($Mode -eq 'Compare') { return 0 } else { return $null }
        }
        else { # Different structure
            Switch ($Mode) {
                Equals  { return $false }
                Compare { # Structure order: PSLeafNode - PSListNode - PSMapNode (can't be reversed)
                    if ($Node1 -is [PSLeafNode] -or $Node2 -isnot [PSMapNode] ) { return -1 } else { return 1 }
                }
                default {
                    $this.Differences.Add([PSCustomObject]@{
                        Path        = $Node1.Path
                        $this.Issue = 'Structure'
                        $this.Name1 = $Node1.ValueType.Name
                        $this.Name2 = $Node2.ValueType.Name
                    })
                }
            }
        }
        if ($Mode -eq 'Equals')  { throw 'Equals comparison should have returned boolean.' }
        if ($Mode -eq 'Compare') { throw 'Compare comparison should have returned integer.' }
        return $null
    }
}

class PSListNodeComparer : ObjectComparer, IComparer[Object] { # https://github.com/PowerShell/PowerShell/issues/23959
    PSListNodeComparer () {}
    PSListNodeComparer ([String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey }
    PSListNodeComparer ([ObjectComparison]$ObjectComparison) { $this.ObjectComparison = $ObjectComparison }
    PSListNodeComparer ([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison }
    PSListNodeComparer ([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison }
    [int] Compare ([Object]$Node1, [Object]$Node2) { return $this.CompareRecurse($Node1, $Node2, 'Compare') }
}

class PSMapNodeComparer : IComparer[Object] {
    [String[]]$PrimaryKey
    [ObjectComparison]$ObjectComparison

    PSMapNodeComparer () {}
    PSMapNodeComparer ([String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey }
    PSMapNodeComparer ([ObjectComparison]$ObjectComparison) { $this.ObjectComparison = $ObjectComparison }
    PSMapNodeComparer ([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison }
    PSMapNodeComparer ([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison }
    [int] Compare ([Object]$Node1, [Object]$Node2) {
        $Comparison = $this.ObjectComparison
        $MatchCase = $Comparison -band 'MatchCase'
        $Equal = if ($MatchCase) { $Node1.Name -ceq $Node2.Name } else { $Node1.Name -eq $Node2.Name }
        if ($Equal) { return 0 }
        else {
            if ($this.PrimaryKey) {                                           # Primary keys take always priority
                if ($this.PrimaryKey -eq $Node1.Name) { return -1 }
                if ($this.PrimaryKey -eq $Node2.Name) { return 1 }
            }
            $Greater = if ($MatchCase) { $Node1.Name -cgt $Node2.Name } else { $Node1.Name -gt $Node2.Name }
            if ($Greater -xor $Comparison -band 'Descending') { return 1 } else { return -1 }
        }
    }
}