modules/shared/Renderers/ResilienceMapRenderer.ps1

# ResilienceMapRenderer.ps1
#
# Track B (#429) scaffold. Implementation held until Foundation #435 lands.
# Per Round 3 reconciliation on #427 (AUTHORITATIVE):
# - #435 lands 16 EdgeRelations total; this renderer consumes 6:
# DependsOn, RegionPinned, ZonePinned, BackedUpBy, FailsOverTo, ReplicatedTo.
# - #435 does NOT add named FindingRow fields. RTO/RPO/Remediation/DocsUrl are
# deferred to #432b. This renderer must degrade gracefully when those fields
# are absent (silent skip, no throw, no layout shift).
# - Hot files (Schema.ps1, Invoke-AzureAnalyzer.ps1, New-HtmlReport.ps1,
# tool-manifest.json) are owned by #435 in Phase 0; do not edit here.
#
# See docs/design/resilience-map.md for the full design.
#
# Phase 1: relation-only edge styling. Tier-weighted DependsOn line weights and
# region-matched FailsOverTo/ReplicatedTo coloring are deferred to a follow-up
# (tracked under #429). Resolve-ResilienceEdgeStyle currently returns a fixed
# style per Relation; consumers needing per-tier or per-region differentiation
# must layer that on top of the base style.

Set-StrictMode -Version Latest

# Sanitizer is loaded lazily so the renderer stays usable from contexts that
# pre-load Sanitize.ps1 as well as from standalone tests. Falls back to a
# pass-through if the shared sanitizer is not available.
if (-not (Get-Command -Name Remove-Credentials -ErrorAction SilentlyContinue)) {
    $sanitizePath = Join-Path $PSScriptRoot '..\Sanitize.ps1'
    if (Test-Path -LiteralPath $sanitizePath) { . $sanitizePath }
}
function Invoke-ResilienceMapSanitize {
    param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Text)
    $cmd = Get-Command -Name Remove-Credentials -ErrorAction SilentlyContinue
    if ($null -ne $cmd) { return Remove-Credentials -Text $Text }
    return $Text
}

function Test-ObjectProperty {
    param(
        [Parameter(Mandatory)] [AllowNull()] [object] $Object,
        [Parameter(Mandatory)] [string] $Name
    )
    if ($null -eq $Object) { return $false }
    if ($Object -is [System.Collections.IDictionary]) { return $Object.Contains($Name) }
    return $null -ne $Object.PSObject -and $null -ne $Object.PSObject.Properties[$Name]
}

function Get-ObjectValue {
    param(
        [Parameter(Mandatory)] [AllowNull()] [object] $Object,
        [Parameter(Mandatory)] [string[]] $Names
    )
    foreach ($name in $Names) {
        if (Test-ObjectProperty -Object $Object -Name $name) {
            $value = if ($Object -is [System.Collections.IDictionary]) { $Object[$name] } else { $Object.$name }
            if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) {
                return $value
            }
        }
    }
    return $null
}

function Get-EntityResilienceMetadata {
    param([Parameter(Mandatory)] [object] $Entity)

    $properties = if (Test-ObjectProperty -Object $Entity -Name 'Properties') { $Entity.Properties } else { $null }
    $rawProperties = if (Test-ObjectProperty -Object $Entity -Name 'RawProperties') { $Entity.RawProperties } else { $null }

    $region = Get-ObjectValue -Object $Entity -Names @('Region', 'Location')
    if (-not $region) { $region = Get-ObjectValue -Object $properties -Names @('Region', 'Location') }
    if (-not $region) { $region = Get-ObjectValue -Object $rawProperties -Names @('Region', 'Location') }
    if (-not $region) { $region = 'global' }

    $zone = Get-ObjectValue -Object $Entity -Names @('Zone', 'AvailabilityZone')
    if (-not $zone) { $zone = Get-ObjectValue -Object $properties -Names @('Zone', 'AvailabilityZone') }
    if (-not $zone) { $zone = Get-ObjectValue -Object $rawProperties -Names @('Zone', 'AvailabilityZone') }
    if (-not $zone) { $zone = 'all' }

    $scope = Get-ObjectValue -Object $Entity -Names @('Scope', 'ManagementGroup')
    if (-not $scope) { $scope = Get-ObjectValue -Object $properties -Names @('Scope', 'ManagementGroup') }
    if (-not $scope) { $scope = Get-ObjectValue -Object $rawProperties -Names @('Scope', 'ManagementGroup') }
    if (-not $scope) { $scope = 'ManagementGroup' }

    return [PSCustomObject]@{
        Region = [string]$region
        Zone   = [string]$zone
        Scope  = [string]$scope
    }
}

