modules/shared/Compare-EntitySnapshots.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Compare two entities.json snapshots and classify drift by canonical entity ID.
.DESCRIPTION
    Returns a hashtable with Added, Removed, Modified, and Unchanged arrays.
    Entity keys are canonicalized using ConvertTo-CanonicalEntityId when available.

    Volatile fields intentionally ignored in deep comparison:
      - Observations[*].Id
      - Observations[*].Provenance.RunId
      - Observations[*].Provenance.Timestamp
      - Observations[*].Provenance.RawRecordRef

    These values are run-specific and would otherwise create false-positive drift.
#>


Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sanitizePath = Join-Path $PSScriptRoot 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$errorsPath = Join-Path $PSScriptRoot 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string] $Text) return $Text }
}

function Get-EntitySnapshotPayload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $Path
    )

    if (-not (Test-Path $Path)) {
        if (Get-Command -Name New-FindingError -ErrorAction SilentlyContinue) {
            throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Compare-EntitySnapshots' `
                -Category 'NotFound' `
                -Reason "Snapshot not found: $Path" `
                -Remediation 'Provide a valid path to an entities.json snapshot produced by Invoke-AzureAnalyzer.ps1.'))
        }
        throw "Snapshot not found: $Path"
    }

    $raw = Get-Content -Path $Path -Raw -ErrorAction Stop
    $parsed = $raw | ConvertFrom-Json -Depth 100 -ErrorAction Stop

    if ($parsed -is [System.Array]) {
        return [pscustomobject]@{
            SchemaVersion = $null
            Entities      = @($parsed)
        }
    }

    if ($parsed.PSObject.Properties['Entities']) {
        return [pscustomobject]@{
            SchemaVersion = if ($parsed.PSObject.Properties['SchemaVersion']) { [string]$parsed.SchemaVersion } else { $null }
            Entities      = @($parsed.Entities)
        }
    }

    if ($parsed.PSObject.Properties['items']) {
        return [pscustomobject]@{
            SchemaVersion = if ($parsed.PSObject.Properties['SchemaVersion']) { [string]$parsed.SchemaVersion } else { $null }
            Entities      = @($parsed.items)
        }
    }

    return [pscustomobject]@{
        SchemaVersion = if ($parsed.PSObject.Properties['SchemaVersion']) { [string]$parsed.SchemaVersion } else { $null }
        Entities      = @($parsed)
    }
}

function Get-CanonicalEntityKey {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object] $Entity
    )

    $rawEntityId = if ($Entity.PSObject.Properties['EntityId']) { [string]$Entity.EntityId } else { '' }
    $entityType = if ($Entity.PSObject.Properties['EntityType']) { [string]$Entity.EntityType } else { '' }

    if ([string]::IsNullOrWhiteSpace($rawEntityId)) { return $null }
    if ([string]::IsNullOrWhiteSpace($entityType)) { return $rawEntityId.Trim().ToLowerInvariant() }

    if (Get-Command ConvertTo-CanonicalEntityId -ErrorAction SilentlyContinue) {
        try {
            $canonical = ConvertTo-CanonicalEntityId -RawId $rawEntityId -EntityType $entityType
            if ($canonical -and $canonical.PSObject.Properties['CanonicalId'] -and $canonical.CanonicalId) {
                return [string]$canonical.CanonicalId
            }
        } catch {
            Write-Verbose (Remove-Credentials "Canonicalization fallback for '$rawEntityId': $_")
        }
    }

    return $rawEntityId.Trim().ToLowerInvariant()
}

function Test-IsIgnoredPath {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $Path
    )

    $normalized = $Path -replace '\[\d+\]', '[]'
    $normalized = $normalized.TrimStart('.')
    return (
        $normalized -match '^EntityId$' -or
        $normalized -match '^Observations\[\]\.Id$' -or
        $normalized -match '^Observations\[\]\.Provenance\.RunId$' -or
        $normalized -match '^Observations\[\]\.Provenance\.Timestamp$' -or
        $normalized -match '^Observations\[\]\.Provenance\.RawRecordRef$'
    )
}

function Normalize-ForComparison {
    [CmdletBinding()]
    param (
        [AllowNull()]
        [object] $InputObject,
        [string] $Path = ''
    )

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

    if ($InputObject -is [System.Management.Automation.PSCustomObject] -or $InputObject -is [hashtable]) {
        $ordered = [ordered]@{}
        $properties = @()
        if ($InputObject -is [hashtable]) {
            $properties = @($InputObject.Keys | Sort-Object)
            foreach ($name in $properties) {
                $childPath = if ($Path) { "$Path.$name" } else { "$name" }
                if (Test-IsIgnoredPath -Path $childPath) { continue }
                $ordered[$name] = Normalize-ForComparison -InputObject $InputObject[$name] -Path $childPath
            }
        } else {
            $properties = @($InputObject.PSObject.Properties | ForEach-Object { $_.Name } | Sort-Object)
            foreach ($name in $properties) {
                $childPath = if ($Path) { "$Path.$name" } else { "$name" }
                if (Test-IsIgnoredPath -Path $childPath) { continue }
                $ordered[$name] = Normalize-ForComparison -InputObject $InputObject.$name -Path $childPath
            }
        }
        return [pscustomobject]$ordered
    }

    if ($InputObject -is [System.Array] -or ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string])) {
        $items = @()
        $idx = 0
        foreach ($item in $InputObject) {
            $items += ,(Normalize-ForComparison -InputObject $item -Path "$Path[$idx]")
            $idx++
        }

        $sorted = @(
            $items |
                Sort-Object {
                    try { ($_ | ConvertTo-Json -Depth 100 -Compress) }
                    catch { [string]$_ }
                }
        )
        return ,$sorted
    }

    return $InputObject
}

