Private/Get-InforcerComparisonData.ps1

function Get-InforcerComparisonData {
    <#
    .SYNOPSIS
        Fetches and normalizes data from two tenants for comparison.
    .DESCRIPTION
        Stage 1 of the Compare-InforcerEnvironments pipeline. Collects data from both
        environments via Get-InforcerDocData and normalizes through ConvertTo-InforcerDocModel
        with -ComparisonMode, producing two DocModels ready for diffing.
        The destination is collected in a separate runspace while the source is collected here, so
        the two slow policies calls overlap. The runspace imports this module and works on a copy
        of the destination session (token renewed here first); its warnings are re-emitted here.
        If it fails for any reason, the destination is collected here afterwards, as before.
        When baseline IDs are specified, filters each tenant's policies to only those
        belonging to the specified baseline before building DocModels.
    .PARAMETER SourceTenantId
        Source tenant identifier. Accepts numeric ID, GUID, or tenant name.
    .PARAMETER DestinationTenantId
        Destination tenant identifier. Accepts numeric ID, GUID, or tenant name.
    .PARAMETER SourceSession
        Inforcer session hashtable for the source tenant. Defaults to $script:InforcerSession.
    .PARAMETER DestinationSession
        Inforcer session hashtable for the destination tenant. Defaults to $script:InforcerSession.
    .PARAMETER SettingsCatalogPath
        Optional explicit path to settings.json. Auto-discovers if omitted.
    .PARAMETER IncludingAssignments
        When specified, policy assignment data is included in the collected policies.
    .PARAMETER FetchGraphData
        When specified, connects to Microsoft Graph to resolve group ObjectIDs and assignment
        filter IDs to friendly display names. Requires Microsoft.Graph.Authentication module
        and interactive sign-in for each tenant.
    .PARAMETER SourceBaselineId
        Optional baseline GUID or friendly name. Filters source tenant policies to the baseline.
    .PARAMETER DestinationBaselineId
        Optional baseline GUID or friendly name. Filters destination tenant policies to the baseline.
    .OUTPUTS
        Hashtable with keys: SourceModel, DestinationModel, SourceName, DestinationName,
        IncludingAssignments, CollectedAt
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$SourceTenantId,

        [Parameter(Mandatory)]
        [object]$DestinationTenantId,

        [Parameter()]
        [hashtable]$SourceSession,

        [Parameter()]
        [hashtable]$DestinationSession,

        [Parameter()]
        [string]$SettingsCatalogPath,

        [Parameter()]
        [switch]$IncludingAssignments,

        [Parameter()]
        [switch]$FetchGraphData,

        [Parameter()]
        [string]$SourceBaselineId,

        [Parameter()]
        [string]$DestinationBaselineId
    )

    if ($null -eq $SourceSession) { $SourceSession = $script:InforcerSession }
    if ($null -eq $DestinationSession) { $DestinationSession = $script:InforcerSession }

    $originalSession = $script:InforcerSession

    $sourceBaselineName = $null
    $destBaselineName = $null

    $docDataParams = @{}
    if (-not [string]::IsNullOrEmpty($SettingsCatalogPath)) {
        $docDataParams['SettingsCatalogPath'] = $SettingsCatalogPath
    }

    $destPs = $null
    $destAsync = $null
    try {
        # ── Destination, in parallel ──
        if (Get-InforcerAuthHeader -Session $DestinationSession -ErrorAction Ignore) {
            $sessionCopy = @{}
            foreach ($key in $DestinationSession.Keys) {
                $value = $DestinationSession[$key]
                $sessionCopy[$key] = if ($value -is [securestring]) { $value.Copy() } else { $value }
            }
            $destPs = [PowerShell]::Create()
            $null = $destPs.AddScript({
                param($ManifestPath, $Session, $TenantId, $DocDataParams)
                Import-Module $ManifestPath -ErrorAction Stop
                & (Get-Module InforcerCommunity) {
                    param($s, $t, $p)
                    $script:InforcerSession = $s
                    Get-InforcerDocData -TenantId $t @p
                } $Session $TenantId $DocDataParams
            }.ToString())
            $null = $destPs.AddParameters(@{
                ManifestPath  = Join-Path $ExecutionContext.SessionState.Module.ModuleBase 'InforcerCommunity.psd1'
                Session       = $sessionCopy
                TenantId      = $DestinationTenantId
                DocDataParams = $docDataParams
            })
            $destAsync = $destPs.BeginInvoke()
        }

        # ── Source ──
        Write-Host 'Collecting source tenant data...' -ForegroundColor Gray
        $script:InforcerSession = $SourceSession
        $sourceDocData = Get-InforcerDocData -TenantId $SourceTenantId @docDataParams
        if ($null -eq $sourceDocData -or $null -eq $sourceDocData.Policies) {
            Write-Error -Message "Failed to collect data for source tenant '$SourceTenantId'. The API may be unavailable — try again later." `
                -ErrorId 'SourceDataCollectionFailed' -Category ConnectionError
            return $null
        }

        # ── Source baseline filtering (while source session is active) ──
        if (-not [string]::IsNullOrWhiteSpace($SourceBaselineId)) {
            Write-Host " Filtering source to baseline: $SourceBaselineId" -ForegroundColor Gray
            $sourceBaselineName = Select-InforcerBaselinePolicies -DocData $sourceDocData -BaselineId $SourceBaselineId
            if ($null -eq $sourceBaselineName) {
                Write-Error -Message "Failed to filter source tenant to baseline '$SourceBaselineId'." `
                    -ErrorId 'SourceBaselineFilterFailed' -Category InvalidResult
                return $null
            }
        }

        # ── Destination ──
        Write-Host 'Collecting destination tenant data...' -ForegroundColor Gray
        $script:InforcerSession = $DestinationSession
        $destDocData = $null
        if ($destAsync) {
            try {
                $out = $destPs.EndInvoke($destAsync)
                $last = if ($out.Count -gt 0) { $out[$out.Count - 1] } else { $null }
                # A result whose fetch reported an error is incomplete: fetch it again in the foreground,
                # so the error reaches the caller under its own -ErrorAction.
                if ($last -is [hashtable] -and $destPs.Streams.Error.Count -eq 0) {
                    $destDocData = [hashtable]$last
                    foreach ($w in $destPs.Streams.Warning) { Write-Warning $w.Message }
                }
            } catch {
                Write-Verbose "Parallel destination fetch failed; fetching it now: $($_.Exception.Message)"
            }
        }
        if ($null -eq $destDocData) {
            $destDocData = Get-InforcerDocData -TenantId $DestinationTenantId @docDataParams
        }
        if ($null -eq $destDocData -or $null -eq $destDocData.Policies) {
            Write-Error -Message "Failed to collect data for destination tenant '$DestinationTenantId'. The API may be unavailable — try again later." `
                -ErrorId 'DestDataCollectionFailed' -Category ConnectionError
            return $null
        }

        # ── Destination baseline filtering (while dest session is active) ──
        # The destination inherits the source's baseline when none was given. Scoping one side
        # only compares N baseline policies against the destination's ENTIRE estate, so every
        # destination-only policy scores as a deviation: 3-policy baseline vs 756-policy tenant
        # reported 0.2% where Inforcer itself reports 100% for the same pair. "How aligned is
        # this tenant to this baseline" means comparing like with like on both sides.
        $effectiveDestBaseline = $DestinationBaselineId
        $baselineInherited = $false
        if ([string]::IsNullOrWhiteSpace($effectiveDestBaseline) -and -not [string]::IsNullOrWhiteSpace($SourceBaselineId)) {
            $effectiveDestBaseline = $SourceBaselineId
            $baselineInherited = $true
        }

        if (-not [string]::IsNullOrWhiteSpace($effectiveDestBaseline)) {
            Write-Host " Filtering destination to baseline: $effectiveDestBaseline" -ForegroundColor Gray
            $destBaselineName = Select-InforcerBaselinePolicies -DocData $destDocData -BaselineId $effectiveDestBaseline
            if ($null -eq $destBaselineName) {
                # An explicit -DestinationBaselineId that fails is an error. An inherited one is
                # not: the destination simply may not be a member of the source's baseline, which
                # is a legitimate comparison to want. Select-InforcerBaselinePolicies returns
                # $null before it mutates DocData, so the unfiltered destination is still intact.
                if (-not $baselineInherited) {
                    Write-Error -Message "Failed to filter destination tenant to baseline '$DestinationBaselineId'." `
                        -ErrorId 'DestBaselineFilterFailed' -Category InvalidResult
                    return $null
                }
                Write-Warning ("Destination tenant '$DestinationTenantId' could not be scoped to baseline '$SourceBaselineId' " +
                    'so the comparison runs against its full policy set. Every destination-only policy then counts as a ' +
                    'deviation, which understates the alignment score. Pass -DestinationBaselineId to scope it explicitly.')
                $effectiveDestBaseline = $null
            }
        }
    } finally {
        $script:InforcerSession = $originalSession
        if ($destPs) {
            try { if ($destPs.InvocationStateInfo.State -eq 'Running') { $destPs.Stop() } } catch { $null }
            $destPs.Dispose()
        }
    }

    # ── Graph enrichment (resolve group names and assignment filters) ──
    $srcGraphMaps = @{ GroupNameMap = $null; FilterMap = $null; ScopeTagMap = $null }
    $dstGraphMaps = @{ GroupNameMap = $null; FilterMap = $null; ScopeTagMap = $null }

    if ($FetchGraphData) {
        Write-Host 'Connecting to Microsoft Graph for assignment resolution...' -ForegroundColor Cyan

        # Always sign in separately for each tenant to ensure correct Azure AD context
        $srcTenantName = if ($sourceDocData.Tenant.tenantFriendlyName) { $sourceDocData.Tenant.tenantFriendlyName } else { $SourceTenantId }
        $dstTenantName = if ($destDocData.Tenant.tenantFriendlyName) { $destDocData.Tenant.tenantFriendlyName } else { $DestinationTenantId }

        Write-Host " Sign in for SOURCE tenant: $srcTenantName" -ForegroundColor Yellow
        $srcGraphMaps = Resolve-InforcerGraphEnrichment -DocData $sourceDocData -Label "Source ($srcTenantName)" -ComplianceRules

        Write-Host " Sign in for DESTINATION tenant: $dstTenantName" -ForegroundColor Yellow
        $dstGraphMaps = Resolve-InforcerGraphEnrichment -DocData $destDocData -Label "Destination ($dstTenantName)" -ComplianceRules
    }

    # ── Helper: inject compliance rules and link discovery scripts ──
    # Shared by both source and destination pipelines
    $enrichComplianceData = {
        param([object[]]$Policies, [hashtable]$GraphMaps, [string]$Label)

        # Inject rulesContent for policies that DON'T have a linked script
        $linkedPolicyIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($p in $Policies) {
            if ($p.policyData -and -not [string]::IsNullOrWhiteSpace($p.policyData.deviceComplianceScriptId)) {
                [void]$linkedPolicyIds.Add($p.policyData.id)
            }
        }
        if ($GraphMaps.ComplianceRulesMap -and $GraphMaps.ComplianceRulesMap.Count -gt 0) {
            $injected = 0
            foreach ($policy in $Policies) {
                if ($null -eq $policy.policyData -or $null -eq $policy.policyData.id) { continue }
                $pid = $policy.policyData.id
                if ($GraphMaps.ComplianceRulesMap.ContainsKey($pid) -and -not $linkedPolicyIds.Contains($pid)) {
                    $policy.policyData | Add-Member -NotePropertyName 'rulesContent' -NotePropertyValue $GraphMaps.ComplianceRulesMap[$pid] -Force
                    $injected++
                }
            }
            if ($injected -gt 0) { Write-Host " Injected compliance rules into $injected $Label policies" -ForegroundColor Gray }
        }

        # Link compliance discovery scripts to their parent compliance policies
        $scriptById = @{}
        foreach ($p in $Policies) {
            if ($p.policyTypeId -eq 104 -and $p.policyData -and $p.policyData.id) {
                $scriptById[$p.policyData.id] = $p
            }
        }
        if ($scriptById.Count -gt 0) {
            foreach ($policy in $Policies) {
                if ($null -eq $policy.policyData -or $null -eq $policy.policyData.id) { continue }
                if ($policy.policyTypeId -eq 104) { continue }
                $policyId = $policy.policyData.id
                # Priority 1: Graph-based link
                $scriptId = $null
                if ($GraphMaps.ComplianceScriptLinkMap -and $GraphMaps.ComplianceScriptLinkMap.ContainsKey($policyId)) {
                    $scriptId = $GraphMaps.ComplianceScriptLinkMap[$policyId]
                }
                # Priority 2: Inforcer API deviceComplianceScriptId (often empty — API limitation)
                if (-not $scriptId) {
                    $infoScriptId = "$($policy.policyData.deviceComplianceScriptId)"
                    if ($infoScriptId -match '^[0-9a-f]{8}-') { $scriptId = $infoScriptId }
                }
                if (-not $scriptId -or -not $scriptById.ContainsKey($scriptId)) { continue }
                $scriptPolicy = $scriptById[$scriptId]
                $policyName = if ($policy.displayName) { $policy.displayName } else { $policy.name }
                Write-Host " Linked script ($Label): '$policyName' -> '$($scriptPolicy.displayName)'" -ForegroundColor Green
                $scriptData = @{
                    scriptName = if ($scriptPolicy.displayName) { $scriptPolicy.displayName }
                                 elseif ($scriptPolicy.name) { $scriptPolicy.name }
                                 else { $scriptPolicy.policyData.displayName }
                }
                foreach ($prop in $scriptPolicy.policyData.PSObject.Properties) {
                    $propName = $prop.Name
                    if ($propName -match '@odata|^id$|^createdDateTime|^lastModifiedDateTime|^version|^displayName|^description|^roleScopeTagIds') { continue }
                    $val = $prop.Value
                    if ($propName -match '(?i)scriptContent|rulesContent|^payload$' -and $val -is [string]) {
                        $decoded = ConvertFrom-InforcerBase64Text -Value $val
                        if ($decoded) { $val = $decoded }
                    }
                    $scriptData[$propName] = $val
                }
                $scriptJson = $scriptData | ConvertTo-Json -Depth 100 -Compress
                $policy.policyData | Add-Member -NotePropertyName 'linkedComplianceScript' -NotePropertyValue $scriptJson -Force
                $scriptPolicy | Add-Member -NotePropertyName '_claimedByCompliancePolicy' -NotePropertyValue $true -Force
            }
        }
    }

    & $enrichComplianceData @($sourceDocData.Policies) $srcGraphMaps 'source'
    & $enrichComplianceData @($destDocData.Policies) $dstGraphMaps 'destination'

    # ── Build DocModels ──
    foreach ($entry in @(
        @{ DocData = $sourceDocData; Maps = $srcGraphMaps; Var = 'sourceModel' },
        @{ DocData = $destDocData;   Maps = $dstGraphMaps; Var = 'destModel' }
    )) {
        $params = @{ DocData = $entry.DocData; ComparisonMode = $true }
        foreach ($key in @('GroupNameMap', 'FilterMap', 'ScopeTagMap')) {
            if ($entry.Maps[$key]) { $params[$key] = $entry.Maps[$key] }
        }
        Set-Variable -Name $entry.Var -Value (ConvertTo-InforcerDocModel @params)
    }

    @{
        SourceModel          = $sourceModel
        DestinationModel     = $destModel
        SourceName           = $sourceModel.TenantName
        DestinationName      = $destModel.TenantName
        IncludingAssignments     = $IncludingAssignments.IsPresent
        SourceBaselineName       = $sourceBaselineName
        DestinationBaselineName  = $destBaselineName
        CollectedAt              = [datetime]::UtcNow
    }
}