Public/Sync-TCMDriftToMaester.ps1

function Sync-TCMDriftToMaester {
    <#
    .SYNOPSIS
        Bridge TCM drift detection to Maester's built-in drift tests (MT.1060).
    .DESCRIPTION
        Generates the drift folder structure that Maester's MT.1060 natively discovers.
        No Maester modifications needed — MT.1060 (shipped in Maester v2.0+) auto-discovers
        drift suites as subfolders containing baseline.json and current.json.

        TCM provides server-side baseline storage and automatic 6-hour monitoring,
        so there's no local state to manage. This cmdlet simply materializes the
        TCM drift state into files that Maester already knows how to test.

        Generated structure:
            <OutputPath>/
              TCM-<MonitorName>/
                baseline.json # Desired state (from TCM monitor baseline)
                current.json # Actual state (baseline + drift deltas)
                settings.json # Optional MT.1060 settings
                TCM-Drift.Tests.ps1 # Pester test discovered by Invoke-Maester

        Then just run: Invoke-Maester
        MT.1060 picks up the TCM drift suites automatically.

    .PARAMETER OutputPath
        The folder where drift suites will be written. Should be under or alongside
        your Maester test root so MT.1060 discovers them.
        Default priority: $env:MAESTER_TESTS_PATH/Drift → ./tests/Maester/Drift
    .PARAMETER MonitorId
        Sync drifts from a specific monitor. If omitted, syncs all active drifts.
    .PARAMETER IncludeFixed
        Also include recently fixed drifts (for audit trail).
    .PARAMETER CompareBaseline
        Also detect new/deleted resources by running Compare-TCMBaseline.
        This takes a fresh snapshot (uses quota) and generates an additional
        Maester test suite for baseline drift. Without this flag, only property
        drifts on existing monitored resources are synced.
    .PARAMETER PassThru
        Also return the drift summary objects.
    .EXAMPLE
        # Sync all TCM drifts — then run Maester normally
        Sync-TCMDriftToMaester
        Invoke-Maester

    .EXAMPLE
        # Full sync including new/deleted resources
        Sync-TCMDriftToMaester -CompareBaseline
        Invoke-Maester

    .EXAMPLE
        # Sync to a custom path and get summary
        $summary = Sync-TCMDriftToMaester -OutputPath "./my-tests/drift" -PassThru
        $summary | Where-Object { $_.DriftCount -gt 0 }
    #>

    [CmdletBinding()]
    param(
        [string]$OutputPath,

        [string]$MonitorId,

        [switch]$IncludeFixed,

        [switch]$CompareBaseline,

        [switch]$PassThru
    )

    # Resolve OutputPath: explicit param → env var → default
    if (-not $OutputPath) {
        $OutputPath = if ($env:MAESTER_TESTS_PATH) {
            Join-Path $env:MAESTER_TESTS_PATH 'Drift'
        } else {
            './tests/Maester/Drift'
        }
    }

    Write-Host '🔗 Syncing TCM drifts to Maester format...' -ForegroundColor Cyan

    # Step 1: Get monitors and their baselines
    $monitors = if ($MonitorId) {
        @(Get-TCMMonitor -Id $MonitorId -IncludeBaseline)
    }
    else {
        $allMonitors = Get-TCMMonitor
        if (-not $allMonitors) {
            Write-Warning 'No TCM monitors found. Create monitors first with New-TCMMonitor.'
            return
        }
        foreach ($m in $allMonitors) {
            Get-TCMMonitor -Id $m.id -IncludeBaseline
        }
    }

    # Step 2: Get active drifts
    $driftParams = @{ Status = 'active' }
    if ($MonitorId) { $driftParams.MonitorId = $MonitorId }
    $drifts = Get-TCMDrift @driftParams

    if ($IncludeFixed) {
        $fixedDrifts = Get-TCMDrift -Status fixed
        if ($MonitorId) { $fixedDrifts = $fixedDrifts | Where-Object { $_.MonitorId -eq $MonitorId } }
        $drifts = @($drifts) + @($fixedDrifts)
    }

    # Step 3: Create output directory
    if (-not (Test-Path $OutputPath)) {
        New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
    }

    # Step 4: Generate per-monitor drift suites
    $summaries = [System.Collections.Generic.List[object]]::new()

    foreach ($monitor in $monitors) {
        $monDisplayName = if ($monitor -is [System.Collections.IDictionary]) { $monitor['displayName'] } else { $monitor.displayName }
        $monId = if ($monitor -is [System.Collections.IDictionary]) { $monitor['id'] } else { $monitor.id }
        $monBaseline = if ($monitor -is [System.Collections.IDictionary]) { $monitor['baseline'] } else { $monitor.baseline }

        $monitorName = ($monDisplayName -replace '[^\w\-]', '_')
        $suitePath = Join-Path $OutputPath "TCM-$monitorName"

        if (-not (Test-Path $suitePath)) {
            New-Item -Path $suitePath -ItemType Directory -Force | Out-Null
        }

        # Build "baseline" from monitor's baseline
        $baselineData = @{}
        $baselineResources = if ($monBaseline -is [System.Collections.IDictionary]) { $monBaseline['resources'] } elseif ($monBaseline) { $monBaseline.resources } else { $null }
        if ($baselineResources) {
            foreach ($res in $baselineResources) {
                $resType = if ($res -is [System.Collections.IDictionary]) { $res['resourceType'] } else { $res.resourceType }
                $resDn = if ($res -is [System.Collections.IDictionary]) { $res['displayName'] } else { $res.displayName }
                $resProps = if ($res -is [System.Collections.IDictionary]) { $res['properties'] } else { $res.properties }
                # Flatten array properties to space-joined strings so Maester's
                # Compare-MtJsonObject does string comparison (shows actual values)
                # instead of ArraySizeMismatch (shows only counts).
                # Nested object arrays get JSON-serialized to preserve structure.
                $flatProps = @{}
                if ($resProps -is [System.Collections.IDictionary]) {
                    foreach ($pk in $resProps.Keys) {
                        $val = $resProps[$pk]
                        if ($val -is [Array] -and $val.Count -gt 0 -and ($val[0] -is [System.Collections.IDictionary] -or $val[0] -is [PSCustomObject])) {
                            $flatProps[$pk] = ($val | ConvertTo-Json -Depth 10 -Compress)
                        }
                        elseif ($val -is [Array]) { $flatProps[$pk] = $val -join ' ' }
                        else { $flatProps[$pk] = $val }
                    }
                }
                elseif ($resProps) {
                    foreach ($prop in $resProps.PSObject.Properties) {
                        $val = $prop.Value
                        if ($val -is [Array] -and $val.Count -gt 0 -and ($val[0] -is [System.Collections.IDictionary] -or $val[0] -is [PSCustomObject])) {
                            $flatProps[$prop.Name] = ($val | ConvertTo-Json -Depth 10 -Compress)
                        }
                        elseif ($val -is [Array]) { $flatProps[$prop.Name] = $val -join ' ' }
                        else { $flatProps[$prop.Name] = $val }
                    }
                }
                $key = "${resType}::${resDn}"
                $baselineData[$key] = @{
                    resourceType = $resType
                    displayName  = $resDn
                    properties   = $flatProps
                }
            }
        }

        # Build "current" by deep-copying baseline and applying drift deltas
        # NOTE: .Clone() is shallow — nested properties would be shared references.
        # Deep copy via JSON roundtrip to avoid mutating baselineData.
        $currentData = @{}
        foreach ($k in $baselineData.Keys) {
            $currentData[$k] = $baselineData[$k] | ConvertTo-Json -Depth 20 | ConvertFrom-Json -AsHashtable
        }
        $monitorDrifts = @($drifts | Where-Object { $_.MonitorId -eq $monId })

        foreach ($drift in $monitorDrifts) {
            $key = "$($drift.ResourceType)::$($drift.ResourceDisplay)"

            if ($currentData.ContainsKey($key)) {
                $current = $currentData[$key]
                # Override drifted properties with current (actual) values
                foreach ($dp in $drift.DriftedProperties) {
                    $current.properties[$dp.propertyName] = $dp.currentValue
                }
            }
            else {
                # Drift on a resource not in our baseline copy — create entry from drift data
                $props = @{}
                foreach ($dp in $drift.DriftedProperties) {
                    $props[$dp.propertyName] = $dp.currentValue
                }
                $currentData[$key] = @{
                    resourceType = $drift.ResourceType
                    displayName  = $drift.ResourceDisplay
                    properties   = $props
                }
            }
        }

        # Write baseline.json and current.json
        $baselineFile = Join-Path $suitePath 'baseline.json'
        $currentFile  = Join-Path $suitePath 'current.json'

        $baselineData | ConvertTo-Json -Depth 20 | Set-Content -Path $baselineFile -Encoding utf8
        $currentData  | ConvertTo-Json -Depth 20 | Set-Content -Path $currentFile -Encoding utf8

        # Write settings.json for MT.1060 (optional metadata)
        $settingsFile = Join-Path $suitePath 'settings.json'
        @{
            Source    = 'EasyTCM'
            MonitorId = $monitor.id
            SyncedAt  = (Get-Date -Format 'o')
        } | ConvertTo-Json | Set-Content -Path $settingsFile -Encoding utf8

        # Write Pester .Tests.ps1 so Invoke-Maester discovers this suite directly
        $escapedName = $monDisplayName -replace "'", "''"
        $testContent = @"
# Auto-generated by EasyTCM Sync-TCMDriftToMaester — do not edit
Describe 'MT.1060: TCM Drift - $escapedName' -Tag 'TCM', 'Drift', 'MT.1060' {
    It 'MT.1060: $escapedName has no configuration drift' {
        `$suitePath = `$PSScriptRoot
        `$baseline = Get-Content (Join-Path `$suitePath 'baseline.json') -Raw | ConvertFrom-Json -AsHashtable
        `$current = Get-Content (Join-Path `$suitePath 'current.json') -Raw | ConvertFrom-Json -AsHashtable

        `$drifted = [System.Collections.Generic.List[string]]::new()
        foreach (`$key in @(@(`$baseline.Keys) + @(`$current.Keys) | Select-Object -Unique)) {
            if (-not `$baseline.ContainsKey(`$key)) { `$drifted.Add(('| ``[NEW]`` | ``{0}`` |' -f `$key)); continue }
            if (-not `$current.ContainsKey(`$key)) { `$drifted.Add(('| ``[DELETED]`` | ``{0}`` |' -f `$key)); continue }
            `$bJson = `$baseline[`$key] | ConvertTo-Json -Depth 20 -Compress
            `$cJson = `$current[`$key] | ConvertTo-Json -Depth 20 -Compress
            if (`$bJson -ne `$cJson) { `$drifted.Add(('| ``[CHANGED]`` | ``{0}`` |' -f `$key)) }
        }

        `$desc = ("Compares the TCM monitor baseline with current tenant state for **$escapedName**.`n`nBaseline: ``{0}`` resources | Current: ``{1}`` resources" -f `$baseline.Count, `$current.Count)
        if (`$drifted.Count -eq 0) {
            Add-MtTestResultDetail -Description `$desc -Result "✅ All resources match the TCM baseline."
        } else {
            `$table = "| Status | Resource |`n|--------|----------|`n" + (`$drifted -join "`n")
            Add-MtTestResultDetail -Description `$desc -Result `$table
        }

        `$drifted | Should -HaveCount 0 -Because "all resources should match the TCM baseline"
    }
}
"@

        $testFile = Join-Path $suitePath 'TCM-Drift.Tests.ps1'
        Set-Content -Path $testFile -Value $testContent -Encoding utf8

        $summary = [PSCustomObject]@{
            MonitorName   = $monDisplayName
            MonitorId     = $monId
            SuitePath     = $suitePath
            BaselineFile  = $baselineFile
            CurrentFile   = $currentFile
            ResourceCount = $baselineData.Count
            DriftCount    = $monitorDrifts.Count
            DriftedProps  = ($monitorDrifts | ForEach-Object { $_.DriftedPropertyCount } | Measure-Object -Sum).Sum
        }
        $summaries.Add($summary)

        $driftIcon = if ($monitorDrifts.Count -gt 0) { '⚠️' } else { '✅' }
        Write-Host " $driftIcon $monDisplayName`: $($monitorDrifts.Count) drifts across $($baselineData.Count) resources" -ForegroundColor $(if ($monitorDrifts.Count -gt 0) { 'Yellow' } else { 'Green' })
    }

    # Set the environment variable that MT.1060 uses for drift folder discovery.
    # NOTE: Maester v2.0.0 has a typo — reads MEASTER_FOLDER_DRIFT instead of MAESTER.
    # Set both so it works with current and future (fixed) versions.
    $resolvedOutput = (Resolve-Path $OutputPath).Path
    $env:MEASTER_FOLDER_DRIFT = $resolvedOutput
    $env:MAESTER_FOLDER_DRIFT = $resolvedOutput

    # Summary
    $totalDrifts = ($summaries | Measure-Object -Property DriftCount -Sum).Sum
    Write-Host ''
    if ($totalDrifts -gt 0) {
        Write-Host "⚠️ $totalDrifts active drifts synced across $($summaries.Count) monitors." -ForegroundColor Yellow
    }
    else {
        Write-Host "✅ No active drifts. All $($summaries.Count) monitors are clean." -ForegroundColor Green
    }
    Write-Host " Drift folder: $resolvedOutput" -ForegroundColor DarkGray
    Write-Host " Run: Invoke-Maester -Path $resolvedOutput" -ForegroundColor DarkGray
    if ($env:MAESTER_TESTS_PATH) {
        Write-Host " Full suite: cd $($env:MAESTER_TESTS_PATH); Invoke-Maester" -ForegroundColor DarkGray
    }

    # Optional: run Compare-TCMBaseline and generate a baseline drift suite
    if ($CompareBaseline) {
        Write-Host ''
        Write-Host '🔗 Running baseline comparison (takes a snapshot)...' -ForegroundColor Cyan
        $compareParams = @{ Detailed = $true }
        if ($MonitorId) { $compareParams.MonitorId = $MonitorId }
        $comparison = Compare-TCMBaseline @compareParams

        if ($comparison -and ($comparison.NewCount -gt 0 -or $comparison.DeletedCount -gt 0)) {
            $blSuitePath = Join-Path $OutputPath 'TCM-BaselineDrift'
            if (-not (Test-Path $blSuitePath)) {
                New-Item -Path $blSuitePath -ItemType Directory -Force | Out-Null
            }

            # baseline.json = empty (no expected new/deleted resources)
            # current.json = new and deleted resources as entries
            $blBaseline = @{}
            $blCurrent = @{}

            foreach ($r in $comparison.NewResources) {
                $key = "NEW::$($r.ResourceType)::$($r.Id)"
                $blCurrent[$key] = @{
                    status       = 'New'
                    resourceType = $r.ResourceType
                    displayName  = $r.DisplayName
                    id           = $r.Id
                }
            }
            foreach ($r in $comparison.DeletedResources) {
                $key = "DELETED::$($r.ResourceType)::$($r.Id)"
                $blBaseline[$key] = @{
                    status       = 'Expected'
                    resourceType = $r.ResourceType
                    displayName  = $r.DisplayName
                    id           = $r.Id
                }
            }

            $blBaseline | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $blSuitePath 'baseline.json') -Encoding utf8
            $blCurrent  | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $blSuitePath 'current.json') -Encoding utf8
            @{
                Source    = 'EasyTCM-CompareBaseline'
                SyncedAt  = (Get-Date -Format 'o')
                NewCount  = $comparison.NewCount
                DeletedCount = $comparison.DeletedCount
            } | ConvertTo-Json | Set-Content -Path (Join-Path $blSuitePath 'settings.json') -Encoding utf8

            # Write Pester .Tests.ps1 for baseline drift
            $blTestContent = @"
