Src/Private/Export-IntuneBackup.ps1

#region --- JSON Backup Helpers ---

# Get-IntuneBackupSectionEnabled moved to Helpers.ps1 for deterministic load order

function Remove-IntuneSystemFields {
    <#
    .SYNOPSIS
    Strips non-restorable system fields from a Graph API response object.

    Removes fields that are auto-generated by Graph and must be absent when POSTing
    to create a new resource:
      id, createdDateTime, lastModifiedDateTime, version, @odata.etag,
      supportsScopeTags, deviceCount, deviceStatusCount, userStatusCount, runSummary

    .PARAMETER InputObject
        A single PSCustomObject from a Graph API response.
    .PARAMETER KeepId
        Retain the 'id' field (useful for cross-referencing, not for restore).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]$InputObject,
        [switch]$KeepId
    )
    process {
        # Deep clone via JSON round-trip to avoid mutating the original
        $obj = $InputObject | ConvertTo-Json -Depth 20 -Compress | ConvertFrom-Json

        $systemFields = @(
            'createdDateTime'
            'lastModifiedDateTime'
            'version'
            '@odata.etag'
            'supportsScopeTags'
            'deviceCount'
            'deviceStatusCount'
            'userStatusCount'
            'runSummary'
        )
        if (-not $KeepId) { $systemFields += 'id' }

        foreach ($field in $systemFields) {
            if ($obj.PSObject.Properties[$field]) {
                $obj.PSObject.Properties.Remove($field)
            }
        }
        return $obj
    }
}

function Get-IntuneBackupSafeFileName {
    <#
    .SYNOPSIS
    Converts a policy display name to a safe filename by removing invalid characters.
    #>

    [CmdletBinding()]
    param([string]$Name)
    # Remove chars invalid in Windows/macOS/Linux filenames
    $safe = $Name -replace '[\\/:*?"<>|]', '_'
    # Collapse multiple spaces/underscores
    $safe = $safe -replace '\s+', ' '
    $safe = $safe.Trim()
    # Limit length to avoid path length issues
    if ($safe.Length -gt 100) { $safe = $safe.Substring(0, 100).TrimEnd() }
    return $safe
}

