Private/Report.Model.ps1

function Get-UTCMObjectField {
    # Returns the first non-null value among the candidate names, for hashtables and objects alike.
    [CmdletBinding()]
    param([AllowNull()] $Object, [Parameter(Mandatory)][string[]] $Name)

    if ($null -eq $Object) { return $null }
    foreach ($n in $Name) {
        if ($Object -is [System.Collections.IDictionary]) {
            if ($Object.Contains($n) -and $null -ne $Object[$n]) { return $Object[$n] }
        } else {
            $p = $Object.PSObject.Properties[$n]
            if ($p -and $null -ne $p.Value) { return $p.Value }
        }
    }
    return $null
}

function Get-UTCMWorkloadFromType {
    # Derives the workload (e.g. 'exchange') from a resource type such as 'microsoft.exchange.sharedmailbox'.
    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string] $Type)

    if (-not [string]::IsNullOrWhiteSpace($Type) -and $Type -match '^microsoft\.([a-z0-9]+)') {
        return $Matches[1]
    }
    return 'unknown'
}

function Format-UTCMJsonPretty {
    # Pretty-prints a JSON string; returns the input unchanged when it is not valid JSON.
    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string] $Json)

    if ([string]::IsNullOrWhiteSpace($Json)) { return '' }
    try {
        $obj = $Json | ConvertFrom-Json -ErrorAction Stop
        if ($null -eq $obj) { return 'null' }
        return ($obj | ConvertTo-Json -Depth 50)
    } catch {
        return $Json
    }
}

function ConvertTo-UTCMUtcDisplay {
    # Formats a date value as 'yyyy-MM-dd HH:mm:ss UTC'; 'n/a' when empty, the raw text when unparseable.
    [CmdletBinding()]
    param([AllowNull()] $Value)

    if ($null -eq $Value -or ([string]$Value).Trim() -eq '') { return 'n/a' }
    $dto = [datetimeoffset]::MinValue
    if ($Value -is [datetimeoffset]) { $dto = $Value }
    elseif ($Value -is [datetime]) { $dto = [datetimeoffset]$Value }
    elseif (-not [datetimeoffset]::TryParse([string]$Value, [cultureinfo]::InvariantCulture,
            [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$dto)) {
        return [string]$Value
    }
    return $dto.UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture) + ' UTC'
}

function ConvertTo-UTCMReportItem {
    # Normalizes a snapshot/current-state item (either 'data' or 'properties'; either 'type' or
    # 'resourceType') to { Id, DisplayName, Type, Workload, Data }.
    [CmdletBinding()]
    param([AllowNull()] $Item)

    $id = $null; $data = $null; $type = $null; $workload = $null; $display = $null
    if ($Item -is [System.Collections.IDictionary]) {
        $id = $Item['id'] ?? $Item['resourceInstanceIdentifier']
        $data = $Item['data'] ?? $Item['properties']
        $type = $Item['type'] ?? $Item['resourceType']
        $workload = $Item['workload']; $display = $Item['displayName']
    } elseif ($null -ne $Item) {
        $p = $Item.PSObject.Properties
        foreach ($n in 'id','resourceInstanceIdentifier') { $m = $p[$n]; if ($m -and $null -ne $m.Value) { $id = $m.Value; break } }
        foreach ($n in 'data','properties') { $m = $p[$n]; if ($m -and $null -ne $m.Value) { $data = $m.Value; break } }
        foreach ($n in 'type','resourceType') { $m = $p[$n]; if ($m -and $null -ne $m.Value) { $type = $m.Value; break } }
        $m = $p['workload']; if ($m) { $workload = $m.Value }
        $m = $p['displayName']; if ($m) { $display = $m.Value }
    }
    if ($null -eq $id -and $null -ne $data) {
        foreach ($n in 'Id','Identity','Guid','ObjectId') {
            $v = Get-UTCMObjectField $data @($n)
            if ($v) { $id = $v; break }
        }
    }
    $type = [string]$type
    $workload = [string]$workload
    if (-not $workload) { $workload = Get-UTCMWorkloadFromType $type }

    [pscustomobject]@{
        Id          = [string]$id
        DisplayName = [string]$display
        Type        = $type
        Workload    = $workload
        Data        = $data
    }
}

function ConvertTo-UTCMScalarText {
    [CmdletBinding()]
    param([AllowNull()] $Value)

    if ($null -eq $Value) { return 'null' }
    if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) }
    if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM-ddTHH:mm:ss.FFFFFFFK', [cultureinfo]::InvariantCulture) }
    if ($Value -is [datetimeoffset]) { return $Value.ToString('yyyy-MM-ddTHH:mm:ss.FFFFFFFK', [cultureinfo]::InvariantCulture) }
    if ($Value -is [System.IFormattable]) { return $Value.ToString($null, [cultureinfo]::InvariantCulture) }
    return [string]$Value
}

