Private/Test-IACAssignmentFilter.ps1

function ConvertTo-IACTokenList {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Rule
    )

    if ($Rule.Length -gt 8192) { throw 'Assignment filter rule exceeds the 8192-character safety limit.' }

    $tokens = [System.Collections.Generic.List[object]]::new()
    $index = 0
    while ($index -lt $Rule.Length) {
        if ($tokens.Count -ge 1024) { throw 'Assignment filter rule exceeds the 1024-token safety limit.' }
        $character = $Rule[$index]
        if ([char]::IsWhiteSpace($character)) {
            $index++
            continue
        }

        $punctuation = switch ($character) {
            '(' { 'LeftParen' }
            ')' { 'RightParen' }
            '[' { 'LeftBracket' }
            ']' { 'RightBracket' }
            ',' { 'Comma' }
            default { $null }
        }
        if ($punctuation) {
            $tokens.Add([PSCustomObject]@{ Kind = $punctuation; Value = "$character"; Position = $index })
            $index++
            continue
        }

        if ($character -eq '"' -or $character -eq "'") {
            $quote = $character
            $start = $index
            $index++
            $builder = [System.Text.StringBuilder]::new()
            $closed = $false
            while ($index -lt $Rule.Length) {
                $current = $Rule[$index]
                if ($current -eq $quote) {
                    # Accept doubled quotes as a literal quote as well as the common
                    # backslash/backtick escape form handled below.
                    if (($index + 1) -lt $Rule.Length -and $Rule[$index + 1] -eq $quote) {
                        [void]$builder.Append($quote)
                        $index += 2
                        continue
                    }
                    $closed = $true
                    $index++
                    break
                }
                if (($current -eq '\' -or $current -eq '`') -and ($index + 1) -lt $Rule.Length) {
                    $escaped = $Rule[$index + 1]
                    if ($escaped -eq $quote -or $escaped -eq '\' -or $escaped -eq '`') {
                        [void]$builder.Append($escaped)
                        $index += 2
                        continue
                    }
                }
                [void]$builder.Append($current)
                $index++
            }
            if (-not $closed) { throw "Unterminated string literal at position $start." }
            $tokens.Add([PSCustomObject]@{ Kind = 'String'; Value = $builder.ToString(); Position = $start })
            continue
        }

        if ("$character" -match '^[A-Za-z0-9_.$-]$') {
            $start = $index
            while ($index -lt $Rule.Length -and "$($Rule[$index])" -match '^[A-Za-z0-9_.$-]$') { $index++ }
            $tokens.Add([PSCustomObject]@{ Kind = 'Word'; Value = $Rule.Substring($start, $index - $start); Position = $start })
            continue
        }

        throw "Unsupported character '$character' at position $index."
    }

    $tokens.Add([PSCustomObject]@{ Kind = 'End'; Value = ''; Position = $Rule.Length })
    return , $tokens
}