function Get-FlatPathMap {
    [CmdletBinding()]
    param (
        [AllowNull()]
        [object] $InputObject,
        [string] $Path = ''
    )

    $map = @{}
    if ($null -eq $InputObject) {
        if ($Path) { $map[$Path] = $null }
        return $map
    }

    if ($InputObject -is [System.Management.Automation.PSCustomObject] -or $InputObject -is [hashtable]) {
        $names = if ($InputObject -is [hashtable]) { @($InputObject.Keys) } else { @($InputObject.PSObject.Properties | ForEach-Object { $_.Name }) }
        foreach ($name in $names) {
            $childPath = if ($Path) { "$Path.$name" } else { "$name" }
            $value = if ($InputObject -is [hashtable]) { $InputObject[$name] } else { $InputObject.$name }
            $childMap = Get-FlatPathMap -InputObject $value -Path $childPath
            foreach ($k in $childMap.Keys) { $map[$k] = $childMap[$k] }
        }
        return $map
    }

    if ($InputObject -is [System.Array] -or ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string])) {
        $i = 0
        foreach ($item in $InputObject) {
            $childPath = "$Path[$i]"
            $childMap = Get-FlatPathMap -InputObject $item -Path $childPath
            foreach ($k in $childMap.Keys) { $map[$k] = $childMap[$k] }
            $i++
        }
        if ($i -eq 0 -and $Path) {
            $map[$Path] = @()
        }
        return $map
    }

    if ($Path) { $map[$Path] = $InputObject }
    return $map
}

function Get-ChangedPaths {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object] $PreviousEntity,
        [Parameter(Mandatory)]
        [object] $CurrentEntity
    )

    $left = Normalize-ForComparison -InputObject $PreviousEntity
    $right = Normalize-ForComparison -InputObject $CurrentEntity
    $leftMap = Get-FlatPathMap -InputObject $left
    $rightMap = Get-FlatPathMap -InputObject $right

    $paths = @($leftMap.Keys + $rightMap.Keys | Sort-Object -Unique)
    $changed = [System.Collections.Generic.List[string]]::new()
    foreach ($path in $paths) {
        if (Test-IsIgnoredPath -Path $path) { continue }
        $leftVal = if ($leftMap.ContainsKey($path)) { $leftMap[$path] } else { $null }
        $rightVal = if ($rightMap.ContainsKey($path)) { $rightMap[$path] } else { $null }
        $leftJson = try { $leftVal | ConvertTo-Json -Depth 30 -Compress } catch { [string]$leftVal }
        $rightJson = try { $rightVal | ConvertTo-Json -Depth 30 -Compress } catch { [string]$rightVal }
        if ($leftJson -ne $rightJson) {
            $changed.Add($path) | Out-Null
        }
    }
    return @($changed.ToArray())
}

function Get-DriftSeverity {
    [CmdletBinding()]
    param (
        [string[]] $ChangedPaths = @(),
        [object] $PreviousEntity,
        [object] $CurrentEntity
    )

    $previousJson = ''
    $currentJson = ''
    try { $previousJson = $PreviousEntity | ConvertTo-Json -Depth 20 -Compress } catch { $previousJson = '' }
    try { $currentJson = $CurrentEntity | ConvertTo-Json -Depth 20 -Compress } catch { $currentJson = '' }

    $context = @(
        ($ChangedPaths -join ' ')
        $previousJson
        $currentJson
    ) -join ' '

    if ($context -match '(?i)\brbac\b|\brole\b|roleassignment|permission|owner|accesspolicy|privilege') {
        return 'Medium'
    }

    return 'Info'
}

