modules/shared/Renderers/AttackPathRenderer.ps1

# AttackPathRenderer.ps1
#
# Track A (attack-path visualizer) renderer module.
#
# Foundation PR #435 has landed:
# * the six new EdgeRelations enum values in modules/shared/Schema.ps1
# * the optional -EdgeCollector normalizer parameter contract
# * cytoscape.js + cytoscape-dagre vendor files under assets/vendor/
#
# See docs/design/attack-path.md for the full contract.
# Issue: #428. Epic: #427.

Set-StrictMode -Version Latest

function Get-AttackPathSeverityRank {
    param([string] $Severity)

    switch (($Severity ?? '').ToLowerInvariant()) {
        'critical' { return 5 }
        'high' { return 4 }
        'medium' { return 3 }
        'low' { return 2 }
        'info' { return 1 }
        default { return 0 }
    }
}

function Get-AttackPathSeverityLabel {
    param([int] $Rank)

    switch ($Rank) {
        5 { return 'Critical' }
        4 { return 'High' }
        3 { return 'Medium' }
        2 { return 'Low' }
        1 { return 'Info' }
        default { return 'Info' }
    }
}

function Remove-AttackPathNullProperties {
    param([object] $Value)

    if ($null -eq $Value) { return $null }
    if ($Value -is [string]) { return $Value }
    if ($Value -is [System.Collections.IDictionary]) {
        $clean = @{}
        foreach ($key in @($Value.Keys)) {
            $child = Remove-AttackPathNullProperties -Value $Value[$key]
            if ($null -eq $child) { continue }
            if ($child -is [string] -and [string]::IsNullOrWhiteSpace($child)) { continue }
            $clean[$key] = $child
        }
        return $clean
    }
    if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) {
        $items = [System.Collections.Generic.List[object]]::new()
        foreach ($item in @($Value)) {
            $child = Remove-AttackPathNullProperties -Value $item
            if ($null -eq $child) { continue }
            $items.Add($child) | Out-Null
        }
        return @($items)
    }

    $props = @($Value.PSObject.Properties)
    if ($props.Count -eq 0) { return $Value }

    $obj = [ordered]@{}
    foreach ($prop in $props) {
        $child = Remove-AttackPathNullProperties -Value $prop.Value
        if ($null -eq $child) { continue }
        if ($child -is [string] -and [string]::IsNullOrWhiteSpace($child)) { continue }
        $obj[$prop.Name] = $child
    }
    return $obj
}