function ConvertTo-IACFilterAst {
    [CmdletBinding()]
    # PSScriptAnalyzer cannot trace the Tokens reference through the nested
    # recursive-descent parser functions below.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Tokens')]
    param(
        [Parameter(Mandatory)]
        [object[]]$Tokens
    )

    $state = [PSCustomObject]@{ Position = 0; Depth = 0 }
    $comparisonOperators = @('eq', 'ne', 'gt', 'ge', 'lt', 'le', 'in', 'notin', 'contains', 'notcontains', 'startswith')

    function Get-IACParserToken {
        return $Tokens[$state.Position]
    }

    function Read-IACParserToken {
        param([string]$Kind)
        $token = Get-IACParserToken
        if ($token.Kind -ne $Kind) { throw "Expected $Kind at position $($token.Position), found $($token.Kind)." }
        $state.Position++
        return $token
    }

    function Get-IACNormalizedOperator {
        param([string]$Value)
        $normalized = $Value.TrimStart([char]'-').ToLowerInvariant()
        switch ($normalized) {
            'equals' { 'eq' }
            'notequals' { 'ne' }
            'greaterthan' { 'gt' }
            'greaterthanorequals' { 'ge' }
            'lessthan' { 'lt' }
            'lessthanorequals' { 'le' }
            default { $normalized }
        }
    }

    function Test-IACLogicalToken {
        param([string]$Name)
        $token = Get-IACParserToken
        return $token.Kind -eq 'Word' -and (Get-IACNormalizedOperator -Value $token.Value) -eq $Name
    }

    function Read-IACScalarLiteral {
        $token = Get-IACParserToken
        if ($token.Kind -eq 'String') {
            $state.Position++
            return [PSCustomObject]@{ ValueType = 'String'; Value = $token.Value }
        }
        if ($token.Kind -eq 'Word') {
            $state.Position++
            if ($token.Value -ieq 'null' -or $token.Value -ieq '$null') {
                return [PSCustomObject]@{ ValueType = 'Null'; Value = $null }
            }
            return [PSCustomObject]@{ ValueType = 'Bare'; Value = $token.Value }
        }
        throw "Expected a scalar value at position $($token.Position)."
    }

    function Read-IACLiteral {
        if ((Get-IACParserToken).Kind -ne 'LeftBracket') { return Read-IACScalarLiteral }

        [void](Read-IACParserToken -Kind 'LeftBracket')
        $values = [System.Collections.Generic.List[object]]::new()
        if ((Get-IACParserToken).Kind -ne 'RightBracket') {
            $values.Add((Read-IACScalarLiteral))
            while ((Get-IACParserToken).Kind -eq 'Comma') {
                [void](Read-IACParserToken -Kind 'Comma')
                $values.Add((Read-IACScalarLiteral))
            }
        }
        [void](Read-IACParserToken -Kind 'RightBracket')
        return [PSCustomObject]@{ ValueType = 'Array'; Value = @($values) }
    }

    function Read-IACComparison {
        $property = Read-IACParserToken -Kind 'Word'
        if ($property.Value -notmatch '^(?i:device|app)\.[A-Za-z][A-Za-z0-9_]*$') {
            throw "Invalid assignment-filter property '$($property.Value)' at position $($property.Position)."
        }
        $operatorToken = Read-IACParserToken -Kind 'Word'
        $operator = Get-IACNormalizedOperator -Value $operatorToken.Value
        if ($comparisonOperators -notcontains $operator) {
            throw "Unsupported assignment-filter operator '$($operatorToken.Value)' at position $($operatorToken.Position)."
        }
        $literal = Read-IACLiteral
        return [PSCustomObject]@{
            NodeType = 'Comparison'
            Property = $property.Value
            Operator = $operator
            Literal  = $literal
        }
    }

    function Read-IACPrimary {
        if ((Get-IACParserToken).Kind -eq 'LeftParen') {
            $state.Depth++
            if ($state.Depth -gt 64) { throw 'Assignment filter nesting exceeds the 64-level safety limit.' }
            [void](Read-IACParserToken -Kind 'LeftParen')
            $expression = Read-IACOrExpression
            [void](Read-IACParserToken -Kind 'RightParen')
            $state.Depth--
            return $expression
        }
        return Read-IACComparison
    }

    function Read-IACAndExpression {
        $left = Read-IACPrimary
        while (Test-IACLogicalToken -Name 'and') {
            $state.Position++
            $left = [PSCustomObject]@{ NodeType = 'And'; Left = $left; Right = (Read-IACPrimary) }
        }
        return $left
    }

    function Read-IACOrExpression {
        $left = Read-IACAndExpression
        while (Test-IACLogicalToken -Name 'or') {
            $state.Position++
            $left = [PSCustomObject]@{ NodeType = 'Or'; Left = $left; Right = (Read-IACAndExpression) }
        }
        return $left
    }

    $ast = Read-IACOrExpression
    $remaining = Get-IACParserToken
    if ($remaining.Kind -ne 'End') { throw "Unexpected token '$($remaining.Value)' at position $($remaining.Position)." }
    return $ast
}