function Get-LatestPreviousRun {
    <#
    .SYNOPSIS
        Returns the most recently modified sibling run directory containing entities.json.
    .PARAMETER OutputRoot
        Root directory containing per-run output directories.
    .PARAMETER CurrentRunDir
        Current run output directory (excluded from candidates).
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $OutputRoot,
        [Parameter(Mandatory)]
        [string] $CurrentRunDir
    )

    if (-not (Test-Path $OutputRoot -PathType Container)) { return $null }

    $currentResolved = $null
    if (Test-Path $CurrentRunDir -PathType Container) {
        $currentResolved = (Resolve-Path $CurrentRunDir).Path
    }

    $candidates = @(
        Get-ChildItem -Path $OutputRoot -Directory -ErrorAction SilentlyContinue |
            Where-Object {
                if ($currentResolved -and $_.FullName -eq $currentResolved) { return $false }
                Test-Path (Join-Path $_.FullName 'entities.json')
            } |
            Sort-Object LastWriteTimeUtc -Descending
    )

    if ($candidates.Count -eq 0) { return $null }
    return $candidates[0].FullName
}

function Compare-EntitySnapshots {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $Previous,
        [Parameter(Mandatory)]
        [string] $Current
    )

    $previousPayload = Get-EntitySnapshotPayload -Path $Previous
    $currentPayload = Get-EntitySnapshotPayload -Path $Current

    $previousIndex = @{}
    foreach ($entity in @($previousPayload.Entities)) {
        if (-not $entity) { continue }
        $key = Get-CanonicalEntityKey -Entity $entity
        if (-not $key) { continue }
        if (-not $previousIndex.ContainsKey($key)) { $previousIndex[$key] = $entity }
    }

    $currentIndex = @{}
    foreach ($entity in @($currentPayload.Entities)) {
        if (-not $entity) { continue }
        $key = Get-CanonicalEntityKey -Entity $entity
        if (-not $key) { continue }
        if (-not $currentIndex.ContainsKey($key)) { $currentIndex[$key] = $entity }
    }

    $added = [System.Collections.Generic.List[object]]::new()
    $removed = [System.Collections.Generic.List[object]]::new()
    $modified = [System.Collections.Generic.List[object]]::new()
    $unchanged = [System.Collections.Generic.List[object]]::new()

    foreach ($key in $currentIndex.Keys) {
        $curr = $currentIndex[$key]
        if (-not $previousIndex.ContainsKey($key)) {
            $added.Add([pscustomobject]@{
                    ChangeKind = 'Added'
                    EntityId   = $key
                    EntityType = if ($curr.PSObject.Properties['EntityType']) { $curr.EntityType } else { $null }
                    Platform   = if ($curr.PSObject.Properties['Platform']) { $curr.Platform } else { $null }
                    Severity   = 'Info'
                    Previous   = $null
                    Current    = $curr
                    ChangedPaths = @()
                }) | Out-Null
            continue
        }

        $prev = $previousIndex[$key]
        $normalizedPrev = Normalize-ForComparison -InputObject $prev
        $normalizedCurr = Normalize-ForComparison -InputObject $curr
        $prevJson = $normalizedPrev | ConvertTo-Json -Depth 100 -Compress
        $currJson = $normalizedCurr | ConvertTo-Json -Depth 100 -Compress

        if ($prevJson -eq $currJson) {
            $unchanged.Add([pscustomobject]@{
                    ChangeKind = 'Unchanged'
                    EntityId   = $key
                    EntityType = if ($curr.PSObject.Properties['EntityType']) { $curr.EntityType } else { $null }
                    Platform   = if ($curr.PSObject.Properties['Platform']) { $curr.Platform } else { $null }
                    Severity   = 'Info'
                    Previous   = $prev
                    Current    = $curr
                    ChangedPaths = @()
                }) | Out-Null
            continue
        }

        $changedPaths = Get-ChangedPaths -PreviousEntity $prev -CurrentEntity $curr
        $severity = Get-DriftSeverity -ChangedPaths $changedPaths -PreviousEntity $prev -CurrentEntity $curr
        $modified.Add([pscustomobject]@{
                ChangeKind = 'Modified'
                EntityId   = $key
                EntityType = if ($curr.PSObject.Properties['EntityType']) { $curr.EntityType } else { if ($prev.PSObject.Properties['EntityType']) { $prev.EntityType } else { $null } }
                Platform   = if ($curr.PSObject.Properties['Platform']) { $curr.Platform } else { if ($prev.PSObject.Properties['Platform']) { $prev.Platform } else { $null } }
                Severity   = $severity
                Previous   = $prev
                Current    = $curr
                ChangedPaths = @($changedPaths)
            }) | Out-Null
    }

    foreach ($key in $previousIndex.Keys) {
        if ($currentIndex.ContainsKey($key)) { continue }
        $prev = $previousIndex[$key]
        $removed.Add([pscustomobject]@{
                ChangeKind = 'Removed'
                EntityId   = $key
                EntityType = if ($prev.PSObject.Properties['EntityType']) { $prev.EntityType } else { $null }
                Platform   = if ($prev.PSObject.Properties['Platform']) { $prev.Platform } else { $null }
                Severity   = 'Info'
                Previous   = $prev
                Current    = $null
                ChangedPaths = @()
            }) | Out-Null
    }

    return [ordered]@{
        Added     = @($added)
        Removed   = @($removed)
        Modified  = @($modified)
        Unchanged = @($unchanged)
    }
}