function New-AttackPathModel {
    <#
    .SYNOPSIS
        Build the Cytoscape elements payload for the attack-path canvas.
    .DESCRIPTION
        Reads the v3 entity store + findings store and emits a hashtable shaped
        for the #atkPathModel JSON island. Honours the shared per-canvas edge
        budget (Tier 1 default 2500). Returns nodes/edges plus a budget block
        and a truncated flag.

    .PARAMETER Entities
        Entity collection from EntityStore (v3).
    .PARAMETER Findings
        Finding rows used for severity weighting and click-to-pivot mapping.
    .PARAMETER Tier
        Rendering tier (1-4). Drives sampling and hydration strategy.
    .PARAMETER EdgeBudget
        Shared per-canvas edge cap. Default 2500 for Tier 1.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Entities,
        [Parameter(Mandatory)] [object[]] $Findings,
        [ValidateRange(1, 4)] [int] $Tier = 1,
        [ValidateRange(1, 100000)] [int] $EdgeBudget = 2500
    )
    $trackARelations = @('TriggeredBy', 'AuthenticatesAs', 'DeploysTo', 'UsesSecret', 'HasFederatedCredential', 'Declares')

    $entityMap = [ordered]@{}
    $candidateEdges = [System.Collections.Generic.List[object]]::new()

    foreach ($item in @($Entities)) {
        if ($null -eq $item) { continue }

        if ($item.PSObject.Properties['Entities']) {
            foreach ($nestedEntity in @($item.Entities)) {
                if ($nestedEntity -and $nestedEntity.PSObject.Properties['EntityId']) {
                    $id = ([string]$nestedEntity.EntityId).ToLowerInvariant()
                    if (-not [string]::IsNullOrWhiteSpace($id) -and -not $entityMap.Contains($id)) {
                        $entityMap[$id] = $nestedEntity
                    }
                }
            }
        }

        if ($item.PSObject.Properties['Edges']) {
            foreach ($nestedEdge in @($item.Edges)) {
                if ($nestedEdge) { $candidateEdges.Add($nestedEdge) | Out-Null }
            }
        }

        if ($item.PSObject.Properties['EntityId']) {
            $id = ([string]$item.EntityId).ToLowerInvariant()
            if (-not [string]::IsNullOrWhiteSpace($id) -and -not $entityMap.Contains($id)) {
                $entityMap[$id] = $item
            }
        }

        if ($item.PSObject.Properties['Relation'] -and $item.PSObject.Properties['Source'] -and $item.PSObject.Properties['Target']) {
            $candidateEdges.Add($item) | Out-Null
        }
    }

    $findingMap = @{}
    $severityByEntity = @{}
    foreach ($finding in @($Findings)) {
        if ($null -eq $finding) { continue }
        $entityIds = [System.Collections.Generic.List[string]]::new()

        if ($finding.PSObject.Properties['EntityId'] -and $finding.EntityId) {
            $entityIds.Add(([string]$finding.EntityId).ToLowerInvariant()) | Out-Null
        }
        if ($finding.PSObject.Properties['Entity'] -and $finding.Entity) {
            $entityIds.Add(([string]$finding.Entity).ToLowerInvariant()) | Out-Null
        }
        if ($finding.PSObject.Properties['EntityRefs']) {
            foreach ($entityRef in @($finding.EntityRefs)) {
                $ref = [string]$entityRef
                if ([string]::IsNullOrWhiteSpace($ref)) { continue }
                if ($ref -match '^https?://') { continue }
                $entityIds.Add($ref.Trim().ToLowerInvariant()) | Out-Null
            }
        }

        $severity = if ($finding.PSObject.Properties['Severity']) { [string]$finding.Severity } else { '' }
        $rank = Get-AttackPathSeverityRank -Severity $severity

        foreach ($entityId in @($entityIds | Select-Object -Unique)) {
            if (-not $findingMap.ContainsKey($entityId)) {
                $findingMap[$entityId] = [System.Collections.Generic.List[object]]::new()
            }
            $findingMap[$entityId].Add($finding) | Out-Null
            if (-not $severityByEntity.ContainsKey($entityId) -or $rank -gt $severityByEntity[$entityId]) {
                $severityByEntity[$entityId] = $rank
            }
        }
    }

    $rankedEdges = [System.Collections.Generic.List[object]]::new()
    foreach ($edge in @($candidateEdges)) {
        $relation = if ($edge.PSObject.Properties['Relation']) { [string]$edge.Relation } else { '' }
        if ($relation -notin $trackARelations) { continue }
        $source = if ($edge.PSObject.Properties['Source']) { ([string]$edge.Source).ToLowerInvariant() } else { '' }
        $target = if ($edge.PSObject.Properties['Target']) { ([string]$edge.Target).ToLowerInvariant() } else { '' }
        if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($target)) { continue }
        $edgeRank = [Math]::Max(
            $(if ($severityByEntity.ContainsKey($source)) { [int]$severityByEntity[$source] } else { 0 }),
            $(if ($severityByEntity.ContainsKey($target)) { [int]$severityByEntity[$target] } else { 0 })
        )
        $edgeId = if ($edge.PSObject.Properties['EdgeId'] -and $edge.EdgeId) {
            [string]$edge.EdgeId
        } else {
            "edge:$source|$relation|$target"
        }

        if (-not $entityMap.Contains($source)) {
            $entityMap[$source] = [pscustomobject]@{ EntityId = $source; EntityType = 'Unknown'; DisplayName = $source; Platform = '' }
        }
        if (-not $entityMap.Contains($target)) {
            $entityMap[$target] = [pscustomobject]@{ EntityId = $target; EntityType = 'Unknown'; DisplayName = $target; Platform = '' }
        }

        $rankedEdges.Add([pscustomobject]@{
                Edge = $edge
                EdgeId = $edgeId
                Source = $source
                Target = $target
                Relation = $relation
                Rank = $edgeRank
            }) | Out-Null
    }

    $requestedEdges = @($rankedEdges).Count
    $selectedEdges = @()
    if ($Tier -eq 1) {
        $selectedEdges = @($rankedEdges | Sort-Object -Property @{ Expression = 'Rank'; Descending = $true }, EdgeId | Select-Object -First $EdgeBudget)
    } elseif ($Tier -eq 2) {
        $seedNodes = @($severityByEntity.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 200 | ForEach-Object { $_.Key })
        $selectedEdges = @($rankedEdges | Where-Object { $_.Source -in $seedNodes -or $_.Target -in $seedNodes } | Sort-Object -Property @{ Expression = 'Rank'; Descending = $true }, EdgeId | Select-Object -First $EdgeBudget)
    } else {
        $selectedEdges = @()
    }

    $nodesUsed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($selected in @($selectedEdges)) {
        $nodesUsed.Add($selected.Source) | Out-Null
        $nodesUsed.Add($selected.Target) | Out-Null
    }
    if (@($selectedEdges).Count -eq 0) {
        foreach ($candidate in @($severityByEntity.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 200)) {
            $nodesUsed.Add($candidate.Key) | Out-Null
        }
    }

    $nodes = [System.Collections.Generic.List[object]]::new()
    foreach ($nodeId in @($nodesUsed)) {
        if (-not $entityMap.Contains($nodeId)) { continue }
        $entity = $entityMap[$nodeId]
        $entityType = if ($entity.PSObject.Properties['EntityType'] -and $entity.EntityType) { [string]$entity.EntityType } else { 'Unknown' }
        $displayName = if ($entity.PSObject.Properties['DisplayName'] -and $entity.DisplayName) { [string]$entity.DisplayName } else { $nodeId }
        $platform = if ($entity.PSObject.Properties['Platform'] -and $entity.Platform) { [string]$entity.Platform } else { '' }
        $rank = if ($severityByEntity.ContainsKey($nodeId)) { [int]$severityByEntity[$nodeId] } else { 0 }

        $nodes.Add([ordered]@{
                data = [ordered]@{
                    id = $nodeId
                    type = $entityType
                    label = $displayName
                    platform = $platform
                    severity = (Get-AttackPathSeverityLabel -Rank $rank)
                    findingCount = $(if ($findingMap.ContainsKey($nodeId)) { @($findingMap[$nodeId]).Count } else { 0 })
                }
            }) | Out-Null
    }

    $edges = [System.Collections.Generic.List[object]]::new()
    foreach ($selected in @($selectedEdges)) {
        $edges.Add([ordered]@{
                data = [ordered]@{
                    id = $selected.EdgeId
                    source = $selected.Source
                    target = $selected.Target
                    relation = $selected.Relation
                    severity = (Get-AttackPathSeverityLabel -Rank ([int]$selected.Rank))
                    layer = 'attack'
                }
            }) | Out-Null
    }

    $hydration = switch ($Tier) {
        1 { [ordered]@{ mode = 'inline'; source = 'atkPathModel' } }
        2 { [ordered]@{ mode = 'sqlite-wasm'; strategy = 'top-n-seed'; seedNodeCap = 200; expand = 'one-hop' } }
        3 { [ordered]@{ mode = 'worker-tiles'; strategy = 'viewport-stream'; fetch = '/graph/attack-path/tiles' } }
        default { [ordered]@{ mode = 'pode-api'; endpoint = '/api/graph/attack-paths'; strategy = 'recursive-cte' } }
    }

    $pivot = @{}
    foreach ($key in @($findingMap.Keys)) {
        $pivot[$key] = @($findingMap[$key] | ForEach-Object {
                [ordered]@{
                    id = $(if ($_.PSObject.Properties['Id']) { [string]$_.Id } else { '' })
                    title = $(if ($_.PSObject.Properties['Title']) { [string]$_.Title } else { '' })
                    severity = $(if ($_.PSObject.Properties['Severity']) { [string]$_.Severity } else { '' })
                    source = $(if ($_.PSObject.Properties['Source']) { [string]$_.Source } else { '' })
                }
            })
    }

    return [ordered]@{
        schemaVersion = '3.0'
        tier = $Tier
        truncated = ($requestedEdges -gt @($selectedEdges).Count)
        budget = [ordered]@{
            edgeCap = $EdgeBudget
            requested = $requestedEdges
            edgesUsed = @($selectedEdges).Count
        }
        nodes = @($nodes)
        edges = @($edges)
        hydration = $hydration
        findingMap = $pivot
    }
}