function Get-IACObjectProperty {
    param($InputObject, [string[]]$Names)

    foreach ($name in $Names) {
        if ($InputObject -is [System.Collections.IDictionary]) {
            foreach ($key in $InputObject.Keys) {
                if ("$key" -ieq $name) {
                    return [PSCustomObject]@{ Found = $true; Name = "$key"; Value = $InputObject[$key] }
                }
            }
        }
        elseif ($null -ne $InputObject) {
            $property = $InputObject.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1
            if ($property) { return [PSCustomObject]@{ Found = $true; Name = $property.Name; Value = $property.Value } }
        }
    }
    return [PSCustomObject]@{ Found = $false; Name = $null; Value = $null }
}

function Get-IACDeviceFilterValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Device,
        [Parameter(Mandatory)][string]$Property
    )

    # Intune filter property -> beta managedDevice/Entra device field mapping.
    # Only these documented properties are eligible for local evaluation.
    $propertyMap = @{
        cpuarchitecture        = @{ Sources = @('processorArchitecture', 'cpuArchitecture'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin') }
        devicecategory         = @{ Sources = @('deviceCategoryDisplayName', 'deviceCategory'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
        devicemanagementtype   = @{ Sources = @('deviceManagementType', 'deviceEnrollmentType'); Transform = 'ManagementType'; ValueType = 'String'; Operators = @('eq', 'ne') }
        devicename             = @{ Sources = @('deviceName', 'displayName'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
        deviceownership        = @{ Sources = @('managedDeviceOwnerType', 'deviceOwnership'); Transform = 'Ownership'; ValueType = 'String'; Operators = @('eq', 'ne') }
        devicetrusttype        = @{ Sources = @('joinType', 'trustType'); Transform = 'TrustType'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin') }
        enrollmentprofilename = @{ Sources = @('enrollmentProfileName'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
        isrooted               = @{ Sources = @('isRooted', 'jailBroken'); Transform = 'Rooted'; ValueType = 'String'; Operators = @('eq', 'ne') }
        manufacturer           = @{ Sources = @('manufacturer'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
        model                  = @{ Sources = @('model'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
        operatingsystemversion = @{ Sources = @('operatingSystemVersion', 'osVersion'); Transform = 'Identity'; ValueType = 'Version'; Operators = @('eq', 'ne', 'gt', 'ge', 'lt', 'le') }
        osversion              = @{ Sources = @('osVersion', 'operatingSystemVersion'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
        operatingsystemsku     = @{ Sources = @('operatingSystemSKU', 'skuNumber'); Transform = 'Sku'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') }
    }

    if ($Property -notmatch '^(?i:device)\.(?<name>[A-Za-z][A-Za-z0-9_]*)$') {
        return [PSCustomObject]@{ Known = $false; Reason = "Only documented managed-device properties are supported; '$Property' is not one." }
    }
    $propertyName = $Matches.name.ToLowerInvariant()
    if (-not $propertyMap.ContainsKey($propertyName)) {
        return [PSCustomObject]@{ Known = $false; Reason = "Unsupported managed-device filter property '$Property'." }
    }

    $definition = $propertyMap[$propertyName]
    $source = Get-IACObjectProperty -InputObject $Device -Names $definition.Sources
    if (-not $source.Found) {
        return [PSCustomObject]@{ Known = $false; Reason = "Device response does not contain a source field for '$Property'." }
    }
    if ($null -eq $source.Value) {
        return [PSCustomObject]@{ Known = $true; Value = $null; ValueType = $definition.ValueType; Operators = $definition.Operators; SourceProperty = $source.Name }
    }

    $rawValue = "$($source.Value)"
    $transformed = switch ($definition.Transform) {
        'Ownership' {
            switch -Regex ($rawValue) {
                '^(?i:company|corporate)$' { 'Corporate'; break }
                '^(?i:personal)$' { 'Personal'; break }
                '^(?i:unknown)$' { 'Unknown'; break }
                default { $null }
            }
        }
        'TrustType' {
            switch -Regex ($rawValue) {
                '^(?i:azuread|azureadjoined)$' { 'Azure AD joined'; break }
                '^(?i:workplace|azureadregistered)$' { 'Azure AD registered'; break }
                '^(?i:serverad|hybridazureadjoined)$' { 'Hybrid Azure AD joined'; break }
                '^(?i:unknown)$' { 'Unknown'; break }
                default { $null }
            }
        }
        'Rooted' {
            if ($source.Value -is [bool]) { if ($source.Value) { 'True' } else { 'False' } }
            elseif ($rawValue -match '^(?i:true|false|unknown)$') { $rawValue }
            else { $null }
        }
        'ManagementType' {
            $managementTypes = @{
                androidenterprisededicateddevice       = $null # shared mode cannot be inferred safely
                androidenterprisefullymanaged          = 'Corporate-owned fully managed'
                androidenterprisecorporateworkprofile  = 'Corporate-owned with work profile'
                androidaospuserlessdeviceenrollment    = 'AOSP userless devices'
                androidaospuserowneddeviceenrollment   = 'AOSP user-associated devices'
            }
            if ($managementTypes.ContainsKey($rawValue.ToLowerInvariant())) { $managementTypes[$rawValue.ToLowerInvariant()] }
            elseif ($rawValue -in $managementTypes.Values) { $rawValue }
            else { $null }
        }
        'Sku' {
            $skuByNumber = @{
                '4' = 'Enterprise'; '10' = 'Core'; '27' = 'EnterpriseN'; '48' = 'Professional'; '49' = 'BusinessN'
                '72' = 'EnterpriseEval'; '84' = 'EnterpriseNEval'; '98' = 'CoreN'; '99' = 'CoreCountrySpecific'
                '100' = 'CoreSingleLanguage'; '101' = 'Core'; '111' = 'Core'; '119' = 'PPIPro'; '121' = 'Education'
                '122' = 'EducationN'; '123' = 'IoTUAP'; '125' = 'EnterpriseS'; '126' = 'EnterpriseSN'
                '129' = 'EnterpriseSEval'; '131' = 'IoTUAPCommercial'; '136' = 'Holographic'
                '138' = 'ProfessionalSingleLanguage'; '161' = 'ProfessionalWorkstation'; '162' = 'ProfessionalN'
                '164' = 'ProfessionalEducation'; '165' = 'ProfessionalEducationN'; '171' = 'EnterpriseG'
                '172' = 'EnterpriseGN'; '175' = 'ServerRdsh'; '188' = 'IoTEnterprise'; '202' = 'CloudEditionN'
                '203' = 'CloudEdition'
            }
            $supportedSkus = @($skuByNumber.Values | Select-Object -Unique)
            if ($source.Name -ieq 'skuNumber') { $skuByNumber[$rawValue] }
            else { $supportedSkus | Where-Object { $_ -ieq $rawValue } | Select-Object -First 1 }
        }
        default { $source.Value }
    }

    if ($null -eq $transformed) {
        return [PSCustomObject]@{ Known = $false; Reason = "Device field '$($source.Name)' value '$rawValue' cannot be mapped safely to '$Property'." }
    }
    return [PSCustomObject]@{
        Known          = $true
        Value          = $transformed
        ValueType      = $definition.ValueType
        Operators      = $definition.Operators
        SourceProperty = $source.Name
    }
}

function Compare-IACVersionValue {
    param([AllowNull()]$Left, [AllowNull()]$Right)
    $pattern = '^\d+(?:\.\d+){0,15}$'
    if ($null -eq $Left -or $null -eq $Right -or "$Left" -notmatch $pattern -or "$Right" -notmatch $pattern) {
        return [PSCustomObject]@{ Known = $false; Comparison = 0 }
    }
    $leftParts = @("$Left" -split '\.' | ForEach-Object { [System.Numerics.BigInteger]::Parse($_) })
    $rightParts = @("$Right" -split '\.' | ForEach-Object { [System.Numerics.BigInteger]::Parse($_) })
    if ($leftParts.Count -ne $rightParts.Count) {
        return [PSCustomObject]@{ Known = $false; Comparison = 0 }
    }
    $length = $leftParts.Count
    for ($index = 0; $index -lt $length; $index++) {
        $leftPart = if ($index -lt $leftParts.Count) { $leftParts[$index] } else { [System.Numerics.BigInteger]::Zero }
        $rightPart = if ($index -lt $rightParts.Count) { $rightParts[$index] } else { [System.Numerics.BigInteger]::Zero }
        if ($leftPart -lt $rightPart) { return [PSCustomObject]@{ Known = $true; Comparison = -1 } }
        if ($leftPart -gt $rightPart) { return [PSCustomObject]@{ Known = $true; Comparison = 1 } }
    }
    return [PSCustomObject]@{ Known = $true; Comparison = 0 }
}

function Compare-IACFilterScalar {
    param($Actual, [string]$ValueType, $Literal, [string]$Operator)

    if ($Literal.ValueType -eq 'Array') { return 'Unknown' }
    $expected = $Literal.Value
    if ($null -eq $Actual -or $Literal.ValueType -eq 'Null') {
        if ($Operator -notin @('eq', 'ne')) { return 'Unknown' }
        $equal = $null -eq $Actual -and $Literal.ValueType -eq 'Null'
        if ($Operator -eq 'ne') { $equal = -not $equal }
        return $(if ($equal) { 'Match' } else { 'NotMatch' })
    }

    if ($ValueType -eq 'Version') {
        $comparison = Compare-IACVersionValue -Left $Actual -Right $expected
        if (-not $comparison.Known) { return 'Unknown' }
        $matched = switch ($Operator) {
            'eq' { $comparison.Comparison -eq 0 }
            'ne' { $comparison.Comparison -ne 0 }
            'gt' { $comparison.Comparison -gt 0 }
            'ge' { $comparison.Comparison -ge 0 }
            'lt' { $comparison.Comparison -lt 0 }
            'le' { $comparison.Comparison -le 0 }
            default { return 'Unknown' }
        }
        return $(if ($matched) { 'Match' } else { 'NotMatch' })
    }

    $actualText = "$Actual"
    $expectedText = "$expected"
    $matched = switch ($Operator) {
        'eq' { [string]::Equals($actualText, $expectedText, [System.StringComparison]::OrdinalIgnoreCase) }
        'ne' { -not [string]::Equals($actualText, $expectedText, [System.StringComparison]::OrdinalIgnoreCase) }
        'contains' { $actualText.IndexOf($expectedText, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 }
        'notcontains' { $actualText.IndexOf($expectedText, [System.StringComparison]::OrdinalIgnoreCase) -lt 0 }
        'startswith' { $actualText.StartsWith($expectedText, [System.StringComparison]::OrdinalIgnoreCase) }
        default { return 'Unknown' }
    }
    return $(if ($matched) { 'Match' } else { 'NotMatch' })
}

function Invoke-IACFilterAstEvaluation {
    param($Node, $Device, [System.Collections.Generic.List[string]]$Reasons)

    if ($Node.NodeType -eq 'And') {
        $left = Invoke-IACFilterAstEvaluation -Node $Node.Left -Device $Device -Reasons $Reasons
        $right = Invoke-IACFilterAstEvaluation -Node $Node.Right -Device $Device -Reasons $Reasons
        if ($left -eq 'NotMatch' -or $right -eq 'NotMatch') { return 'NotMatch' }
        if ($left -eq 'Unknown' -or $right -eq 'Unknown') { return 'Unknown' }
        return 'Match'
    }
    if ($Node.NodeType -eq 'Or') {
        $left = Invoke-IACFilterAstEvaluation -Node $Node.Left -Device $Device -Reasons $Reasons
        $right = Invoke-IACFilterAstEvaluation -Node $Node.Right -Device $Device -Reasons $Reasons
        if ($left -eq 'Match' -or $right -eq 'Match') { return 'Match' }
        if ($left -eq 'Unknown' -or $right -eq 'Unknown') { return 'Unknown' }
        return 'NotMatch'
    }

    $resolved = Get-IACDeviceFilterValue -Device $Device -Property $Node.Property
    if (-not $resolved.Known) {
        $Reasons.Add($resolved.Reason)
        return 'Unknown'
    }
    if ($resolved.Operators -notcontains $Node.Operator) {
        $Reasons.Add("Operator '$($Node.Operator)' is not documented for '$($Node.Property)'.")
        return 'Unknown'
    }
    if ($Node.Operator -in @('in', 'notin')) {
        if ($Node.Literal.ValueType -ne 'Array') {
            $Reasons.Add("Operator '$($Node.Operator)' requires an array value.")
            return 'Unknown'
        }
        $sawUnknown = $false
        $matched = $false
        foreach ($literal in @($Node.Literal.Value)) {
            $result = Compare-IACFilterScalar -Actual $resolved.Value -ValueType $resolved.ValueType -Literal $literal -Operator 'eq'
            if ($result -eq 'Match') { $matched = $true; break }
            if ($result -eq 'Unknown') { $sawUnknown = $true }
        }
        if (-not $matched -and $sawUnknown) { return 'Unknown' }
        if ($Node.Operator -eq 'notin') { $matched = -not $matched }
        return $(if ($matched) { 'Match' } else { 'NotMatch' })
    }

    $result = Compare-IACFilterScalar -Actual $resolved.Value -ValueType $resolved.ValueType -Literal $Node.Literal -Operator $Node.Operator
    if ($result -eq 'Unknown') {
        $Reasons.Add("Operator '$($Node.Operator)' or value type is not valid for '$($Node.Property)'.")
    }
    return $result
}

function Test-IACFilterPlatformCompatibility {
    param($Device, [string]$FilterPlatform)

    if ([string]::IsNullOrWhiteSpace($FilterPlatform)) {
        return [PSCustomObject]@{ Compatible = $true; Reason = $null }
    }
    $operatingSystemProperty = Get-IACObjectProperty -InputObject $Device -Names @('operatingSystem')
    if (-not $operatingSystemProperty.Found -or [string]::IsNullOrWhiteSpace("$($operatingSystemProperty.Value)")) {
        return [PSCustomObject]@{ Compatible = $false; Reason = "Device response does not contain operatingSystem required to validate filter platform '$FilterPlatform'." }
    }

    $platformOperatingSystems = @{
        android                   = @('Android')
        androidforwork            = @('Android')
        androidworkprofile        = @('Android')
        androidaosp               = @('Android')
        ios                       = @('iOS', 'iPadOS')
        macos                     = @('macOS')
        windows10andlater         = @('Windows')
        windows81andlater         = @('Windows')
        windowsphone81            = @('Windows Phone')
    }
    $key = $FilterPlatform.ToLowerInvariant()
    if (-not $platformOperatingSystems.ContainsKey($key)) {
        return [PSCustomObject]@{ Compatible = $false; Reason = "Unsupported assignment filter platform '$FilterPlatform'." }
    }
    if ($operatingSystemProperty.Value -notin $platformOperatingSystems[$key]) {
        return [PSCustomObject]@{
            Compatible = $false
            Reason = "Filter platform '$FilterPlatform' is not evaluated by Intune for device operating system '$($operatingSystemProperty.Value)'."
        }
    }
    return [PSCustomObject]@{ Compatible = $true; Reason = $null }
}

function Test-IACAssignmentFilter {
    [CmdletBinding(DefaultParameterSetName = 'Filter')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Filter')]
        $Filter,

        [Parameter(Mandatory, ParameterSetName = 'Rule')]
        [AllowEmptyString()]
        [string]$Rule,

        [Parameter(Mandatory)]
        [AllowNull()]
        $Device,

        [Parameter()]
        [string]$FilterMode = 'include'
    )

    $filterId = $filterName = $platform = $managementType = $null
    $ruleText = $Rule
    if ($PSCmdlet.ParameterSetName -eq 'Filter') {
        $filterId = (Get-IACObjectProperty -InputObject $Filter -Names @('Id')).Value
        $filterName = (Get-IACObjectProperty -InputObject $Filter -Names @('Name', 'DisplayName')).Value
        $platform = (Get-IACObjectProperty -InputObject $Filter -Names @('Platform')).Value
        $managementType = (Get-IACObjectProperty -InputObject $Filter -Names @('AssignmentFilterManagementType', 'ManagementType')).Value
        $ruleText = (Get-IACObjectProperty -InputObject $Filter -Names @('Rule')).Value
    }

    $mode = if ([string]::IsNullOrWhiteSpace($FilterMode)) { 'none' } else { $FilterMode.ToLowerInvariant() }
    $ruleResult = 'Unknown'
    $effectiveResult = 'Unknown'
    $reason = $null
    $platformCompatibility = Test-IACFilterPlatformCompatibility -Device $Device -FilterPlatform "$platform"

    if ($mode -eq 'none') {
        $ruleResult = $effectiveResult = 'Match'
        $reason = 'No assignment filter is applied.'
    }
    elseif ($mode -notin @('include', 'exclude')) {
        $reason = "Unsupported assignment filter mode '$FilterMode'."
    }
    elseif ($managementType -and "$managementType" -ine 'devices') {
        $reason = "Filter management type '$managementType' cannot be evaluated against a managed device."
    }
    elseif ($null -eq $Device) {
        $reason = 'Managed-device properties were not provided.'
    }
    elseif (-not $platformCompatibility.Compatible) {
        $reason = $platformCompatibility.Reason
    }
    elseif ([string]::IsNullOrWhiteSpace("$ruleText")) {
        $reason = 'The assignment filter rule is empty.'
    }
    else {
        try {
            $tokens = ConvertTo-IACTokenList -Rule "$ruleText"
            $ast = ConvertTo-IACFilterAst -Tokens $tokens
            $reasons = [System.Collections.Generic.List[string]]::new()
            $ruleResult = Invoke-IACFilterAstEvaluation -Node $ast -Device $Device -Reasons $reasons
            $effectiveResult = if ($mode -eq 'exclude') {
                switch ($ruleResult) {
                    'Match' { 'NotMatch' }
                    'NotMatch' { 'Match' }
                    default { 'Unknown' }
                }
            }
            else { $ruleResult }
            if ($ruleResult -eq 'Unknown') {
                $reason = @($reasons | Select-Object -Unique) -join ' '
                if ([string]::IsNullOrWhiteSpace($reason)) { $reason = 'The rule could not be evaluated safely.' }
            }
            else {
                $reason = "Rule evaluated to $ruleResult; $mode filter semantics produce $effectiveResult."
            }
        }
        catch {
            $reason = "Rule could not be parsed safely: $($_.Exception.Message)"
        }
    }

    $deviceId = (Get-IACObjectProperty -InputObject $Device -Names @('id')).Value
    $resolvedDeviceName = (Get-IACObjectProperty -InputObject $Device -Names @('deviceName', 'displayName')).Value
    $result = [PSCustomObject][ordered]@{
        Result         = $effectiveResult
        RuleResult     = $ruleResult
        DeviceId       = $deviceId
        DeviceName     = $resolvedDeviceName
        FilterMode     = $mode
        FilterId       = $filterId
        FilterName     = $filterName
        FilterPlatform = $platform
        ManagementType = $managementType
        Rule           = $ruleText
        Reason         = $reason
    }
    $result.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentFilterEvaluation')
    return $result
}