Private/Logic/Compare-AgainstBaseline.ps1

# Copyright (c) 2026 Sandy Zeng. All rights reserved.
# Source-available. All rights reserved. See LICENSE file.

<#
    Compare-AgainstBaseline.ps1 — Compares a baseline policy against one or more comparison policies.
 
    Author: Sandy Zeng
    Project: IntuneDiff
 
    Version History:
    1.0.0 Initial release.
#>


function Compare-AgainstBaseline {
    <#
    .SYNOPSIS
        Compares a baseline policy against one or more comparison policies.
 
    .DESCRIPTION
        PowerShell port of comparisonService.compareAgainstBaseline (core algorithm).
        Returns a list of result rows with Result = Covered | Missing | Conflict | Extra.
 
    .PARAMETER BaselinePolicy
        Hashtable with .name and .settings (or .settingTemplates) from Graph.
 
    .PARAMETER ComparisonPolicies
        Array of hashtables, each shaped like BaselinePolicy.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $BaselinePolicy,
        [Parameter(Mandatory)] [object[]]$ComparisonPolicies
    )

    $baselineArray = $BaselinePolicy.settings
    if ($null -eq $baselineArray) { $baselineArray = $BaselinePolicy.settingTemplates }
    if ($null -eq $baselineArray) { $baselineArray = $BaselinePolicy.value }
    if ($null -eq $baselineArray) { $baselineArray = @() }
    $baselineSettings = ConvertTo-ExtractedSettings -SettingsArray $baselineArray

    $comparisonList = New-Object System.Collections.Generic.List[object]
    foreach ($p in @($ComparisonPolicies)) {
        $arr = $p.settings
        if ($null -eq $arr) { $arr = $p.settingTemplates }
        if ($null -eq $arr) { $arr = $p.value }
        if ($null -eq $arr) { $arr = @() }

        $comparisonList.Add([pscustomobject]@{
            Id       = $p.id
            Name     = if ($p.name) { $p.name } else { $p.displayName }
            Settings = ConvertTo-ExtractedSettings -SettingsArray $arr
        })
    }
    $comparisons = $comparisonList.ToArray()

    $rows = New-Object System.Collections.Generic.List[object]

    foreach ($settingId in $baselineSettings.Keys) {
        $baselineSetting = $baselineSettings[$settingId]
        $baselineVal = $baselineSetting.Value
        $baselineHasValue = ($null -ne $baselineVal -and $baselineVal -ne 'Not configured')

        # Collect every comparison policy that contains this setting
        $present = New-Object System.Collections.Generic.List[object]
        foreach ($cp in $comparisons) {
            if (-not $cp.Settings) { continue }
            if (-not $cp.Settings.Contains($settingId)) { continue }
            $cpSetting = $cp.Settings[$settingId]
            $present.Add([pscustomobject]@{
                PolicyName = $cp.Name
                Value      = $cpSetting.Value
                Raw        = $cpSetting.Raw
            })
        }

        if ($present.Count -eq 0) {
            # No comparison policy has this setting
            $cNames = @($comparisons.Name)
            $cPolicyList = if ($cNames.Count -gt 1) { ($cNames | ForEach-Object { "• $_" }) -join "`n" } else { $cNames[0] }
            $rows.Add([pscustomobject]@{
                SettingName    = $baselineSetting.Name
                SettingId      = $settingId
                Description    = $baselineSetting.Description
                Result         = 'missing'
                PolicyName     = $cPolicyList
                BaselineValue  = $baselineVal
                CurrentValue   = $null
                Category       = $baselineSetting.Category
                BaselineRaw    = $baselineSetting.Raw
                CurrentRaw     = $null
            })
            continue
        }

        # Group comparison policies by normalized value
        $byValue = @($present | Group-Object { Get-NormalizedValue $_.Value })
        $uniqueValueCount = $byValue.Count

        if ($uniqueValueCount -gt 1) {
            # Comparison policies disagree among themselves -> one row per policy
            foreach ($p in $present) {
                $rows.Add([pscustomobject]@{
                    SettingName    = $baselineSetting.Name
                    SettingId      = $settingId
                    Description    = $baselineSetting.Description
                    Result         = 'attention'
                    PolicyName     = $p.PolicyName
                    BaselineValue  = $baselineVal
                    CurrentValue   = $p.Value
                    Category       = $baselineSetting.Category
                    BaselineRaw    = $baselineSetting.Raw
                    CurrentRaw     = $p.Raw
                })
            }
            continue
        }

        # All comparison policies that have the setting agree on a single value
        $currentVal      = $present[0].Value
        $deviceHasValue  = ($null -ne $currentVal -and $currentVal -ne 'Not configured')

        $result = 'covered'
        if (-not $baselineHasValue -and $deviceHasValue) {
            $result = 'extra'
        } elseif ($baselineHasValue -and -not $deviceHasValue) {
            $result = 'missing'
        } elseif ((Get-NormalizedValue $baselineVal) -ne (Get-NormalizedValue $currentVal)) {
            $result = 'conflict'
        }

        $sortedNames = @($present.PolicyName | Sort-Object)
        $policyList = if ($sortedNames.Count -gt 1) {
            ($sortedNames | ForEach-Object { "• $_" }) -join "`n"
        } else { $sortedNames[0] }
        $rows.Add([pscustomobject]@{
            SettingName    = $baselineSetting.Name
            SettingId      = $settingId
            Description    = $baselineSetting.Description
            Result         = $result
            PolicyName     = $policyList
            BaselineValue  = $baselineVal
            CurrentValue   = $currentVal
            Category       = $baselineSetting.Category
            BaselineRaw    = $baselineSetting.Raw
            CurrentRaw     = $present[0].Raw
        })
    }

    # Extra settings: present in comparison policies but not in the baseline
    # Group by SettingId so the same extra setting from N policies collapses to one row.
    $extraGroups = @{}
    foreach ($cp in $comparisons) {
        if (-not $cp.Settings) { continue }
        foreach ($settingId in $cp.Settings.Keys) {
            if ($baselineSettings.Contains($settingId)) { continue }
            if (-not $extraGroups.ContainsKey($settingId)) {
                $extraGroups[$settingId] = New-Object System.Collections.Generic.List[object]
            }
            $cpSetting = $cp.Settings[$settingId]
            $extraGroups[$settingId].Add([pscustomobject]@{
                PolicyName = $cp.Name
                Value      = $cpSetting.Value
                Raw        = $cpSetting.Raw
                Name       = $cpSetting.Name
                Description= $cpSetting.Description
                Category   = $cpSetting.Category
            })
        }
    }

    foreach ($settingId in $extraGroups.Keys) {
        $entries = $extraGroups[$settingId]
        $byValue = @($entries | Group-Object { Get-NormalizedValue $_.Value })
        if ($byValue.Count -gt 1) {
            foreach ($e in $entries) {
                $rows.Add([pscustomobject]@{
                    SettingName    = $e.Name
                    SettingId      = $settingId
                    Description    = $e.Description
                    Result         = 'attention'
                    PolicyName     = $e.PolicyName
                    BaselineValue  = $null
                    CurrentValue   = $e.Value
                    Category       = $e.Category
                    BaselineRaw    = $null
                    CurrentRaw     = $e.Raw
                })
            }
        } else {
            $rows.Add([pscustomobject]@{
                SettingName    = $entries[0].Name
                SettingId      = $settingId
                Description    = $entries[0].Description
                Result         = 'extra'
                PolicyName     = $(  $enames = @($entries.PolicyName | Sort-Object); if ($enames.Count -gt 1) { ($enames | ForEach-Object { "• $_" }) -join "`n" } else { $enames[0] }  )
                BaselineValue  = $null
                CurrentValue   = $entries[0].Value
                Category       = $entries[0].Category
                BaselineRaw    = $null
                CurrentRaw     = $entries[0].Raw
            })
        }
    }

    # Sort: covered, conflict, missing, extra, attention; then by setting name
    $order = @{ 'covered' = 0; 'conflict' = 1; 'missing' = 2; 'extra' = 3; 'attention' = 4 }
    $sorted = $rows | Sort-Object @{Expression = { $order[$_.Result] }}, SettingName

    return [pscustomobject]@{
        BaselineName       = if ($BaselinePolicy.name) { $BaselinePolicy.name } else { $BaselinePolicy.displayName }
        ComparisonPolicies = $comparisons | Select-Object Id, Name
        Rows               = @($sorted)
        Summary            = [pscustomobject]@{
            TotalBaselineSettings = $baselineSettings.Keys.Count
            CoveredSettings       = @($sorted | Where-Object Result -eq 'covered').Count
            MissingSettings       = @($sorted | Where-Object Result -eq 'missing').Count
            ConflictSettings      = @($sorted | Where-Object Result -eq 'conflict').Count
            AttentionSettings     = @($sorted | Where-Object Result -eq 'attention').Count
            ExtraSettings         = @($sorted | Where-Object Result -eq 'extra').Count
        }
    }
}

function Get-NormalizedValue {
    <#
    .SYNOPSIS
        Normalises a setting value to a trimmed string for comparison. Returns empty string for null.
    #>

    [CmdletBinding()]
    param([AllowNull()] $Value)
    if ($null -eq $Value) { return '' }
    if ($Value -is [string]) { return $Value.Trim() }
    return [string]$Value
}