function Invoke-ResilienceMapRender {
    <#
    .SYNOPSIS
        Render the resilience map for a given entity store and viewer tier.
    .PARAMETER EntityStorePath
        Path to entities.json produced by the orchestrator.
    .PARAMETER Tier
        Viewer tier (1, 2, or 3). Drives edge-cap and collapse behavior.
        See docs/design/resilience-map.md section 3.4.
    .PARAMETER OutputPath
        Destination directory for the rendered HTML fragment + JSON sidecar.
    .PARAMETER SharedEdgeBudget
        Remaining edge budget after attack-path (#428) and policy (#434) have
        consumed their share. Defaults to 2500.
    .OUTPUTS
        PSCustomObject with Path, EdgeCount, CellCount, DroppedEdges.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $EntityStorePath,
        [Parameter(Mandatory)] [ValidateSet(1, 2, 3)] [int] $Tier,
        [Parameter(Mandatory)] [string] $OutputPath,
        [Parameter()] [int] $SharedEdgeBudget = 2500
    )
    $json = Get-Content -Path $EntityStorePath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 64

    if ($json -is [System.Collections.IEnumerable] -and -not ($json -is [string])) {
        $entities = @($json)
        $edges = @()
    } else {
        $entities = if (Test-ObjectProperty -Object $json -Name 'Entities') { @($json.Entities) } else { @() }
        $edges = if (Test-ObjectProperty -Object $json -Name 'Edges') { @($json.Edges) } else { @() }
    }

    $resilienceRelations = @('DependsOn', 'RegionPinned', 'ZonePinned', 'BackedUpBy', 'FailsOverTo', 'ReplicatedTo')
    $resilienceEdges = @($edges | Where-Object { $_.Relation -in $resilienceRelations })
    $cells = @(Get-ResilienceHeatmapCells -Entities $entities -Edges $resilienceEdges)

    if ($Tier -eq 3) {
        $resilienceEdges = @()
        $cells = @(
            $cells |
                Group-Object -Property Region |
                ForEach-Object {
                    $groupCells = @($_.Group)
                    [PSCustomObject]@{
                        Region       = $_.Name
                        Zone         = '*'
                        Scope        = 'ManagementGroup'
                        Score        = [Math]::Round((($groupCells | Measure-Object -Property Score -Average).Average), 2)
                        Color        = if (($groupCells.Color -contains 'red')) { 'red' } elseif (($groupCells.Color -contains 'orange')) { 'orange' } elseif (($groupCells.Color -contains 'yellow')) { 'yellow' } else { 'green' }
                        FillDensity  = [Math]::Round((($groupCells | Measure-Object -Property FillDensity -Average).Average), 2)
                        Expandable   = $false
                        BackupRatio  = [Math]::Round((($groupCells | Measure-Object -Property BackupRatio -Average).Average), 4)
                        ZoneExpanded = $false
                    }
                }
        )
    } elseif ($Tier -in @(1, 2)) {
        foreach ($cell in $cells) {
            $cell.Expandable = $true
            $cell.ZoneExpanded = $false
        }
    }

    $budget = [Math]::Max(0, $SharedEdgeBudget)
    $keptEdges = @($resilienceEdges | Select-Object -First $budget)
    $droppedEdges = [Math]::Max(0, $resilienceEdges.Count - $keptEdges.Count)

    New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
    $jsonPath = Join-Path $OutputPath 'resilience-map.json'
    $htmlPath = Join-Path $OutputPath 'resilience-map.html'

    $payload = [ordered]@{
        Tier         = $Tier
        EdgeCount    = $keptEdges.Count
        CellCount    = $cells.Count
        DroppedEdges = $droppedEdges
        Edges        = $keptEdges
        Cells        = $cells
    }
    $jsonOut = $payload | ConvertTo-Json -Depth 64
    $jsonOut = Invoke-ResilienceMapSanitize -Text $jsonOut
    $htmlOut = "<div id='resilience-map' data-tier='$Tier' data-edge-count='$($keptEdges.Count)' data-cell-count='$($cells.Count)'></div>"
    $htmlOut = Invoke-ResilienceMapSanitize -Text $htmlOut
    $jsonOut | Set-Content -Path $jsonPath -Encoding UTF8
    $htmlOut | Set-Content -Path $htmlPath -Encoding UTF8

    return [PSCustomObject]@{
        Path         = $jsonPath
        HtmlPath     = $htmlPath
        EdgeCount    = $keptEdges.Count
        CellCount    = $cells.Count
        DroppedEdges = $droppedEdges
        Cells        = $cells
        Edges        = $keptEdges
    }
}

