Private/ConvertTo-InforcerDocModel.ps1

function Get-InforcerCategoryKey {
    <#
    .SYNOPSIS
        Builds a category key from primaryGroup and secondaryGroup, deduplicating when appropriate.
    .DESCRIPTION
        Returns primaryGroup alone when secondaryGroup is null, empty, equal to primaryGroup,
        or equal to "All". Otherwise returns "primaryGroup / secondaryGroup".
        Used by ConvertTo-InforcerDocModel for deterministic category naming (per D-11).
    #>

    [CmdletBinding()]
    param(
        [Parameter()][string]$PrimaryGroup,
        [Parameter()][string]$SecondaryGroup
    )
    if ([string]::IsNullOrWhiteSpace($SecondaryGroup) -or
        $SecondaryGroup -eq $PrimaryGroup -or
        $SecondaryGroup -eq 'All') {
        return $PrimaryGroup
    }
    return "$PrimaryGroup / $SecondaryGroup"
}

function Get-InforcerPolicyName {
    <#
    .SYNOPSIS
        Resolves a policy display name using the D-13 fallback chain.
    .DESCRIPTION
        Applies: displayName -> friendlyName -> name -> policyData.name ->
        policyData.displayName -> "Policy {id}". Ensures no policy appears with a null name.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Policy)
    $name = $Policy.displayName -as [string]
    if ([string]::IsNullOrWhiteSpace($name)) { $name = $Policy.friendlyName -as [string] }
    if ([string]::IsNullOrWhiteSpace($name)) { $name = $Policy.name -as [string] }
    if ([string]::IsNullOrWhiteSpace($name) -and $Policy.policyData) {
        $name = $Policy.policyData.name -as [string]
        if ([string]::IsNullOrWhiteSpace($name)) { $name = $Policy.policyData.displayName -as [string] }
    }
    if ([string]::IsNullOrWhiteSpace($name)) {
        $idVal = $Policy.id
        $name = "Policy $(if ($null -ne $idVal) { $idVal } else { 'Unknown' })"
    }
    return $name
}

