Public/New-UTCMDriftReport.ps1

function New-UTCMDriftReport {
    <#
    .SYNOPSIS
        Generates a self-contained HTML drift report and a CSV from a configuration diff.
 
    .DESCRIPTION
        Produces a single HTML file (no external resources) with:
          - Summary cards (resources with drift, Changed, Missing, Added) and workload / type distribution.
          - A searchable, filterable, sortable, paged table of drifted resources. A resource that changed
            is reported once as "Changed" with a property-level baseline vs current comparison; the full
            JSON stays available as a secondary "Raw JSON" detail.
          - A "Full current state" section when -CurrentItems is provided.
          - Column chooser, CSV/JSON downloads, copy buttons, shareable filter state (URL hash), print layout.
 
        A modified resource appears in Compare-UTCMConfiguration output as two rows (one per side); the
        report pairs them. The companion CSV still lists one row per diff row
        (Id, DisplayName, Type, DriftType, NormalizedData). An empty -Diff produces a "No drift detected"
        report and a header-only CSV. Formula-like CSV text cells are prefixed with an apostrophe
        for spreadsheet safety; JSON and HTML values are not modified.
        Available snapshot metadata is retained in a companion diagnostics JSON file.
        Partial metadata produces a prominent incomplete-comparison warning instead of a
        clean no-drift conclusion.
 
    .PARAMETER Diff
        The diff array from Compare-UTCMConfiguration (may be empty).
 
    .PARAMETER SnapshotId
        GUID of the baseline snapshot (used in the report header and file name).
 
    .PARAMETER OutputPath
        Directory for the output files.
 
    .PARAMETER CurrentItems
        Full configurationItems array from the current-state snapshot. When provided,
        a "Full current state" section is added to the HTML report.
 
    .PARAMETER Open
        Open the HTML report in the default browser after generation.
 
    .PARAMETER SnapshotDiagnostics
        Baseline and comparison job metadata, including status, resources and errorDetails.
        Get-UTCMTenantDriftReport supplies both automatically. For manually generated partial
        reports, supply both jobs so the warning and diagnostics cover both sides.
 
    .OUTPUTS
        PSCustomObject with HtmlPath, CsvPath, Added, Missing, Changed, Opened.
 
    .EXAMPLE
        $diff = Compare-UTCMConfiguration -BaselineSnapshotId $id1 -CompareSnapshotId $id2
        New-UTCMDriftReport -Diff $diff -SnapshotId $id1 -OutputPath .\Reports
 
    .EXAMPLE
        # With the full current state included
        New-UTCMDriftReport -Diff $diff -SnapshotId $id -OutputPath .\Reports -CurrentItems $items -Open
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowNull()][AllowEmptyCollection()] $Diff,
        [Parameter(Mandatory)][string] $SnapshotId,
        [Parameter(Mandatory)][string] $OutputPath,

        # Full configuration items from the current-state snapshot
        $CurrentItems,

        [object[]] $SnapshotDiagnostics,

        # Open the generated HTML report in the default browser
        [switch] $Open
    )

    function _WriteLog([string]$msg, [string]$color='Gray') {
        if (Get-Command -Name Write-Log -ErrorAction SilentlyContinue) {
            Write-Log -Message $msg -Color $color
        } else {
            Write-Host ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $msg) -ForegroundColor $color
        }
    }

    # Resolve output directory using module helper if available
    $outDir = if (Get-Command -Name Resolve-OutputPath -ErrorAction SilentlyContinue) {
        Resolve-OutputPath -Path $OutputPath
    } else {
        if (-not (Test-Path -LiteralPath $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }
        $OutputPath
    }

    $htmlPath = Join-Path -Path $outDir -ChildPath ("UTCM-Drift-Report-{0}.html" -f $SnapshotId)
    $csvPath  = Join-Path -Path $outDir -ChildPath ("UTCM-Drift-Report-{0}.csv"  -f $SnapshotId)

    # Baseline snapshot metadata for the header (best effort)
    $baseline = $null
    try {
        $baseline = Get-UTCMSnapshot -SnapshotId $SnapshotId -IncludeDetails
    } catch {
        _WriteLog ("Warning: Could not fetch snapshot metadata for header (ID: $SnapshotId). Proceeding without details. Error: $($_.Exception.Message)") 'Yellow'
    }
    $baselineName     = [string](Get-UTCMObjectField $baseline @('displayName'))
    $baselineCreated  = Get-UTCMObjectField $baseline @('createdDateTime')
    $resourceLocation = [string](Get-UTCMObjectField $baseline @('resourceLocation'))
    $diagnostics = @($SnapshotDiagnostics | Where-Object { $null -ne $_ } |
        ForEach-Object { Get-UTCMSnapshotDiagnostic -Snapshot $_ })
    if ($baseline) {
        $baselineDiagnostic = Get-UTCMSnapshotDiagnostic -Snapshot $baseline
        if ($SnapshotId -notin @($diagnostics | ForEach-Object id)) {
            $diagnostics = @($baselineDiagnostic) + $diagnostics
        } elseif ($baselineDiagnostic.status -ne 'succeeded') {
            $diagnostics = @($baselineDiagnostic) + @($diagnostics | Where-Object id -ne $SnapshotId)
        }
    }
    $diagnosticsPath = Join-Path -Path $outDir -ChildPath ("UTCM-Drift-Report-{0}.diagnostics.json" -f $SnapshotId)
    Export-UTCMSnapshotDiagnostic -Diagnostic $diagnostics -Path $diagnosticsPath -Overwrite | Out-Null
    $incomplete = @($diagnostics | Where-Object { $_.status -ne 'succeeded' })
    if ($incomplete.Count -gt 0) {
        Write-Warning "INCOMPLETE COMPARISON: report differences are provisional, not reliable unattended drift conclusions. Diagnostics: $diagnosticsPath"
    }

    # One record per resource; a '<=' + '=>' pair becomes Changed
    $diffRows = @($Diff | Where-Object { $null -ne $_ })
    $records  = @(ConvertTo-UTCMDriftRecord -Diff $diffRows)
    $added    = @($records | Where-Object Status -eq 'Added').Count
    $missing  = @($records | Where-Object Status -eq 'Missing').Count
    $changed  = @($records | Where-Object Status -eq 'Changed').Count

    $html = Format-UTCMDriftReportHtml -Records $records -Diff $diffRows -SnapshotId $SnapshotId `
        -BaselineName $baselineName -BaselineCreated $baselineCreated -ResourceLocation $resourceLocation `
        -SnapshotDiagnostics $diagnostics `
        -CurrentItems $CurrentItems -GeneratedUtc ((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + ' UTC')

    $fullHtmlPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($htmlPath)
    [System.IO.File]::WriteAllText($fullHtmlPath, $html, (New-Object System.Text.UTF8Encoding($false)))

    if ($diffRows.Count -eq 0) {
        # Export-Csv emits nothing for empty input; keep the documented header row
        '"Id","DisplayName","Type","DriftType","NormalizedData"' | Out-File -LiteralPath $csvPath -Encoding UTF8
    } else {
        $diffRows |
            Select-Object `
                @{n='Id';e={ConvertTo-UTCMCsvCell $_.id}},
                @{n='DisplayName';e={ConvertTo-UTCMCsvCell $_.displayName}},
                @{n='Type';e={
                    if ($_.PSObject.Properties.Name -contains 'type') { ConvertTo-UTCMCsvCell $_.type }
                    elseif ($_.PSObject.Properties.Name -contains 'resourceType') { ConvertTo-UTCMCsvCell $_.resourceType }
                    else { $null }
                }},
                @{n='DriftType';e={ ConvertTo-UTCMCsvCell (Get-UTCMDriftTypeLabel $_.SideIndicator) }},
                @{n='NormalizedData';e={ConvertTo-UTCMCsvCell $_.normalizedData}} |
            Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
    }

    _WriteLog ("Dashboard written to: $htmlPath") 'Green'
    _WriteLog ("CSV written to: $csvPath") 'Green'

    # Open the HTML report if requested (cross-platform)
    if ($Open) {
        try {
            if ($IsWindows) {
                Start-Process -FilePath $htmlPath | Out-Null
            } elseif ($IsMacOS) {
                Start-Process -FilePath 'open' -ArgumentList @($htmlPath) | Out-Null
            } elseif ($IsLinux) {
                if (Get-Command -Name xdg-open -ErrorAction SilentlyContinue) {
                    Start-Process -FilePath 'xdg-open' -ArgumentList @($htmlPath) | Out-Null
                } else {
                    Start-Process -FilePath $htmlPath | Out-Null
                }
            } else {
                _WriteLog "Unknown platform; please open the report manually: $htmlPath" 'Yellow'
            }
        } catch {
            _WriteLog ("Failed to open the report automatically: $($_.Exception.Message)`nPath: $htmlPath") 'Yellow'
        }
    }

    [pscustomobject]@{
        HtmlPath = $htmlPath
        CsvPath  = $csvPath
        Added    = $added
        Missing  = $missing
        Changed  = $changed
        Opened   = [bool]$Open
    }
}