function Get-ResilienceHeatmapCells {
    <#
    .SYNOPSIS
        Build the per-region/zone heatmap cell matrix from entity store.
    .PARAMETER Entities
        Parsed entities.json (array of v3 entities).
    .PARAMETER Edges
        Edge collection filtered to resilience relations only.
    .OUTPUTS
        Array of cell descriptors: { Region, Zone, Scope, Score, Color, FillDensity }.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Entities,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Edges
    )
    $edgeBySource = @{}
    foreach ($edge in @($Edges)) {
        if (-not (Test-ObjectProperty -Object $edge -Name 'Source')) { continue }
        $source = [string]$edge.Source
        if (-not $edgeBySource.ContainsKey($source)) {
            $edgeBySource[$source] = @()
        }
        $edgeBySource[$source] += $edge
    }

    $rows = foreach ($entity in @($Entities)) {
        if (-not (Test-ObjectProperty -Object $entity -Name 'EntityId')) { continue }

        $entityId = [string]$entity.EntityId
        $meta = Get-EntityResilienceMetadata -Entity $entity
        $entityEdges = if ($edgeBySource.ContainsKey($entityId)) { @($edgeBySource[$entityId]) } else { @() }

        $hasPinned = ($entityEdges | Where-Object { $_.Relation -in @('RegionPinned', 'ZonePinned') } | Measure-Object).Count -gt 0
        $hasBackup = ($entityEdges | Where-Object { $_.Relation -eq 'BackedUpBy' } | Measure-Object).Count -gt 0
        $hasReplica = ($entityEdges | Where-Object { $_.Relation -in @('FailsOverTo', 'ReplicatedTo') } | Measure-Object).Count -gt 0
        $zoneRedundant = ($entityEdges | Where-Object { $_.Relation -eq 'ZonePinned' } | Measure-Object).Count -gt 0

        $scoreControls = [int]$hasPinned + [int]$hasBackup + [int]$hasReplica
        [PSCustomObject]@{
            Region        = $meta.Region
            Zone          = $meta.Zone
            Scope         = $meta.Scope
            ScoreControls = $scoreControls
            HasBackup     = $hasBackup
            ZoneRedundant = $zoneRedundant
        }
    }

    $cells = foreach ($group in ($rows | Group-Object -Property Region, Zone, Scope)) {
        $items = @($group.Group)
        if ($items.Count -eq 0) { continue }

        $avgControls = ($items | Measure-Object -Property ScoreControls -Average).Average
        $backupCount = @($items | Where-Object { $_.HasBackup }).Count
        $nonRedundantCount = @($items | Where-Object { -not $_.ZoneRedundant }).Count
        $backupRatio = $backupCount / [double]$items.Count
        $allZoneRedundant = ($nonRedundantCount -eq 0)

        $color = if ($avgControls -lt 0.5) {
            'red'
        } elseif ($avgControls -lt 1.5) {
            'orange'
        } elseif ($avgControls -lt 2.5) {
            'yellow'
        } elseif ($allZoneRedundant) {
            'green'
        } else {
            'yellow'
        }

        [PSCustomObject]@{
            Region       = $items[0].Region
            Zone         = $items[0].Zone
            Scope        = $items[0].Scope
            Score        = [Math]::Round(($avgControls / 3.0) * 100.0, 2)
            Color        = $color
            FillDensity  = [Math]::Round($backupRatio * 100.0, 2)
            BackupRatio  = [Math]::Round($backupRatio, 4)
            Expandable   = $true
            ZoneExpanded = $false
        }
    }

    return @($cells)
}