function ConvertTo-AttackPathDataIsland {
    <#
    .SYNOPSIS
        Serialize an attack-path model to the HTML data-island JSON string.
    .DESCRIPTION
        Wraps the model produced by New-AttackPathModel in the schema envelope
        consumed by the in-browser cytoscape renderer.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [hashtable] $Model
    )
    $cleanModel = Remove-AttackPathNullProperties -Value $Model
    return ($cleanModel | ConvertTo-Json -Depth 16 -Compress)
}

function Get-AttackPathBudgetReport {
    <#
    .SYNOPSIS
        Report requested vs allocated edges for the shared canvas budget.
    .DESCRIPTION
        Returns a hashtable with Requested / Allocated / Truncated, used by the
        canvas controller to coordinate with Track B (#430) and Track C (#434).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable] $Model
    )
    $budget = if ($Model.ContainsKey('budget') -and $Model.budget) { $Model.budget } else { @{} }
    $requested = if ($budget.requested -is [int]) { [int]$budget.requested } elseif ($budget.requested) { [int]$budget.requested } else { @($Model.edges).Count }
    $allocated = if ($budget.edgesUsed -is [int]) { [int]$budget.edgesUsed } elseif ($budget.edgesUsed) { [int]$budget.edgesUsed } else { @($Model.edges).Count }
    $edgeCap = if ($budget.edgeCap -is [int]) { [int]$budget.edgeCap } elseif ($budget.edgeCap) { [int]$budget.edgeCap } else { $allocated }
    $truncated = if ($Model.ContainsKey('truncated')) { [bool]$Model.truncated } else { ($requested -gt $allocated) }

    return [ordered]@{
        Requested = $requested
        Allocated = $allocated
        EdgeCap = $edgeCap
        Truncated = $truncated
    }
}

if ($ExecutionContext.SessionState.Module) {
    Export-ModuleMember -Function New-AttackPathModel, ConvertTo-AttackPathDataIsland, Get-AttackPathBudgetReport -ErrorAction SilentlyContinue
}