function ConvertTo-UTCMFlatProperty {
    # Flattens an object graph into an ordered path -> text map (a.b[0].c). Depth and size are capped.
    [CmdletBinding()]
    param(
        [AllowNull()] $Value,
        [string] $Prefix = '',
        [Parameter(Mandatory)][System.Collections.Specialized.OrderedDictionary] $Into,
        [int] $Depth = 0,
        [int] $MaxEntries = 20000
    )

    if ($Into.Count -ge $MaxEntries) { return }
    $label = if ($Prefix) { $Prefix } else { '(value)' }

    if ($null -eq $Value -or $Value -is [string] -or $Value.GetType().IsPrimitive -or
        $Value -is [decimal] -or $Value -is [datetime] -or $Value -is [datetimeoffset] -or $Value -is [guid] -or $Depth -ge 30) {
        $Into[$label] = ConvertTo-UTCMScalarText $Value
        return
    }

    if ($Value -is [System.Collections.IDictionary]) {
        if ($Value.Count -eq 0) { $Into[$label] = '{}'; return }
        foreach ($k in $Value.Keys) {
            $p = if ($Prefix) { "$Prefix.$k" } else { [string]$k }
            ConvertTo-UTCMFlatProperty -Value $Value[$k] -Prefix $p -Into $Into -Depth ($Depth + 1) -MaxEntries $MaxEntries
        }
        return
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        $i = 0
        foreach ($el in $Value) {
            ConvertTo-UTCMFlatProperty -Value $el -Prefix "$Prefix[$i]" -Into $Into -Depth ($Depth + 1) -MaxEntries $MaxEntries
            $i++
        }
        if ($i -eq 0) { $Into[$label] = '[]' }
        return
    }

    $props = @($Value.PSObject.Properties | Where-Object { $_.MemberType -in 'NoteProperty','Property' })
    if ($props.Count -eq 0) { $Into[$label] = '{}'; return }
    foreach ($prop in $props) {
        $p = if ($Prefix) { "$Prefix.$($prop.Name)" } else { $prop.Name }
        ConvertTo-UTCMFlatProperty -Value $prop.Value -Prefix $p -Into $Into -Depth ($Depth + 1) -MaxEntries $MaxEntries
    }
}

function Get-UTCMPropertyDiff {
    # Compares two JSON documents property by property. Returns $null when either side is not JSON.
    # Rows: { Path, Status (changed|added|removed|unchanged), Baseline, Current }; differences first.
    [CmdletBinding()]
    param([AllowNull()][string] $BaselineJson, [AllowNull()][string] $CurrentJson)

    try {
        $b = if ([string]::IsNullOrWhiteSpace($BaselineJson)) { $null } else { $BaselineJson | ConvertFrom-Json -ErrorAction Stop }
        $c = if ([string]::IsNullOrWhiteSpace($CurrentJson))  { $null } else { $CurrentJson  | ConvertFrom-Json -ErrorAction Stop }
    } catch {
        return $null
    }

    $fb = [ordered]@{}; $fc = [ordered]@{}
    if ($null -ne $b) { ConvertTo-UTCMFlatProperty -Value $b -Into $fb }
    if ($null -ne $c) { ConvertTo-UTCMFlatProperty -Value $c -Into $fc }

    $changed = New-Object System.Collections.Generic.List[object]
    $same    = New-Object System.Collections.Generic.List[object]
    foreach ($k in $fb.Keys) {
        if ($fc.Contains($k)) {
            $row = [pscustomobject]@{ Path = $k; Status = $(if ($fb[$k] -ceq $fc[$k]) { 'unchanged' } else { 'changed' }); Baseline = $fb[$k]; Current = $fc[$k] }
            if ($row.Status -eq 'unchanged') { $same.Add($row) } else { $changed.Add($row) }
        } else {
            $changed.Add([pscustomobject]@{ Path = $k; Status = 'removed'; Baseline = $fb[$k]; Current = $null })
        }
    }
    foreach ($k in $fc.Keys) {
        if (-not $fb.Contains($k)) {
            $changed.Add([pscustomobject]@{ Path = $k; Status = 'added'; Baseline = $null; Current = $fc[$k] })
        }
    }
    # The comma keeps an empty result an (empty) array, so callers can tell it from the $null parse failure
    $out = New-Object System.Collections.Generic.List[object]
    $out.AddRange($changed)
    $out.AddRange($same)
    return ,($out.ToArray())
}

function Get-UTCMItemPreview {
    # Short "key=value" preview of the first few top-level scalar properties.
    [CmdletBinding()]
    param([AllowNull()] $Data, [int] $Max = 3, [int] $ValueLength = 40)

    if ($null -eq $Data -or $Data -is [string] -or $Data.GetType().IsPrimitive) { return '' }
    $pairs = New-Object System.Collections.Generic.List[string]
    $isDict = $Data -is [System.Collections.IDictionary]
    $source = if ($isDict) { $Data.GetEnumerator() } else { $Data.PSObject.Properties }
    foreach ($p in $source) {
        if ($isDict) { $name = [string]$p.Key } else {
            if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { continue }
            $name = $p.Name
        }
        $v = $p.Value
        if ($null -eq $v -or $v -is [string] -or $v.GetType().IsPrimitive -or $v -is [datetime]) {
            $t = ConvertTo-UTCMScalarText $v
            if ($t.Length -gt $ValueLength) { $t = $t.Substring(0, $ValueLength) + '...' }
            $pairs.Add("$name=$t")
            if ($pairs.Count -ge $Max) { break }
        }
    }
    return ($pairs -join '; ')
}