function ConvertTo-InforcerDocModel {
    <#
    .SYNOPSIS
        Normalizes raw Inforcer API data into a format-agnostic DocModel.
    .DESCRIPTION
        Takes a DocData bundle (from Get-InforcerDocData) and transforms it into the
        hierarchical DocModel structure: Products -> Categories -> Policies, each with
        Basics, Settings, and Assignments sections.

        Settings Catalog policies (policyTypeId 10) have their settingDefinitionIDs resolved
        to friendly names via ConvertTo-InforcerSettingRows. All other policy types have their
        policyData properties enumerated as flat Name/Value rows via ConvertTo-FlatSettingRows.

        The DocModel is format-agnostic: it contains only data, no rendering logic, and makes
        no API calls (per NORM-06).
    .PARAMETER DocData
        Hashtable from Get-InforcerDocData containing:
        Tenant, Baselines, Policies, TenantId, CollectedAt.
    .OUTPUTS
        Hashtable representing the DocModel tree (per D-10).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$DocData,

        [Parameter()]
        [hashtable]$GroupNameMap,

        [Parameter()]
        [hashtable]$FilterMap,

        [Parameter()]
        [hashtable]$ScopeTagMap
    )

    $tenant   = $DocData.Tenant
    $policies = $DocData.Policies
    $baselines = $DocData.Baselines

    # Tenant metadata
    $tenantName = ''
    if ($null -ne $tenant) {
        $tenantName = $tenant.tenantFriendlyName
        if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = $tenant.tenantDnsName }
        if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = "Tenant $($DocData.TenantId)" }
    }

    # Collect all baseline names for this tenant
    $baselineNames = @()
    if ($null -ne $baselines -and $baselines.Count -gt 0) {
        foreach ($bl in @($baselines)) {
            $blName = $bl.name
            if ([string]::IsNullOrWhiteSpace($blName)) { $blName = $bl.baselineGroupName }
            if (-not [string]::IsNullOrWhiteSpace($blName)) { $baselineNames += $blName }
        }
    }

    # Group policies by product -> category (per NORM-01, NORM-02, D-04, D-05)
    $products = [ordered]@{}

    foreach ($policy in @($policies)) {
        if ($null -eq $policy) { continue }

        $prod = $policy.product
        if ([string]::IsNullOrWhiteSpace($prod)) { $prod = 'Other' }

        # Normalize policy name (per NORM-05, D-13)
        $policyName = Get-InforcerPolicyName -Policy $policy

        # Map to friendly display name and Microsoft admin portal category
        $displayInfo = Get-InforcerPolicyDisplayInfo -PolicyName $policyName `
            -Product $prod -PrimaryGroup $policy.primaryGroup `
            -SecondaryGroup $policy.secondaryGroup -PolicyTypeId $policy.policyTypeId
        if ($displayInfo.FriendlyName) { $policyName = $displayInfo.FriendlyName }

        $catKey = if ($displayInfo.Category) {
            $displayInfo.Category
        } else {
            Get-InforcerCategoryKey -PrimaryGroup $policy.primaryGroup -SecondaryGroup $policy.secondaryGroup
        }
        if ([string]::IsNullOrWhiteSpace($catKey)) { $catKey = 'General' }

        # Ensure product and category exist (per NORM-02)
        if (-not $products.Contains($prod)) {
            $products[$prod] = @{ Categories = [ordered]@{} }
        }
        if (-not $products[$prod].Categories.Contains($catKey)) {
            $products[$prod].Categories[$catKey] = [System.Collections.Generic.List[object]]::new()
        }

        # Extract policy tags (baseline membership indicators)
        $policyTags = @()
        if ($null -ne $policy.tags -and @($policy.tags).Count -gt 0) {
            $policyTags = @($policy.tags | ForEach-Object {
                if ($_ -is [PSObject] -and $_.PSObject.Properties['name']) { $_.name } else { $_.ToString() }
            } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        }

        # Basics section (per NORM-04)
        $basics = @{
            Name        = $policyName
            Description = if ($policy.policyData -and $policy.policyData.description) { $policy.policyData.description } else { '' }
            ProfileType = if ($policy.inforcerPolicyTypeName) { $policy.inforcerPolicyTypeName } else { '' }
            Platform    = if ($policy.platform) { $policy.platform } else { '' }  # null ~96% per D-14
            Created     = if ($policy.policyData -and $policy.policyData.createdDateTime) { $policy.policyData.createdDateTime } else { '' }
            Modified    = if ($policy.policyData -and $policy.policyData.lastModifiedDateTime) { $policy.policyData.lastModifiedDateTime } else { '' }
            ScopeTags   = ''
            Tags        = if ($policyTags.Count -gt 0) { $policyTags -join ', ' } else { '' }
        }
        # Scope tags normalization (per D-15) -- resolve IDs to names when ScopeTagMap available
        $scopeTags = $policy.policyData.roleScopeTagIds
        if ($null -ne $scopeTags -and $scopeTags.Count -gt 0) {
            if ($ScopeTagMap -and $ScopeTagMap.Count -gt 0) {
                $resolvedTags = @($scopeTags | ForEach-Object {
                    $tagId = $_.ToString()
                    if ($ScopeTagMap.ContainsKey($tagId)) { $ScopeTagMap[$tagId] } else { $tagId }
                })
                $basics.ScopeTags = ($resolvedTags -join ', ')
            } else {
                $basics.ScopeTags = ($scopeTags -join ', ')
            }
        }

        # Settings section: route by policyTypeId (per D-06, D-07, SCAT-01..06)
        $settings = [System.Collections.Generic.List[object]]::new()
        $policyTypeId = $policy.policyTypeId

        if ($policyTypeId -eq 10 -and $policy.policyData -and $policy.policyData.settings) {
            # Settings Catalog -- use ConvertTo-InforcerSettingRows (per SCAT-01..06)
            foreach ($settingGroup in @($policy.policyData.settings)) {
                if ($null -ne $settingGroup -and $settingGroup.settingInstance) {
                    foreach ($row in (ConvertTo-InforcerSettingRows -SettingInstance $settingGroup.settingInstance)) {
                        [void]$settings.Add($row)
                    }
                }
            }
        } elseif ($null -ne $policy.policyData) {
            # Non-catalog -- use ConvertTo-FlatSettingRows (per D-07)
            foreach ($row in (ConvertTo-FlatSettingRows -PolicyData $policy.policyData)) {
                [void]$settings.Add($row)
            }
        }

        # Assignments section (per NORM-03) -- uses Resolve-InforcerAssignments
        $rawAssignments = $policy.policyData.assignments
        if ($null -eq $rawAssignments) { $rawAssignments = $policy.assignments }
        $resolveParams = @{ RawAssignments = $rawAssignments }
        if ($null -ne $GroupNameMap)  { $resolveParams['GroupNameMap'] = $GroupNameMap }
        if ($null -ne $FilterMap)     { $resolveParams['FilterMap'] = $FilterMap }
        $assignments = @(Resolve-InforcerAssignments @resolveParams)

        # Assemble normalized policy (per NORM-03)
        $normalizedPolicy = @{
            Basics      = $basics
            Settings    = $settings.ToArray()
            Assignments = $assignments
        }

        [void]$products[$prod].Categories[$catKey].Add($normalizedPolicy)
    }

    # Return the DocModel (per D-10, NORM-06)
    @{
        TenantName   = $tenantName
        TenantId     = $DocData.TenantId
        GeneratedAt  = $DocData.CollectedAt
        BaselineName = if ($baselineNames.Count -gt 0) { $baselineNames[0] } else { '' }
        Baselines    = $baselineNames
        Products     = $products
    }
}