function Resolve-ResilienceEdgeStyle {
    <#
    .SYNOPSIS
        Return SVG/HTML style descriptor for a resilience edge relation.
    .PARAMETER Relation
        One of: DependsOn, RegionPinned, ZonePinned, BackedUpBy, FailsOverTo, ReplicatedTo.
    .OUTPUTS
        Hashtable with Stroke, DashArray, ArrowHead, HiddenByDefault.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('DependsOn', 'RegionPinned', 'ZonePinned', 'BackedUpBy', 'FailsOverTo', 'ReplicatedTo')]
        [string] $Relation
    )
    $styles = @{
        DependsOn = @{
            Stroke          = '#64748b'
            StrokeWidth     = 2
            DashArray       = ''
            ArrowHead       = 'single'
            HiddenByDefault = $false
        }
        RegionPinned = @{
            Stroke          = '#2563eb'
            StrokeWidth     = 2
            DashArray       = '1 0'
            ArrowHead       = 'single'
            HiddenByDefault = $false
        }
        ZonePinned = @{
            Stroke          = '#7c3aed'
            StrokeWidth     = 2
            DashArray       = '1 0'
            ArrowHead       = 'single'
            HiddenByDefault = $false
        }
        BackedUpBy = @{
            Stroke          = '#0891b2'
            StrokeWidth     = 1
            DashArray       = '3 3'
            ArrowHead       = 'single'
            HiddenByDefault = $true
        }
        FailsOverTo = @{
            Stroke          = '#0ea5e9'
            StrokeWidth     = 2
            DashArray       = '6 3'
            ArrowHead       = 'double'
            HiddenByDefault = $false
        }
        ReplicatedTo = @{
            Stroke          = '#14b8a6'
            StrokeWidth     = 2
            DashArray       = '2 4'
            ArrowHead       = 'single'
            HiddenByDefault = $false
        }
    }
    return $styles[$Relation]
}