# Auto-generated by EasyTCM Sync-TCMDriftToMaester — do not edit
Describe 'MT.1060: TCM Baseline Drift' -Tag 'TCM', 'BaselineDrift', 'MT.1060' {
    It 'MT.1060: No new or deleted resources detected' {
        `$suitePath = `$PSScriptRoot
        `$baseline = Get-Content (Join-Path `$suitePath 'baseline.json') -Raw | ConvertFrom-Json -AsHashtable
        `$current = Get-Content (Join-Path `$suitePath 'current.json') -Raw | ConvertFrom-Json -AsHashtable
        `$settings = Get-Content (Join-Path `$suitePath 'settings.json') -Raw | ConvertFrom-Json

        `$rows = [System.Collections.Generic.List[string]]::new()
        foreach (`$key in `$current.Keys) {
            `$r = `$current[`$key]
            `$type = if (`$r -is [System.Collections.IDictionary]) { `$r['resourceType'] } else { `$r.resourceType }
            `$name = if (`$r -is [System.Collections.IDictionary]) { `$r['displayName'] } else { `$r.displayName }
            `$id = if (`$r -is [System.Collections.IDictionary]) { `$r['id'] } else { `$r.id }
            if (-not `$baseline.ContainsKey(`$key)) { `$rows.Add(('| ➕ New | ``{0}`` | {1} | ``{2}`` |' -f `$type, `$name, `$id)) }
        }
        foreach (`$key in `$baseline.Keys) {
            `$r = `$baseline[`$key]
            `$type = if (`$r -is [System.Collections.IDictionary]) { `$r['resourceType'] } else { `$r.resourceType }
            `$name = if (`$r -is [System.Collections.IDictionary]) { `$r['displayName'] } else { `$r.displayName }
            `$id = if (`$r -is [System.Collections.IDictionary]) { `$r['id'] } else { `$r.id }
            if (-not `$current.ContainsKey(`$key)) { `$rows.Add(('| ❌ Deleted | ``{0}`` | {1} | ``{2}`` |' -f `$type, `$name, `$id)) }
        }

        `$desc = "Detects resources in the tenant that are **not tracked** by the TCM monitor baseline.`n`nNew resources may represent shadow configuration; deleted resources may indicate unauthorized removal."
        if (`$rows.Count -eq 0) {
            Add-MtTestResultDetail -Description `$desc -Result "✅ No untracked resources. Baseline is up to date."
        } else {
            `$table = "**`$(`$settings.NewCount) new, `$(`$settings.DeletedCount) deleted** resources detected:`n`n| Status | Type | Name | Id |`n|--------|------|------|----|`n" + (`$rows -join "`n")
            Add-MtTestResultDetail -Description `$desc -Result `$table
        }

        `$rows | Should -HaveCount 0 -Because "no untracked new or deleted resources should exist"
    }
}
"@

            Set-Content -Path (Join-Path $blSuitePath 'TCM-Drift.Tests.ps1') -Value $blTestContent -Encoding utf8

            Write-Host " ⚠️ Baseline drift: $($comparison.NewCount) new, $($comparison.DeletedCount) deleted → $blSuitePath" -ForegroundColor Yellow
        }
        else {
            Write-Host ' ✅ No baseline drift (no new or deleted resources).' -ForegroundColor Green
        }
    }
    else {
        Write-Host ''
        Write-Host 'Tip: Use -CompareBaseline to also detect new/deleted resources (takes a snapshot).' -ForegroundColor DarkGray
    }

    if ($PassThru) { $summaries }
}