function Get-UTCMCountBy {
    # Counts items by a property and returns { Name, Count } sorted by count (desc) then name.
    [CmdletBinding()]
    param([AllowNull()] $Items, [Parameter(Mandatory)][string] $Property)

    $rows = foreach ($g in (@($Items) | Where-Object { $null -ne $_ } | Group-Object -Property $Property)) {
        [pscustomobject]@{ Name = $(if ([string]::IsNullOrEmpty([string]$g.Name)) { 'unknown' } else { [string]$g.Name }); Count = $g.Count }
    }
    return @($rows | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name)
}

function Get-UTCMSortedReportItem {
    # Orders normalized report items by lowercased name, then id, using ordinal (UTF-16) comparison. The client
    # uses the same comparison for its default view, so server-sorted markup needs no re-sort or DOM move.
    [CmdletBinding()]
    param([AllowNull()] $Items)

    $list = [object[]]@($Items | Where-Object { $null -ne $_ })
    if ($list.Count -le 1) { return $list }
    # [Array]::Sort(keys, items) does not permute the items array when called from PowerShell, so sort
    # the keys alone and carry each item's index at the end of its key.
    $keys = New-Object 'string[]' $list.Count
    for ($i = 0; $i -lt $list.Count; $i++) {
        $keys[$i] = ([string]$list[$i].DisplayName).ToLowerInvariant() + [char]0 + [string]$list[$i].Id + [char]0 + $i.ToString([cultureinfo]::InvariantCulture)
    }
    [Array]::Sort($keys, [System.StringComparer]::Ordinal)
    $out = New-Object 'object[]' $list.Count
    for ($i = 0; $i -lt $keys.Count; $i++) {
        $k = $keys[$i]
        $out[$i] = $list[[int]$k.Substring($k.LastIndexOf([char]0) + 1)]
    }
    return $out
}

function ConvertTo-UTCMDriftRecord {
    # Pairs Compare-Object output into one record per resource.
    # '=>' only -> Added
    # '<=' only -> Missing
    # one '<=' + one '=>' with the same key -> Changed
    # Key is type+id, falling back to type+displayName when id is empty. Keys that appear more than
    # once on a side are ambiguous and are never paired (reported as plain Added/Missing).
    [CmdletBinding()]
    param([AllowNull()] $Diff)

    $rows = @($Diff | Where-Object { $null -ne $_ })
    if ($rows.Count -eq 0) { return @() }

    $entries = for ($i = 0; $i -lt $rows.Count; $i++) {
        $r = $rows[$i]
        $type = [string](Get-UTCMObjectField $r @('type','resourceType'))
        $id   = [string](Get-UTCMObjectField $r @('id'))
        $name = [string](Get-UTCMObjectField $r @('displayName'))
        $key  = if ($id) { "$type|id:$id" } else { "$type|name:$name" }
        [pscustomobject]@{
            Index = $i; Key = $key; Id = $id; DisplayName = $name; Type = $type
            Side = [string](Get-UTCMObjectField $r @('SideIndicator'))
            Normalized = [string](Get-UTCMObjectField $r @('normalizedData'))
        }
    }

    $records = New-Object System.Collections.Generic.List[object]
    $consumed = New-Object System.Collections.Generic.HashSet[int]

    foreach ($group in ($entries | Group-Object Key)) {
        $base = @($group.Group | Where-Object Side -eq '<=')
        $curr = @($group.Group | Where-Object Side -eq '=>')
        if ($base.Count -eq 1 -and $curr.Count -eq 1) {
            $b = $base[0]; $c = $curr[0]
            [void]$consumed.Add($b.Index); [void]$consumed.Add($c.Index)
            $records.Add([pscustomobject]@{
                Order = [Math]::Min($b.Index, $c.Index); Status = 'Changed'
                Id = $c.Id; DisplayName = $c.DisplayName; BaselineDisplayName = $b.DisplayName
                Type = $c.Type; Workload = (Get-UTCMWorkloadFromType $c.Type)
                BaselineData = $b.Normalized; CurrentData = $c.Normalized
                RowIndexes = @($b.Index, $c.Index)
            })
        }
    }

    foreach ($e in $entries) {
        if ($consumed.Contains($e.Index)) { continue }
        $status = if ($e.Side -eq '=>') { 'Added' } elseif ($e.Side -eq '<=') { 'Missing' } else { 'Changed' }
        $records.Add([pscustomobject]@{
            Order = $e.Index; Status = $status
            Id = $e.Id; DisplayName = $e.DisplayName; BaselineDisplayName = $null
            Type = $e.Type; Workload = (Get-UTCMWorkloadFromType $e.Type)
            BaselineData = $(if ($e.Side -eq '<=') { $e.Normalized } else { $null })
            CurrentData  = $(if ($e.Side -eq '=>') { $e.Normalized } else { $null })
            RowIndexes = @($e.Index)
        })
    }

    return @($records | Sort-Object Order)
}