function Get-RecoveryObjectiveOverlay {
    <#
    .SYNOPSIS
        Build RTO/RPO tooltip overlay for a resource entity. Returns $null when
        recovery objectives are absent (graceful absence per design 3.3).
    .DESCRIPTION
        Depends on #432b for any canonical FindingRow field carrying RTO/RPO.
        Until #432b lands, this function reads opportunistically from
        Entity.RawProperties and returns $null on absence. When #432b adds
        canonical fields, prefer the canonical field, then RawProperties, then
        silent absence. Never throws on missing fields.
    .PARAMETER Entity
        Single v3 entity object.
    .OUTPUTS
        Hashtable { Rto, Rpo, BadgeColor } or $null.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Entity
    )
    try {
        $properties = if (Test-ObjectProperty -Object $Entity -Name 'Properties') { $Entity.Properties } else { $null }
        $rawProperties = if (Test-ObjectProperty -Object $Entity -Name 'RawProperties') { $Entity.RawProperties } else { $null }

        $rto = Get-ObjectValue -Object $Entity -Names @('RecoveryTimeObjective', 'RTO')
        if (-not $rto) { $rto = Get-ObjectValue -Object $properties -Names @('RecoveryTimeObjective', 'RTO') }
        if (-not $rto) { $rto = Get-ObjectValue -Object $rawProperties -Names @('RecoveryTimeObjective', 'RTO') }

        $rpo = Get-ObjectValue -Object $Entity -Names @('RecoveryPointObjective', 'RPO')
        if (-not $rpo) { $rpo = Get-ObjectValue -Object $properties -Names @('RecoveryPointObjective', 'RPO') }
        if (-not $rpo) { $rpo = Get-ObjectValue -Object $rawProperties -Names @('RecoveryPointObjective', 'RPO') }

        if (-not $rto -and -not $rpo) {
            return $null
        }

        return @{
            Rto        = $rto
            Rpo        = $rpo
            BadgeColor = if ($rto -and $rpo) { 'blue' } elseif ($rto) { 'purple' } else { 'teal' }
        }
    } catch {
        return $null
    }
}

function Resolve-BlastRadius {
    <#
    .SYNOPSIS
        Traverse DependsOn / FailsOverTo / ReplicatedTo edges from a root entity
        and return the impacted entity set. Drives the 60-second auditor query
        in design section 1.
    .PARAMETER RootEntityId
        Canonical entity id.
    .PARAMETER Edges
        Edge collection.
    .PARAMETER MaxDepth
        Defaults to 5 (matches viewer tier 1 default).
    .OUTPUTS
        Array of impacted entity ids with Distance and EdgePath.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $RootEntityId,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Edges,
        [Parameter()] [int] $MaxDepth = 5
    )
    $allowed = @('DependsOn', 'FailsOverTo', 'ReplicatedTo')
    $adjacency = @{}
    foreach ($edge in @($Edges)) {
        if (-not (Test-ObjectProperty -Object $edge -Name 'Relation')) { continue }
        if ($edge.Relation -notin $allowed) { continue }
        if (-not (Test-ObjectProperty -Object $edge -Name 'Source')) { continue }
        if (-not (Test-ObjectProperty -Object $edge -Name 'Target')) { continue }

        $source = [string]$edge.Source
        if (-not $adjacency.ContainsKey($source)) {
            $adjacency[$source] = @()
        }
        $adjacency[$source] += $edge
    }

    $queue = [System.Collections.Generic.Queue[object]]::new()
    $visited = @{}
    $results = @()

    $queue.Enqueue([PSCustomObject]@{
            EntityId = $RootEntityId
            Distance = 0
            EdgePath = @()
        })
    $visited[$RootEntityId] = $true

    while ($queue.Count -gt 0) {
        $current = $queue.Dequeue()
        $results += [PSCustomObject]@{
            EntityId = $current.EntityId
            Distance = $current.Distance
            EdgePath = @($current.EdgePath)
        }

        if ($current.Distance -ge $MaxDepth) { continue }
        if (-not $adjacency.ContainsKey($current.EntityId)) { continue }

        foreach ($edge in @($adjacency[$current.EntityId])) {
            $target = [string]$edge.Target
            if ($visited.ContainsKey($target)) { continue }

            $visited[$target] = $true
            $queue.Enqueue([PSCustomObject]@{
                    EntityId = $target
                    Distance = $current.Distance + 1
                    EdgePath = @($current.EdgePath + @([PSCustomObject]@{
                                Source   = $edge.Source
                                Target   = $edge.Target
                                Relation = $edge.Relation
                            }))
                })
        }
    }

    return $results | Sort-Object -Property Distance, EntityId
}

if ($ExecutionContext.SessionState.Module) {
    Export-ModuleMember -Function `
        Invoke-ResilienceMapRender, `
        Get-ResilienceHeatmapCells, `
        Resolve-ResilienceEdgeStyle, `
        Get-RecoveryObjectiveOverlay, `
        Resolve-BlastRadius
}