Private/Compare-TLValue.ps1

function Compare-TLValue {
    <#
    .SYNOPSIS
        Structural diff of two normalized data trees.
    .DESCRIPTION
        Emits change records with the exact property path. Arrays of objects
        are matched by key (id/domain/skuPartNumber) so items are reported as
        Added/Removed while matched items are compared property by property.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Reference,

        [AllowNull()]
        [object]$Difference,

        [string]$Path = '',

        [AllowNull()]
        [string]$Label = $null
    )

    if ($null -eq $Reference -and $null -eq $Difference) { return }

    function Format-TLValueText {
        param([AllowNull()][object]$Value)
        if ($null -eq $Value) { return $null }
        if ($Value -is [string]) { return $Value }
        if ($Value -is [System.Collections.IDictionary]) {
            return (ConvertTo-Json -InputObject $Value -Compress -Depth 15)
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $items = @($Value)
            if ($items.Count -eq 0) { return $null }  # empty array == absent
            return (ConvertTo-Json -InputObject $items -Compress -Depth 15)
        }
        return [string]$Value
    }

    function Get-TLObjectLabel {
        param([object]$Item, [string]$Fallback)
        if ($Item -is [System.Collections.IDictionary]) {
            foreach ($key in @('displayName', 'domain', 'name', '_tlPrincipalName', 'userPrincipalName', 'skuPartNumber', 'id')) {
                if ($Item.Contains($key) -and $Item[$key]) { return [string]$Item[$key] }
            }
        }
        return $Fallback
    }

    # --- dictionaries: recurse over the union of keys -------------------------
    if ($Reference -is [System.Collections.IDictionary] -and $Difference -is [System.Collections.IDictionary]) {
        $keys = @(@($Reference.Keys) + @($Difference.Keys) | ForEach-Object { [string]$_ } | Sort-Object -Unique)
        foreach ($key in $keys) {
            $referenceValue = $(if ($Reference.Contains($key)) { $Reference[$key] } else { $null })
            $differenceValue = $(if ($Difference.Contains($key)) { $Difference[$key] } else { $null })
            $childPath = $(if ($Path) { "$Path.$key" } else { $key })
            Compare-TLValue -Reference $referenceValue -Difference $differenceValue -Path $childPath -Label $Label
        }
        return
    }

    # --- arrays ---------------------------------------------------------------
    $referenceIsArray = ($Reference -is [System.Collections.IEnumerable]) -and ($Reference -isnot [string]) -and ($Reference -isnot [System.Collections.IDictionary])
    $differenceIsArray = ($Difference -is [System.Collections.IEnumerable]) -and ($Difference -isnot [string]) -and ($Difference -isnot [System.Collections.IDictionary])
    if ($referenceIsArray -and $differenceIsArray) {
        $referenceItems = @($Reference)
        $differenceItems = @($Difference)
        $probe = @($referenceItems + $differenceItems) | Where-Object { $null -ne $_ } | Select-Object -First 1

        $keyName = $null
        if ($probe -is [System.Collections.IDictionary]) {
            foreach ($candidate in @('id', 'domain', 'skuPartNumber')) {
                if ($probe.Contains($candidate)) { $keyName = $candidate; break }
            }
        }

        if ($keyName) {
            $referenceById = [ordered]@{}
            foreach ($item in $referenceItems) { $referenceById[[string]$item[$keyName]] = $item }
            $differenceById = [ordered]@{}
            foreach ($item in $differenceItems) { $differenceById[[string]$item[$keyName]] = $item }

            foreach ($itemKey in $referenceById.Keys) {
                if (-not $differenceById.Contains($itemKey)) {
                    [pscustomobject]@{
                        ChangeType = 'Removed'
                        Path       = $Path
                        Label      = Get-TLObjectLabel -Item $referenceById[$itemKey] -Fallback $itemKey
                        ObjectId   = $itemKey
                        Before     = Get-TLObjectLabel -Item $referenceById[$itemKey] -Fallback $itemKey
                        After      = $null
                    }
                }
            }
            foreach ($itemKey in $differenceById.Keys) {
                if (-not $referenceById.Contains($itemKey)) {
                    [pscustomobject]@{
                        ChangeType = 'Added'
                        Path       = $Path
                        Label      = Get-TLObjectLabel -Item $differenceById[$itemKey] -Fallback $itemKey
                        ObjectId   = $itemKey
                        Before     = $null
                        After      = Get-TLObjectLabel -Item $differenceById[$itemKey] -Fallback $itemKey
                    }
                }
            }
            foreach ($itemKey in $referenceById.Keys) {
                if ($differenceById.Contains($itemKey)) {
                    $itemLabel = Get-TLObjectLabel -Item $differenceById[$itemKey] -Fallback $itemKey
                    Compare-TLValue -Reference $referenceById[$itemKey] -Difference $differenceById[$itemKey] -Path $Path -Label $itemLabel
                }
            }
            return
        }

        # scalar or unkeyed arrays: compare as a whole
        $referenceText = Format-TLValueText -Value $referenceItems
        $differenceText = Format-TLValueText -Value $differenceItems
        if ($referenceText -cne $differenceText) {
            [pscustomobject]@{
                ChangeType = 'Changed'
                Path       = $Path
                Label      = $Label
                ObjectId   = $null
                Before     = $referenceText
                After      = $differenceText
            }
        }
        return
    }

    # --- scalars / mixed types ------------------------------------------------
    $referenceText = Format-TLValueText -Value $Reference
    $differenceText = Format-TLValueText -Value $Difference
    if ($referenceText -cne $differenceText) {
        [pscustomobject]@{
            ChangeType = 'Changed'
            Path       = $Path
            Label      = $Label
            ObjectId   = $null
            Before     = $referenceText
            After      = $differenceText
        }
    }
}