Public/Import-IntuneBaseline.ps1

function Import-IntuneBaseline {
    <#
    .SYNOPSIS
        Imports OpenIntuneBaseline policies from bundled templates
    .DESCRIPTION
        Imports OpenIntuneBaseline policies from the Templates/OpenIntuneBaseline directory.
        Supports Settings Catalog, Device Configuration, Compliance, and Update policies.
    .PARAMETER BaselinePath
        Path to the OpenIntuneBaseline directory (defaults to Templates/OpenIntuneBaseline)
    .PARAMETER IntuneManagementPath
        Path to IntuneManagement module (will download if not specified)
    .PARAMETER TenantId
        Target tenant ID (uses connected tenant if not specified)
    .PARAMETER ImportMode
        Import mode: SkipIfExists (default - skip policies that already exist)
    .PARAMETER IncludeAssignments
        Include policy assignments during import
    .PARAMETER Platform
        Filter baseline imports by platform. Valid values: Windows, macOS, iOS, Android, All.
        Defaults to 'All' which imports all baseline policies regardless of platform.
        - Windows: Imports from WINDOWS/ and WINDOWS365/ folders
        - macOS: Imports from MACOS/ folder
        - iOS/Android: Imports from BYOD/ folder (app protection policies)
    .EXAMPLE
        Import-IntuneBaseline
    .EXAMPLE
        Import-IntuneBaseline -BaselinePath ./OpenIntuneBaseline -ImportMode SkipIfExists
    .EXAMPLE
        Import-IntuneBaseline -Platform Windows,macOS
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()]
        [string]$BaselinePath,

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [ValidateSet('Windows', 'macOS', 'iOS', 'Android', 'All')]
        [string[]]$Platform = @('All'),

        [Parameter()]
        [ValidateSet('SkipIfExists')]
        [string]$ImportMode = 'SkipIfExists',

        [Parameter()]
        [switch]$RemoveExisting
    )

    # Use connected tenant if not specified
    if (-not $TenantId -and $script:HydrationState.TenantId) {
        $TenantId = $script:HydrationState.TenantId
    }

    if (-not $TenantId) {
        throw "TenantId is required. Either connect using Connect-IntuneHydration or specify -TenantId parameter."
    }

    # Resolve BaselinePath to bundled templates if not provided
    if ($BaselinePath -and -not (Test-Path -Path $BaselinePath)) {
        Write-Verbose "Specified BaselinePath '$BaselinePath' not found, using bundled templates"
        $BaselinePath = $null
    }

    if (-not $BaselinePath) {
        if ($script:TemplatesPath -and (Test-Path -Path $script:TemplatesPath)) {
            $BaselinePath = Join-Path -Path $script:TemplatesPath -ChildPath 'OpenIntuneBaseline'
        } elseif ($script:ModuleRoot -and (Test-Path -Path $script:ModuleRoot)) {
            $BaselinePath = Join-Path -Path (Join-Path -Path $script:ModuleRoot -ChildPath 'Templates') -ChildPath 'OpenIntuneBaseline'
        } else {
            # Fallback: Calculate from this function's script file location
            $scriptPath = $MyInvocation.MyCommand.ScriptBlock.File
            if ($scriptPath) {
                $moduleRoot = Split-Path -Parent (Split-Path -Parent $scriptPath)
                $BaselinePath = Join-Path -Path (Join-Path -Path $moduleRoot -ChildPath 'Templates') -ChildPath 'OpenIntuneBaseline'
            } elseif (-not $RemoveExisting) {
                throw "Cannot determine OpenIntuneBaseline path. Please specify -BaselinePath parameter."
            }
        }
    }

    if (-not $RemoveExisting -and (-not $BaselinePath -or -not (Test-Path -Path $BaselinePath))) {
        throw "OpenIntuneBaseline templates not found at: $BaselinePath"
    }

    # OpenIntuneBaseline uses OS-based folder structure:
    # - OS/IntuneManagement/ - Exported by IntuneManagement tool (requires Windows GUI to import)
    # - OS/NativeImport/ - Settings Catalog policies that can be imported via Graph API
    # - BYOD/AppProtection/ - App protection policies

    # Map folder names to Graph API endpoints (includes aliases for different baseline versions)
    $endpointMap = @{
        'NativeImport'                     = 'deviceManagement/configurationPolicies'
        'AppProtection'                    = 'deviceAppManagement/managedAppPolicies'
        'Administrative Templates'         = 'deviceManagement/groupPolicyConfigurations'
        'Compliance'                       = 'deviceManagement/deviceCompliancePolicies'
        'Compliance Policies'              = 'deviceManagement/deviceCompliancePolicies'
        'Configuration Profiles'           = 'deviceManagement/deviceConfigurations'
        'Device Configuration'             = 'deviceManagement/deviceConfigurations'
        'Device Enrollment Configurations' = 'deviceManagement/deviceEnrollmentConfigurations'
        'Endpoint Security'                = 'deviceManagement/intents'
        'Settings Catalog'                 = 'deviceManagement/configurationPolicies'
        'Scripts'                          = 'deviceManagement/deviceManagementScripts'
        'Proactive Remediations'           = 'deviceManagement/deviceHealthScripts'
        'Windows Autopilot'                = 'deviceManagement/windowsAutopilotDeploymentProfiles'
        'App Configuration'                = 'deviceAppManagement/mobileAppConfigurations'
        'App Protection Policies'          = 'deviceAppManagement/managedAppPolicies'
    }

    # Map @odata.type to Graph API endpoints for IntuneManagement exports
    $odataTypeToEndpoint = @{
        # Device Configurations
        '#microsoft.graph.windowsHealthMonitoringConfiguration'         = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windows10GeneralConfiguration'                = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windows10EndpointProtectionConfiguration'     = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windows10CustomConfiguration'                 = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windowsDeliveryOptimizationConfiguration'     = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windowsUpdateForBusinessConfiguration'        = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windowsIdentityProtectionConfiguration'       = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windowsKioskConfiguration'                    = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.editionUpgradeConfiguration'                  = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.sharedPCConfiguration'                        = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windowsWifiConfiguration'                     = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.windowsWiredNetworkConfiguration'             = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.macOSGeneralDeviceConfiguration'              = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.macOSCustomConfiguration'                     = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.macOSEndpointProtectionConfiguration'         = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.iosGeneralDeviceConfiguration'                = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.iosCustomConfiguration'                       = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.androidGeneralDeviceConfiguration'            = 'deviceManagement/deviceConfigurations'
        '#microsoft.graph.androidWorkProfileGeneralDeviceConfiguration' = 'deviceManagement/deviceConfigurations'
        # Compliance Policies
        '#microsoft.graph.windows10CompliancePolicy'                    = 'deviceManagement/deviceCompliancePolicies'
        '#microsoft.graph.windows81CompliancePolicy'                    = 'deviceManagement/deviceCompliancePolicies'
        '#microsoft.graph.macOSCompliancePolicy'                        = 'deviceManagement/deviceCompliancePolicies'
        '#microsoft.graph.iosCompliancePolicy'                          = 'deviceManagement/deviceCompliancePolicies'
        '#microsoft.graph.androidCompliancePolicy'                      = 'deviceManagement/deviceCompliancePolicies'
        '#microsoft.graph.androidWorkProfileCompliancePolicy'           = 'deviceManagement/deviceCompliancePolicies'
        '#microsoft.graph.androidDeviceOwnerCompliancePolicy'           = 'deviceManagement/deviceCompliancePolicies'
        # Settings Catalog / Configuration Policies
        '#microsoft.graph.deviceManagementConfigurationPolicy'          = 'deviceManagement/configurationPolicies'
        # Windows Update for Business - Driver Updates
        '#microsoft.graph.windowsDriverUpdateProfile'                   = 'deviceManagement/windowsDriverUpdateProfiles'
        # App Protection Policies (BYOD baseline)
        '#microsoft.graph.androidManagedAppProtection'                  = 'deviceAppManagement/androidManagedAppProtections'
        '#microsoft.graph.iosManagedAppProtection'                      = 'deviceAppManagement/iosManagedAppProtections'
    }

    # Folders routed via @odata.type lookup instead of $endpointMap
    $intuneManagementFolders = @('IntuneManagement', 'AppProtection')

    # Folders to skip - NativeImport duplicates policies from IntuneManagement with fewer options
    $skipFolders = @('NativeImport')

    $results = @()

    # Check Windows Driver Update license upfront (cached for all driver update profiles)
    $hasDriverUpdateLicense = $null  # Lazy-loaded when needed

    # Remove existing baseline policies if requested
    # SAFETY: Only delete policies that have "Imported by Intune Hydration Kit" in description
    if ($RemoveExisting) {
        # Load template names to scope deletes to only policies this kit would create
        $knownTemplateNames = Get-TemplateDisplayNames -Path $BaselinePath -Recurse

        # Delete from all endpoints used by baselines
        # Shared endpoints always included; platform-specific endpoints scoped by -Platform
        $deleteEndpoints = @(
            'beta/deviceManagement/configurationPolicies',
            'beta/deviceManagement/deviceConfigurations',
            'beta/deviceManagement/deviceCompliancePolicies'
        )

        # Add platform-specific endpoints only when that platform is in scope
        if (-not $Platform -or $Platform -contains 'All' -or $Platform -contains 'Windows') {
            $deleteEndpoints += 'beta/deviceManagement/windowsDriverUpdateProfiles'
        }
        if (-not $Platform -or $Platform -contains 'All' -or $Platform -contains 'Android') {
            $deleteEndpoints += 'beta/deviceAppManagement/androidManagedAppProtections'
        }
        if (-not $Platform -or $Platform -contains 'All' -or $Platform -contains 'iOS') {
            $deleteEndpoints += 'beta/deviceAppManagement/iosManagedAppProtections'
        }

        # Collect all policies to delete across all endpoints.
        # Uses accumulation pattern instead of ProcessItems scriptblock to avoid
        # scope issue ($var += inside & {} creates a local copy that is discarded).
        $policiesToDelete = @()
        $escapedPrefix = [regex]::Escape($script:ImportPrefix)
        foreach ($endpoint in $deleteEndpoints) {
            try {
                $allPolicies = Get-GraphPagedResults -Uri $endpoint
                foreach ($policy in $allPolicies) {
                    $policyName = if ($policy.displayName) { $policy.displayName } elseif ($policy.name) { $policy.name } else { "Unknown" }

                    # Safety check: Only delete if created by this kit (has hydration marker in description)
                    if (-not (Test-HydrationKitObject -Description $policy.description -ObjectName $policyName)) {
                        Write-Verbose "Skipping '$policyName' - not created by Intune Hydration Kit"
                        continue
                    }

                    # Warn if policy name doesn't match current templates (may be from older version)
                    $baselineNameForLookup = $policyName -replace "^$escapedPrefix", ''
                    if ($knownTemplateNames -and -not ($knownTemplateNames.Contains($policyName) -or $knownTemplateNames.Contains($baselineNameForLookup))) {
                        Write-Verbose "Policy '$policyName' not in current templates (may be from an older baseline version) - deleting based on hydration kit marker"
                    }

                    $policiesToDelete += @{
                        Name = $policyName
                        Id   = $policy.id
                        Url  = "/$($endpoint -replace '^beta/', '')/$($policy.id)"
                    }
                }
            } catch {
                Write-Warning "Failed to list policies from $endpoint : $_"
            }
        }

        if ($policiesToDelete.Count -eq 0) {
            Write-Verbose "No baseline policies found to delete"
            return $results
        }

        # Handle WhatIf mode
        if (-not $PSCmdlet.ShouldProcess("$($policiesToDelete.Count) baseline policies", "Delete")) {
            if ($WhatIfPreference) {
                foreach ($policy in $policiesToDelete) {
                    Write-HydrationLog -Message " WouldDelete: $($policy.Name)" -Level Info
                    $results += New-HydrationResult -Name $policy.Name -Type 'BaselinePolicy' -Action 'WouldDelete' -Status 'DryRun'
                }
            }
            return $results
        }

        # Batch delete policies using centralized helper
        $results += Invoke-GraphBatchOperation -Items $policiesToDelete -Operation 'DELETE' -ResultType 'BaselinePolicy'

        return $results
    }

    # Find all policy type subfolders within OS folders (WINDOWS, MACOS, BYOD, WINDOWS365)
    # OpenIntuneBaseline structure: OS/PolicyType/policy.json

    # Platform to folder mapping for filtering
    $platformFolderMapping = @{
        'Windows' = @('WINDOWS', 'WINDOWS365', 'Windows', 'Windows365')
        'macOS'   = @('MACOS', 'macOS', 'MacOS')
        'iOS'     = @('BYOD', 'byod')
        'Android' = @('BYOD', 'byod')
    }

    $osFolders = Get-ChildItem -Path $BaselinePath -Directory | Where-Object {
        $_.Name -notmatch '^\.'
    }

    # Filter OS folders by platform if specified
    if ($Platform -and $Platform -notcontains 'All') {
        $allowedFolders = @()
        foreach ($plat in $Platform) {
            if ($platformFolderMapping.ContainsKey($plat)) {
                $allowedFolders += $platformFolderMapping[$plat]
            }
        }
        $allowedFolders = $allowedFolders | Select-Object -Unique

        $osFolders = $osFolders | Where-Object {
            $folderName = $_.Name
            $allowedFolders -contains $folderName
        }

        Write-Verbose "Platform filter active: Processing folders: $($osFolders.Name -join ', ')"
    }

    $totalPolicies = 0
    $policyTypefolders = @()

    foreach ($osFolder in $osFolders) {
        # Get policy type subfolders within each OS folder
        $subFolders = Get-ChildItem -Path $osFolder.FullName -Directory | Where-Object {
            $_.Name -notmatch '^\.' -and (Get-ChildItem -Path $_.FullName -Filter "*.json" -File -Recurse).Count -gt 0
        }

        foreach ($subFolder in $subFolders) {
            $jsonFiles = Get-ChildItem -Path $subFolder.FullName -Filter "*.json" -File -Recurse
            $totalPolicies += $jsonFiles.Count
            $policyTypefolders += @{
                Folder     = $subFolder
                OsFolder   = $osFolder.Name
                PolicyType = $subFolder.Name
            }
        }
    }

    if ($PSCmdlet.ShouldProcess("$totalPolicies policies from OpenIntuneBaseline", "Import to Intune")) {
        # Pre-fetch existing policies from all unique endpoints to avoid repeated API calls
        $endpointPolicyCache = @{}
        $uniqueEndpoints = $odataTypeToEndpoint.Values | Sort-Object -Unique
        foreach ($cacheEndpoint in $uniqueEndpoints) {
            $endpointPolicyCache[$cacheEndpoint] = @{}
            try {
                Get-GraphPagedResults -Uri "beta/$cacheEndpoint" -ProcessItems {
                    param($items)
                    foreach ($policy in $items) {
                        # Use 'name' for configurationPolicies, 'displayName' for others
                        $policyDisplayName = if ($cacheEndpoint -eq 'deviceManagement/configurationPolicies') {
                            $policy.name
                        } else {
                            if ($policy.displayName) { $policy.displayName } elseif ($policy.name) { $policy.name } else { $null }
                        }
                        if ($policyDisplayName -and -not $endpointPolicyCache[$cacheEndpoint].ContainsKey($policyDisplayName)) {
                            $endpointPolicyCache[$cacheEndpoint][$policyDisplayName] = $policy.id
                        }
                    }
                }
            } catch {
                # Endpoint might not support listing, continue without cache for this endpoint
                Write-Verbose "Could not cache policies from $cacheEndpoint - will check individually"
            }
        }

        # Collect all policies to create with their prepared bodies
        $policiesToCreate = @()

        foreach ($policyFolder in $policyTypefolders) {
            $folder = $policyFolder.Folder
            $folderName = $policyFolder.PolicyType
            $osName = $policyFolder.OsFolder

            # Skip folders that duplicate content from other folders (e.g., NativeImport duplicates IntuneManagement)
            if ($folderName -in $skipFolders) {
                Write-Verbose "Skipping $osName/$folderName - duplicates content from IntuneManagement folder"
                continue
            }

            $jsonFiles = Get-ChildItem -Path $folder.FullName -Filter "*.json" -File -Recurse

            # For IntuneManagement folders, try to import using @odata.type routing
            if ($folderName -in $intuneManagementFolders) {
                foreach ($jsonFile in $jsonFiles) {
                    $policyName = [System.IO.Path]::GetFileNameWithoutExtension($jsonFile.Name)

                    try {
                        # Read JSON content and replace %OrganizationId% placeholder with actual tenant ID
                        $jsonContent = Get-Content -Path $jsonFile.FullName -Raw
                        if ($jsonContent -match '%OrganizationId%') {
                            Write-Verbose "Replacing %OrganizationId% with tenant ID in $policyName"
                            $jsonContent = $jsonContent -replace '%OrganizationId%', $TenantId
                        }
                        $policyContent = $jsonContent | ConvertFrom-Json
                        $odataType = $policyContent.'@odata.type'

                        # Determine endpoint from @odata.type
                        $typeEndpoint = $odataTypeToEndpoint[$odataType]
                        if (-not $typeEndpoint) {
                            Write-Warning " Skipping $policyName - unsupported @odata.type: $odataType"
                            $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Skipped' -Status "Unsupported @odata.type: $odataType"
                            continue
                        }

                        # Check for Windows Driver Update license requirement
                        if ($typeEndpoint -eq 'deviceManagement/windowsDriverUpdateProfiles') {
                            # Lazy-load the license check (only check once)
                            if ($null -eq $hasDriverUpdateLicense) {
                                Write-Verbose "Checking Windows Driver Update license..."
                                $hasDriverUpdateLicense = Test-WindowsDriverUpdateLicense
                                if (-not $hasDriverUpdateLicense) {
                                    Write-HydrationLog -Message "Windows Driver Update profiles require additional licensing (Windows E3/E5, M365 Business Premium, etc.)" -Level Warning
                                }
                            }

                            if (-not $hasDriverUpdateLicense) {
                                Write-HydrationLog -Message " Skipped: $policyName - Missing Windows Driver Update license" -Level Warning
                                $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Skipped' -Status 'Missing Windows Driver Update license (requires Windows E3/E5, M365 Business Premium, or equivalent)'
                                continue
                            }
                        }

                        # Get display name with import prefix
                        $displayName = if ($policyContent.displayName) {
                            "$($script:ImportPrefix)$($policyContent.displayName)"
                        } else {
                            "$($script:ImportPrefix)$policyName"
                        }

                        # Check if policy exists using pre-fetched cache
                        $existingPolicy = $endpointPolicyCache[$typeEndpoint].ContainsKey($displayName)

                        if ($existingPolicy -and $ImportMode -eq 'SkipIfExists') {
                            Write-HydrationLog -Message " Skipped: $displayName" -Level Info
                            $results += New-HydrationResult -Name $displayName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Skipped' -Status 'Already exists'
                            continue
                        }

                        # Prepare import body - remove read-only and assignment properties
                        $importBody = Copy-DeepObject -InputObject $policyContent
                        Remove-ReadOnlyGraphProperties -InputObject $importBody -AdditionalProperties @(
                            'supportsScopeTags', 'deviceManagementApplicabilityRuleOsEdition',
                            'deviceManagementApplicabilityRuleOsVersion',
                            'deviceManagementApplicabilityRuleDeviceMode',
                            '@odata.id', '@odata.editLink',
                            'creationSource', 'settingCount', 'priorityMetaData',
                            'assignments', 'settingDefinitions', 'isAssigned'
                        )

                        # Add hydration kit tag to description
                        $importBody.description = New-HydrationDescription -ExistingText $importBody.description

                        # Apply import prefix to body properties
                        if ($importBody.displayName) { $importBody.displayName = $displayName }
                        if ($importBody.name) { $importBody.name = $displayName }

                        # Remove properties with @odata annotations (metadata) except @odata.type
                        # Also remove #microsoft.graph.* action properties
                        $metadataProps = @($importBody.PSObject.Properties | Where-Object {
                                ($_.Name -match '^@odata\.' -and $_.Name -ne '@odata.type') -or
                                ($_.Name -match '@odata\.') -or
                                ($_.Name -match '^#microsoft\.graph\.')
                            })
                        foreach ($prop in $metadataProps) {
                            if ($prop.Name -ne '@odata.type') {
                                $importBody.PSObject.Properties.Remove($prop.Name)
                            }
                        }

                        # Special handling for Settings Catalog (configurationPolicies)
                        if ($typeEndpoint -eq 'deviceManagement/configurationPolicies') {
                            Write-Verbose " Processing Settings Catalog policy: $displayName"

                            # Build a clean body with only the required properties
                            $cleanBody = @{
                                name         = $importBody.name
                                description  = $importBody.description
                                platforms    = $importBody.platforms
                                technologies = $importBody.technologies
                                settings     = @()
                            }

                            # Add optional properties if present
                            if ($importBody.roleScopeTagIds) {
                                $cleanBody.roleScopeTagIds = $importBody.roleScopeTagIds
                            }
                            if ($importBody.templateReference -and $importBody.templateReference.templateId) {
                                $cleanBody.templateReference = @{
                                    templateId = $importBody.templateReference.templateId
                                }
                            }

                            # Clean settings - remove id and odata navigation properties from each setting
                            if ($importBody.settings) {
                                foreach ($setting in $importBody.settings) {
                                    $cleanSetting = $setting | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json

                                    # Remove read-only properties from the setting
                                    $propsToRemove = @($cleanSetting.PSObject.Properties | Where-Object {
                                            $_.Name -eq 'id' -or $_.Name -match '@odata\.' -or $_.Name -eq 'settingDefinitions'
                                        })
                                    foreach ($prop in $propsToRemove) {
                                        $cleanSetting.PSObject.Properties.Remove($prop.Name)
                                    }

                                    $cleanBody.settings += $cleanSetting
                                }
                            }

                            $importBody = [PSCustomObject]$cleanBody
                        }

                        # Clean up scheduledActionsForRule - remove nested @odata.context and IDs
                        if ($importBody.scheduledActionsForRule) {
                            $cleanedActions = @()
                            foreach ($action in $importBody.scheduledActionsForRule) {
                                $cleanAction = @{
                                    ruleName = $action.ruleName
                                }
                                if ($action.scheduledActionConfigurations) {
                                    $cleanConfigs = @()
                                    foreach ($config in $action.scheduledActionConfigurations) {
                                        # Ensure notificationMessageCCList is always an array, never null
                                        $ccList = @()
                                        if ($null -ne $config.notificationMessageCCList -and $config.notificationMessageCCList.Count -gt 0) {
                                            $ccList = @($config.notificationMessageCCList)
                                        }
                                        $cleanConfig = @{
                                            actionType                = $config.actionType
                                            gracePeriodHours          = [int]$config.gracePeriodHours
                                            notificationTemplateId    = if ($config.notificationTemplateId) { $config.notificationTemplateId } else { "" }
                                            notificationMessageCCList = $ccList
                                        }
                                        $cleanConfigs += $cleanConfig
                                    }
                                    $cleanAction.scheduledActionConfigurations = $cleanConfigs
                                }
                                $cleanedActions += $cleanAction
                            }
                            $importBody.scheduledActionsForRule = $cleanedActions
                        }

                        # Add to collection for batch creation
                        # Store body as JSON string to avoid PowerShell serialization issues with circular references
                        $policiesToCreate += @{
                            Name     = $displayName
                            Path     = $jsonFile.FullName
                            Type     = "$osName/$folderName"
                            Url      = "/$typeEndpoint"
                            BodyJson = ($importBody | ConvertTo-Json -Depth 100 -Compress)
                        }
                    } catch {
                        $errorMsg = Get-GraphErrorMessage -ErrorRecord $_
                        Write-HydrationLog -Message " Failed to prepare: $policyName - $errorMsg" -Level Warning
                        $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Failed' -Status "Prepare error: $errorMsg"
                    }
                }
                continue
            }

            # Determine API endpoint based on policy type folder name
            $endpoint = $endpointMap[$folderName]
            if (-not $endpoint) {
                Write-Warning "No endpoint mapping for folder: $osName/$folderName - skipping"
                foreach ($jsonFile in $jsonFiles) {
                    $policyName = [System.IO.Path]::GetFileNameWithoutExtension($jsonFile.Name)
                    $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Skipped' -Status "No endpoint mapping for $folderName"
                }
                continue
            }

            # Pre-fetch existing policies for this endpoint to avoid repeated API calls (page through all results)
            $existingPolicies = @{}
            try {
                Get-GraphPagedResults -Uri "beta/$endpoint" -ProcessItems {
                    param($items)
                    foreach ($policy in $items) {
                        $policyDisplayName = if ($policy.displayName) { $policy.displayName } elseif ($policy.name) { $policy.name } else { $null }
                        if ($policyDisplayName -and -not $existingPolicies.ContainsKey($policyDisplayName)) {
                            $existingPolicies[$policyDisplayName] = $policy.id
                        }
                    }
                }
            } catch {
                # Endpoint might not support listing, continue without cache
                Write-Verbose "Could not cache policies from $endpoint - will check individually"
            }

            foreach ($jsonFile in $jsonFiles) {
                $policyName = [System.IO.Path]::GetFileNameWithoutExtension($jsonFile.Name)

                try {
                    # Read JSON content and replace %OrganizationId% placeholder with actual tenant ID
                    $jsonContent = Get-Content -Path $jsonFile.FullName -Raw
                    if ($jsonContent -match '%OrganizationId%') {
                        Write-Verbose "Replacing %OrganizationId% with tenant ID in $policyName"
                        $jsonContent = $jsonContent -replace '%OrganizationId%', $TenantId
                    }
                    $policyContent = $jsonContent | ConvertFrom-Json

                    # Get display name from policy with import prefix
                    $displayName = if ($policyContent.displayName) {
                        "$($script:ImportPrefix)$($policyContent.displayName)"
                    } elseif ($policyContent.name) {
                        "$($script:ImportPrefix)$($policyContent.name)"
                    } else {
                        "$($script:ImportPrefix)$policyName"
                    }

                    # Check if policy exists using cached list
                    $existingPolicy = $existingPolicies.ContainsKey($displayName)

                    if ($existingPolicy -and $ImportMode -eq 'SkipIfExists') {
                        Write-HydrationLog -Message " Skipped: $displayName" -Level Info
                        $results += New-HydrationResult -Name $displayName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Skipped' -Status 'Already exists'
                        continue
                    }

                    # Clean up import properties that shouldn't be sent
                    $importBody = Copy-DeepObject -InputObject $policyContent

                    # Remove read-only and system properties
                    Remove-ReadOnlyGraphProperties -InputObject $importBody -AdditionalProperties @(
                        'supportsScopeTags', 'deviceManagementApplicabilityRuleOsEdition',
                        'deviceManagementApplicabilityRuleOsVersion',
                        'deviceManagementApplicabilityRuleDeviceMode',
                        'creationSource', 'settingCount', 'priorityMetaData'
                    )

                    # Add hydration kit tag to description
                    $importBody.description = New-HydrationDescription -ExistingText $importBody.description

                    # Apply import prefix to body properties
                    if ($importBody.displayName) { $importBody.displayName = $displayName }
                    if ($importBody.name) { $importBody.name = $displayName }

                    # Add to collection for batch creation
                    # Store body as JSON string to avoid PowerShell serialization issues with circular references
                    $policiesToCreate += @{
                        Name     = $displayName
                        Path     = $jsonFile.FullName
                        Type     = "$osName/$folderName"
                        Url      = "/$endpoint"
                        BodyJson = ($importBody | ConvertTo-Json -Depth 100 -Compress)
                    }
                } catch {
                    $errorMsg = Get-GraphErrorMessage -ErrorRecord $_
                    Write-HydrationLog -Message " Failed to prepare: $policyName - $errorMsg" -Level Warning
                    $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'Failed' -Status "Prepare error: $errorMsg"
                }
            }
        }

        # Batch create all collected policies using centralized helper
        if ($policiesToCreate.Count -gt 0) {
            $results += Invoke-GraphBatchOperation -Items $policiesToCreate -Operation 'POST' -ResultType 'BaselinePolicy'
        }

    } else {
        # WhatIf mode - just report what would be imported
        foreach ($policyFolder in $policyTypefolders) {
            $folder = $policyFolder.Folder
            $osName = $policyFolder.OsFolder
            $folderName = $policyFolder.PolicyType

            # Skip folders that duplicate content from other folders
            if ($folderName -in $skipFolders) {
                continue
            }

            $jsonFiles = Get-ChildItem -Path $folder.FullName -Filter "*.json" -File -Recurse

            foreach ($jsonFile in $jsonFiles) {
                $policyName = [System.IO.Path]::GetFileNameWithoutExtension($jsonFile.Name)

                $results += New-HydrationResult -Name "$($script:ImportPrefix)$policyName" -Path $jsonFile.FullName -Type "$osName/$folderName" -Action 'WouldCreate' -Status 'DryRun'
            }
        }
    }

    return $results
}