private/core/Convert-ZtAssessmentToWorkshop.ps1

function Convert-ZtAssessmentToWorkshop {
    <#
    .SYNOPSIS
        Projects Zero Trust Assessment test results onto Zero Trust Workshop tasks.
 
    .DESCRIPTION
        Projects Zero Trust Assessment test results onto Zero Trust Workshop tasks
        that can be directly imported in Zero Trust Workshop.
 
        For every assessed test it:
          * skips tests whose TestStatus is "Skipped" (no assessed result),
          * resolves the test's pillar and looks up the TestId in the task mapping to find
            the Workshop task id(s) it contributes evidence to,
          * extracts the headline finding from TestResult,
          * records a per-task override (status = 'not-reviewed') carrying the assessment
            finding as notes.
 
        The result is the Workshop import document (metadata / configuration / statistics)
        that the Zero Trust Workshop application consumes.
 
    .PARAMETER AssessmentResults
        The assessment results object from Get-ZtAssessmentResults. Must expose a Tests
        collection plus TenantId / TenantName.
 
    .PARAMETER MappingFilePath
        Path to the pillar -> { TestId -> WorkshopTaskId } mapping file
        (build/ztw-task-mapping.json). A single TestId can map to several Workshop
        tasks by using an array of task ids.
 
    .PARAMETER Pillar
        Optional pillar filter. When supplied only that pillar's tests are exported and the
        document scope / current pillar reflect the selection.
 
    .PARAMETER KnownPillars
        The full set of Workshop pillars to initialise in the output document.
 
    .OUTPUTS
        [ordered] Workshop import document, ready for ConvertTo-Json.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PSObject] $AssessmentResults,

        [Parameter(Mandatory = $true)]
        [string] $MappingFilePath,

        [Parameter(Mandatory = $false)]
        [string] $Pillar,

        [Parameter(Mandatory = $false)]
        [string[]] $KnownPillars = @('identity', 'devices', 'data', 'network', 'infrastructure', 'secops', 'ai')
    )

    function Get-ObjectValue {
        param (
            [Parameter(Mandatory = $false)]
            [object] $InputObject,

            [Parameter(Mandatory = $true)]
            [string] $Name
        )

        if ($null -eq $InputObject) { return $null }
        if ($InputObject -is [System.Collections.IDictionary]) {
            if ($InputObject.Contains($Name)) { return $InputObject[$Name] }
            return $null
        }

        $property = $InputObject.PSObject.Properties[$Name]
        if ($property) { return $property.Value }
        return $null
    }

    # The assessment pipeline passes the literal 'All' as its "no filter" sentinel.
    # Treat 'All' (and empty) as no filter; anything else is a specific pillar.
    $pillarFilterKey = $null
    if (-not [string]::IsNullOrWhiteSpace($Pillar) -and $Pillar -ine 'all') {
        $pillarFilterKey = $Pillar.ToLower()
    }

    $rawTests = Get-ObjectValue -InputObject $AssessmentResults -Name 'Tests'
    $tests = if ($null -eq $rawTests) { @() } else { @($rawTests) }

    # --- Load the mapping (a TestId may map to several Workshop tasks) ---
    $pillarMappings = @{}   # pillar -> hashtable of TestId -> List[string] of Workshop task ids
    $hasMappingFile = $false
    if (Test-Path -LiteralPath $MappingFilePath) {
        $rawMapping  = Get-Content -LiteralPath $MappingFilePath -Raw -Encoding UTF8
        $mapping = $rawMapping | ConvertFrom-Json -ErrorAction Stop
        foreach ($pillarProperty in $mapping.PSObject.Properties) {
            $pillarKey = $pillarProperty.Name.ToLower()
            $pillarMappings[$pillarKey] = @{}
            if ($null -eq $pillarProperty.Value) { continue }

            foreach ($entryProperty in $pillarProperty.Value.PSObject.Properties) {
                $testId = [string]$entryProperty.Name
                $taskIds = [System.Collections.Generic.List[string]]::new()
                foreach ($taskId in @($entryProperty.Value)) {
                    if ($null -eq $taskId) { continue }
                    $taskIdText = [string]$taskId
                    if ([string]::IsNullOrWhiteSpace($taskIdText)) { continue }
                    if (-not $taskIds.Contains($taskIdText)) { $taskIds.Add($taskIdText) }
                }

                if ($taskIds.Count -gt 0) {
                    $pillarMappings[$pillarKey][$testId] = $taskIds
                }
            }
        }
        $hasMappingFile = $true
    }
    else {
        Write-PSFMessage -Level Warning -Message "Workshop task mapping not found: $MappingFilePath. Tests will be keyed by TestId directly."
    }

    # --- Initialise pillars ---
    $pillars = [ordered]@{}
    if ($pillarFilterKey) {
        $pillars[$pillarFilterKey] = [ordered]@{ taskOverrides = [ordered]@{} }
    }
    else {
        foreach ($p in $KnownPillars) {
            $pillars[$p] = [ordered]@{ taskOverrides = [ordered]@{} }
        }
    }

    # --- Process each test ---
    $modifiedCount  = 0
    $collectedNotes = @{}   # "pillar|taskId" -> List[string] of findings

    foreach ($test in $tests) {
        $testId = [string](Get-ObjectValue -InputObject $test -Name 'TestId')

        $testStatus = [string](Get-ObjectValue -InputObject $test -Name 'TestStatus')
        if ($testStatus -ieq 'Skipped') { continue }

        # TestPillar may be a scalar string OR an array (cross-referenced / multi-pillar
        # tests). In-memory it is frequently a single-element array that only collapses to a
        # scalar once serialized to JSON, so normalise to a list of scalar pillar strings.
        $pillarRaw = Get-ObjectValue -InputObject $test -Name 'TestPillar'
        $pillarNames = @($pillarRaw) | ForEach-Object { "$_" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        if ($pillarNames.Count -eq 0) { continue }

        # Extract the headline finding: first non-empty line of TestResult (computed once).
        $testResult = Get-ObjectValue -InputObject $test -Name 'TestResult'
        $notesText  = ''
        if (-not [string]::IsNullOrEmpty($testResult)) {
            $nl = if ($testResult.Contains("`n")) { "`n" } elseif ($testResult.Contains('\n')) { '\n' } else { $null }
            if ($null -ne $nl) {
                # Use the String[] overload so a multi-character separator (literal '\n')
                # is matched as a sequence rather than as a char set ('\' or 'n').
                foreach ($part in $testResult.Split([string[]]@($nl), [System.StringSplitOptions]::None)) {
                    $trimmed = $part.Trim()
                    if ($trimmed.Length -gt 0) { $notesText = $trimmed; break }
                }
            }
            else {
                $notesText = $testResult.Trim()
            }
        }

        foreach ($pillarName in $pillarNames) {
            $pillarKey = $pillarName.ToLower()

            if ($pillarFilterKey -and $pillarKey -ne $pillarFilterKey) { continue }

            # Resolve the Workshop task id(s) this test contributes to within this pillar.
            if ($hasMappingFile) {
                $pillarMap = if ($pillarMappings.ContainsKey($pillarKey)) { $pillarMappings[$pillarKey] } else { @{} }
                if ($pillarMap.ContainsKey($testId)) {
                    $overrideIds = $pillarMap[$testId]
                }
                else {
                    continue   # test not mapped to any Workshop task in this pillar
                }
            }
            else {
                $overrideIds = @($testId)
            }

            if (-not $pillars.Contains($pillarKey)) {
                $pillars[$pillarKey] = [ordered]@{ taskOverrides = [ordered]@{} }
            }

            foreach ($overrideId in $overrideIds) {
                if ($notesText.Length -gt 0) {
                    $noteKey = "$pillarKey|$overrideId"
                    if (-not $collectedNotes.ContainsKey($noteKey)) {
                        $collectedNotes[$noteKey] = [System.Collections.Generic.List[string]]::new()
                    }
                    $collectedNotes[$noteKey].Add($notesText)
                }

                if (-not $pillars[$pillarKey].taskOverrides.Contains($overrideId)) {
                    $pillars[$pillarKey].taskOverrides[$overrideId] = [ordered]@{
                        status = 'not-reviewed'
                        notes  = ''
                    }
                    $modifiedCount++
                }
            }
        }
    }

    # --- Combine collected notes (dedup identical lines; importer rejects repeats) ---
    foreach ($noteKey in $collectedNotes.Keys) {
        $parts = $noteKey -split '\|', 2
        $pKey  = $parts[0]
        $oKey  = $parts[1]
        $uniqueLines = [System.Collections.Generic.List[string]]::new()
        $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
        foreach ($line in $collectedNotes[$noteKey]) {
            if ($seen.Add($line)) { $uniqueLines.Add($line) }
        }
        $combined = ($uniqueLines) -join "`n"
        $pillars[$pKey].taskOverrides[$oKey].notes = "ZT Assessment result:`n$combined`n"
    }

    # --- Statistics ---
    $pillarsWithChanges = @()
    foreach ($pKey in $pillars.Keys) {
        if ($pillars[$pKey].taskOverrides.Count -gt 0) { $pillarsWithChanges += $pKey }
    }

    $timestamp           = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
    $exportScope         = if ($pillarFilterKey) { $pillarFilterKey } else { 'all' }
    $currentPillarOutput = if ($pillarFilterKey) { $pillarFilterKey } else { 'identity' }

    $output = [ordered]@{
        metadata      = [ordered]@{
            version            = '1.0.0'
            formatVersion      = '1.0'
            exportedAt         = $timestamp
            applicationVersion = '1.0.0'
            exportType         = 'full-configuration'
            scope              = $exportScope
            description        = 'Zero Trust Assessment Result Export'
        }
        configuration = [ordered]@{
            applicationState = [ordered]@{
                currentPillar = $currentPillarOutput
                lastModified  = $timestamp
            }
            pillars          = $pillars
            globalSettings   = [ordered]@{
                preferences = [ordered]@{
                    autoSave            = $true
                    confirmationDialogs = $true
                }
            }
        }
        statistics    = [ordered]@{
            totalTasks         = $tests.Count
            modifiedTasks      = $modifiedCount
            completedTasks     = 0
            inProgressTasks    = 0
            plannedTasks       = 0
            pillarsWithChanges = $pillarsWithChanges
        }
    }

    return $output
}