Public/Compare-TenantLensSnapshot.ps1

function Compare-TenantLensSnapshot {
    <#
    .SYNOPSIS
        Detects drift between two snapshots of the same tenant.
    .DESCRIPTION
        Normalizes both snapshots (volatile fields removed, deterministic
        ordering), performs a structural diff per area and reports every change
        as Added / Removed / Changed with the exact property path and a
        severity heuristic (CA exclusions and role assignments are highlighted,
        renames rank as Info). With -Report a self-contained drift HTML is
        written next to the difference snapshot.
    .EXAMPLE
        Compare-TenantLensSnapshot -ReferencePath .\out\contoso_2026-06-23_0900 -DifferencePath .\out\contoso_2026-07-23_1430 -Report
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [Alias('Reference')]
        [string]$ReferencePath,

        [Parameter(Mandatory, Position = 1)]
        [Alias('Difference')]
        [string]$DifferencePath,

        # Write a self-contained drift HTML report.
        [switch]$Report,

        # Target file for the drift report (implies -Report).
        [string]$OutputPath,

        # Open the report in the default browser.
        [switch]$Open
    )

    $reference = Read-TLSnapshot -Path $ReferencePath
    $difference = Read-TLSnapshot -Path $DifferencePath

    $configPath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Config') -ChildPath 'volatile-properties.psd1'
    $config = Import-PowerShellDataFile -Path $configPath
    $globalVolatile = @($config['GlobalVolatileProperties'])
    $areaVolatileMap = $config['AreaVolatileProperties']
    $excludedAreas = @($config['DriftExcludedAreas'])

    $referenceAreas = @($reference.PSObject.Properties.Name | Where-Object { $_ -notlike '_*' })
    $differenceAreas = @($difference.PSObject.Properties.Name | Where-Object { $_ -notlike '_*' })
    $allAreas = @(@($referenceAreas) + @($differenceAreas) | Sort-Object -Unique)

    $changes = [System.Collections.Generic.List[object]]::new()
    $notes = [System.Collections.Generic.List[string]]::new()

    foreach ($area in $allAreas) {
        if ($excludedAreas -contains $area) {
            $notes.Add("Area '$area' is excluded from drift detection (point-in-time report data).")
            continue
        }
        $inReference = $referenceAreas -contains $area
        $inDifference = $differenceAreas -contains $area
        if (-not ($inReference -and $inDifference)) {
            $side = $(if ($inReference) { 'reference' } else { 'difference' })
            $notes.Add("Area '$area' was only collected in the $side snapshot - not compared.")
            continue
        }

        $volatile = @($globalVolatile)
        if ($areaVolatileMap -and $areaVolatileMap.ContainsKey($area)) {
            $volatile = @($globalVolatile) + @($areaVolatileMap[$area])
        }

        $referenceNormalized = ConvertTo-TLNormalizedObject -InputObject $reference.$area -VolatileProperty $volatile
        $differenceNormalized = ConvertTo-TLNormalizedObject -InputObject $difference.$area -VolatileProperty $volatile

        foreach ($change in @(Compare-TLValue -Reference $referenceNormalized -Difference $differenceNormalized)) {
            $changes.Add([pscustomobject]@{
                    Area       = $area
                    ChangeType = $change.ChangeType
                    ObjectName = $change.Label
                    ObjectId   = $change.ObjectId
                    Path       = $change.Path
                    Before     = $change.Before
                    After      = $change.After
                    Severity   = (Get-TLDriftSeverity -Area $area -ChangeType $change.ChangeType -Path ([string]$change.Path))
                })
        }
    }

    Write-Verbose ("Drift: {0} changes across {1} areas ({2} notes)." -f $changes.Count, $allAreas.Count, $notes.Count)

    if ($Report -or $OutputPath -or $Open) {
        function Get-TLSnapshotLabel {
            param([object]$Snapshot)
            $label = Split-Path -Path (Split-Path -Path $Snapshot._Path -Parent) -Leaf
            if ($Snapshot._Manifest -and $Snapshot._Manifest.PSObject.Properties['tenant'] -and
                $Snapshot._Manifest.tenant -and $Snapshot._Manifest.tenant.PSObject.Properties['label']) {
                $label = '{0} ({1})' -f $label, $Snapshot._Manifest.tenant.label
            }
            return $label
        }
        function Get-TLEncodedText { param([object]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) }

        $severityOrder = @{ High = 0; Medium = 1; Low = 2; Info = 3 }
        $sortedChanges = @($changes | Sort-Object -Property `
            @{ Expression = { $_.Area } },
            @{ Expression = { $severityOrder[[string]$_.Severity] } },
            @{ Expression = { $_.ObjectName } })

        $tiles = [System.Text.StringBuilder]::new()
        foreach ($changeType in @('Added', 'Removed', 'Changed')) {
            $count = @($sortedChanges | Where-Object { $_.ChangeType -eq $changeType }).Count
            [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2}</div></div>' -f $changeType.ToLowerInvariant(), $count, $changeType))
        }
        foreach ($severity in @('High', 'Medium', 'Low', 'Info')) {
            $count = @($sortedChanges | Where-Object { $_.Severity -eq $severity }).Count
            [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2} severity</div></div>' -f $severity.ToLowerInvariant(), $count, $severity))
        }

        $changesHtml = [System.Text.StringBuilder]::new()
        if ($sortedChanges.Count -eq 0) {
            [void]$changesHtml.AppendLine('<div class="empty">No drift detected - the snapshots are configuration-identical after normalization.</div>')
        }
        else {
            foreach ($areaGroup in ($sortedChanges | Group-Object -Property Area)) {
                [void]$changesHtml.AppendLine(('<h2 class="area">{0} ({1})</h2>' -f (Get-TLEncodedText -Text $areaGroup.Name), $areaGroup.Count))
                foreach ($change in $areaGroup.Group) {
                    $severityClass = ([string]$change.Severity).ToLowerInvariant()
                    $typeClass = ([string]$change.ChangeType).ToLowerInvariant()
                    [void]$changesHtml.AppendLine(('<div class="change sev-{0}">' -f $severityClass))
                    [void]$changesHtml.Append('<div class="head">')
                    [void]$changesHtml.Append(('<span class="badge {0}">{1}</span>' -f $severityClass, $change.Severity))
                    [void]$changesHtml.Append(('<span class="badge {0}">{1}</span>' -f $typeClass, $change.ChangeType))
                    if ($change.ObjectName) {
                        [void]$changesHtml.Append(('<span class="obj">{0}</span>' -f (Get-TLEncodedText -Text $change.ObjectName)))
                    }
                    if ($change.Path) {
                        [void]$changesHtml.Append(('<span class="path">{0}</span>' -f (Get-TLEncodedText -Text $change.Path)))
                    }
                    [void]$changesHtml.AppendLine('</div>')
                    if ($change.ChangeType -eq 'Changed') {
                        [void]$changesHtml.AppendLine(('<div class="values"><span class="before">{0}</span><span class="arrow">&rarr;</span><span class="after">{1}</span></div>' -f `
                                    (Get-TLEncodedText -Text $change.Before), (Get-TLEncodedText -Text $change.After)))
                    }
                    [void]$changesHtml.AppendLine('</div>')
                }
            }
        }

        $notesHtml = ''
        if ($notes.Count -gt 0) {
            $noteItems = @($notes | ForEach-Object { '<li>{0}</li>' -f (Get-TLEncodedText -Text $_) })
            $notesHtml = '<div class="notes"><strong>Notes</strong><ul>' + ($noteItems -join '') + '</ul></div>'
        }

        $templatePath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Templates') -ChildPath 'drift.html'
        $html = Get-Content -Path $templatePath -Raw
        $referenceLabel = Get-TLSnapshotLabel -Snapshot $reference
        $differenceLabel = Get-TLSnapshotLabel -Snapshot $difference
        $html = $html.Replace('{{TITLE}}', (Get-TLEncodedText -Text ("TenantLens drift - {0} vs {1}" -f $referenceLabel, $differenceLabel)))
        $html = $html.Replace('{{REF_LABEL}}', (Get-TLEncodedText -Text $referenceLabel))
        $html = $html.Replace('{{DIFF_LABEL}}', (Get-TLEncodedText -Text $differenceLabel))
        $html = $html.Replace('{{GENERATED}}', ([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm') + ' UTC'))
        $html = $html.Replace('{{TOTAL_CHANGES}}', "$($sortedChanges.Count)")
        $html = $html.Replace('{{SUMMARY_TILES}}', $tiles.ToString())
        $html = $html.Replace('{{CHANGES_HTML}}', $changesHtml.ToString())
        $html = $html.Replace('{{NOTES_HTML}}', $notesHtml)
        $html = $html.Replace('{{TOOL_VERSION}}', (Get-TLEncodedText -Text $script:TLVersion))

        if (-not $OutputPath) {
            $runFolder = $difference._Path
            if ((Split-Path -Path $runFolder -Leaf) -eq 'snapshot') { $runFolder = Split-Path -Path $runFolder -Parent }
            $referenceLeaf = Split-Path -Path (Split-Path -Path $reference._Path -Parent) -Leaf
            $differenceLeaf = Split-Path -Path $runFolder -Leaf
            $OutputPath = Join-Path -Path $runFolder -ChildPath ("drift_{0}_vs_{1}.html" -f $referenceLeaf, $differenceLeaf)
        }
        Write-TLFile -Path $OutputPath -Content $html
        Write-Verbose ("Drift report written to '{0}'." -f $OutputPath)
        if ($Open) { Invoke-Item -Path $OutputPath }
    }

    return $changes
}