function Export-IntuneBackupJson {
    <#
    .SYNOPSIS
    Exports collected Intune configuration as structured JSON backup files.

    .DESCRIPTION
        Creates a timestamped backup folder containing:
          - One subfolder per configuration category
          - One JSON file per policy/profile within each subfolder
          - A _manifest.json listing all exported items with counts and restore endpoints
          - A _metadata.json with export info and restore instructions

        Structure:
          Intune_Backup_<TenantDomain>_<DateTime>/
            _metadata.json
            _manifest.json
            CompliancePolicies/
              <PolicyName>.json
            ConfigurationProfiles/
              <ProfileName>.json
            ...

        Each individual JSON file is a direct Graph API POST body -- no modification
        needed to restore (when ExcludeSystemFields = true).

        Format chosen: JSON (not XML or CSV) because:
          - Microsoft Graph API natively accepts/returns JSON
          - No conversion needed to restore via Graph
          - Compatible with IntuneBackupAndRestore and Microsoft365DSC tooling
          - Line-based format works well with git diff for change tracking
          - ConvertTo-Json / ConvertFrom-Json are PowerShell built-ins (no extra modules)

    .PARAMETER BasePath
        Parent output folder. A timestamped subfolder is created inside it.
    .PARAMETER TenantName
        Tenant display name (used in metadata).
    .PARAMETER TenantDomain
        Tenant domain (used in metadata and folder name).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BasePath,

        [string]$TenantName   = '',
        [string]$TenantDomain = ''
    )

    if (-not $script:BackupData -or $script:BackupData.Count -eq 0) {
        Write-Host " - JSON backup: no data collected (all sections disabled or no licensed features)." -ForegroundColor DarkGray
        return
    }

    $excludeSystemFields = ($script:Options.JsonBackup.ExcludeSystemFields -ne $false)
    $timestamp           = Get-Date -Format 'yyyyMMdd_HHmmss'
    $backupFolderName    = "Intune_Backup_${TenantDomain}_${timestamp}"
    $backupRootPath      = Join-Path $BasePath $backupFolderName

    # Graph restore endpoints per category
    $RestoreEndpoints = @{
        CompliancePolicies      = 'POST https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies'
        ConfigurationProfiles   = 'POST https://graph.microsoft.com/v1.0/deviceManagement/deviceConfigurations'
        SettingsCatalog         = 'POST https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'
        AdminTemplates          = 'POST https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations'
        AppProtectionPolicies   = 'POST https://graph.microsoft.com/v1.0/deviceAppManagement/iosManagedAppProtections (iOS) | POST .../androidManagedAppProtections (Android)'
        EnrollmentRestrictions  = 'POST https://graph.microsoft.com/v1.0/deviceManagement/deviceEnrollmentConfigurations'
        AutopilotProfiles       = 'POST https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles'
        SecurityBaselines       = 'POST https://graph.microsoft.com/beta/deviceManagement/intents'
        EndpointSecurity        = 'POST https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'
        PowerShellScripts       = 'POST https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts'
        ShellScripts            = 'POST https://graph.microsoft.com/beta/deviceManagement/deviceShellScripts'
        ProactiveRemediations   = 'POST https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts'
    }

    Write-Host " - JSON backup: creating folder structure..." -ForegroundColor Cyan

    try {
        $null = New-Item -ItemType Directory -Path $backupRootPath -Force -ErrorAction Stop
    } catch {
        Write-Warning " - JSON backup: could not create folder '$backupRootPath': $($_.Exception.Message)"
        Write-AbrDebugLog "JSON backup folder creation failed: $($_.Exception.Message)" 'ERROR' 'BACKUP'
        return
    }

    $manifest = [System.Collections.ArrayList]::new()
    $totalFiles = 0

    # Process each category
    foreach ($categoryKey in $script:BackupData.Keys) {
        $rawItems = @($script:BackupData[$categoryKey])
        if (-not $rawItems -or $rawItems.Count -eq 0) { continue }

        # Create category subfolder
        $categoryPath = Join-Path $backupRootPath $categoryKey
        try {
            $null = New-Item -ItemType Directory -Path $categoryPath -Force -ErrorAction Stop
        } catch {
            Write-Warning " - JSON backup: could not create subfolder '$categoryKey': $($_.Exception.Message)"
            continue
        }

        $categoryFileCount = 0

        foreach ($item in $rawItems) {
            # Determine display name for filename
            $displayName = if ($item.displayName) { $item.displayName }
                           elseif ($item.name)    { $item.name }
                           else                   { "Item_$categoryFileCount" }

            $safeFileName = "$(Get-IntuneBackupSafeFileName -Name $displayName).json"
            $filePath     = Join-Path $categoryPath $safeFileName

            # Handle duplicate filenames (multiple policies with same name)
            if (Test-Path $filePath) {
                $safeFileName = "$(Get-IntuneBackupSafeFileName -Name $displayName)_$categoryFileCount.json"
                $filePath     = Join-Path $categoryPath $safeFileName
            }

            # Clean system fields if configured
            $exportObj = if ($excludeSystemFields) {
                Remove-IntuneSystemFields -InputObject $item
            } else {
                $item
            }

            try {
                $exportObj | ConvertTo-Json -Depth 30 |
                    Set-Content -Path $filePath -Encoding UTF8 -ErrorAction Stop
                $categoryFileCount++
                $totalFiles++
            } catch {
                Write-Warning " - JSON backup: failed to write '$safeFileName': $($_.Exception.Message)"
            }
        }

        Write-Host " - ${categoryKey}: $categoryFileCount file(s)" -ForegroundColor DarkGray
        Write-AbrDebugLog "JSON backup category '${categoryKey}': $categoryFileCount files" 'INFO' 'BACKUP'

        # Add to manifest
        $null = $manifest.Add([pscustomobject]@{
            Category        = $categoryKey
            ItemCount       = $categoryFileCount
            FolderPath      = $categoryKey
            RestoreEndpoint = if ($RestoreEndpoints[$categoryKey]) { $RestoreEndpoints[$categoryKey] } else { 'See Microsoft Graph documentation' }
        })
    }

    # Write _manifest.json
    $manifestPath = Join-Path $backupRootPath '_manifest.json'
    try {
        $manifest | ConvertTo-Json -Depth 5 |
            Set-Content -Path $manifestPath -Encoding UTF8 -ErrorAction Stop
    } catch {
        Write-Warning " - JSON backup: could not write manifest: $($_.Exception.Message)"
    }

    # Write _metadata.json
    $metadataPath = Join-Path $backupRootPath '_metadata.json'
    $metadata = [ordered]@{
        exportedAt           = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')
        tenantName           = $TenantName
        tenantDomain         = $TenantDomain
        moduleId             = 'AsBuiltReport.Microsoft.Intune'
        moduleVersion        = '0.1.0'
        totalCategories      = $script:BackupData.Count
        totalFiles           = $totalFiles
        excludeSystemFields  = $excludeSystemFields
        backupStructure      = 'One JSON file per policy/profile. Each file is a direct Graph API POST body when ExcludeSystemFields = true.'
        restoreInstructions  = [ordered]@{
            step1 = 'Open each JSON file in the category subfolder.'
            step2 = 'POST the file content to the Graph API endpoint listed in _manifest.json for that category.'
            step3 = 'Verify assignments reference valid Azure AD Group Object IDs (groups must exist in the target tenant).'
            step4 = 'For App Protection Policies, check the @odata.type field to determine whether to POST to iosManagedAppProtections or androidManagedAppProtections.'
            note  = 'Do NOT include the id field when restoring to a new tenant or creating new resources. It is stripped when ExcludeSystemFields = true.'
        }
        toolingCompatibility = @(
            'IntuneBackupAndRestore (dcklassen) -- folder structure compatible'
            'Microsoft365DSC -- JSON format compatible'
            'Microsoft Graph API -- direct POST body format'
            'Git -- line-based JSON diffs track per-policy changes over time'
        )
    }
    try {
        $metadata | ConvertTo-Json -Depth 10 |
            Set-Content -Path $metadataPath -Encoding UTF8 -ErrorAction Stop
    } catch {
        Write-Warning " - JSON backup: could not write metadata: $($_.Exception.Message)"
    }

    Write-Host " - JSON backup complete: $totalFiles file(s) in $($script:BackupData.Count) category/categories" -ForegroundColor Green
    Write-Host " - JSON backup folder: $backupRootPath" -ForegroundColor Green
    Write-TranscriptLog "JSON backup complete: $totalFiles files -> $backupRootPath" 'SUCCESS' 'BACKUP'
    Write-AbrDebugLog "JSON backup complete: $totalFiles files, folder: $backupRootPath" 'SUCCESS' 'BACKUP'
}

#endregion