src/collect/ConvertTo-ScoutGovernanceResource.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Turn the four governance datasets into the typed envelopes their inventory collectors render.

.DESCRIPTION
    A PURE TRANSFORM. It reads only what `Get-ScoutGovernanceDataset` already collected and makes
    no Azure call of any kind -- which is what lets AB#6779's four worksheets exist without adding
    a round trip. `tests/Collect.Governance.Tests.ps1` proves the no-call claim by running this
    function with every Azure cmdlet stubbed to throw.

    Flattening happens HERE rather than in the collector manifests for two reasons:

      * a manifest expression runs under the declarative interpreter, where an absent nested
        property is a StrictMode throw that loses the whole worksheet (AB#6839/AB#6844). Guarding
        it is ordinary PowerShell here and an unreadable one-liner there;
      * the subscription NAME, the role NAME and the scope KIND each need a lookup or a derivation
        that no single field expression can express.

    Same contract as `Get-ScoutTenantWideResource` (AB#5933/AB#6755): every envelope is returned
    even when its rows array is empty, so an estate with no budgets renders an empty Budgets
    worksheet rather than no worksheet at all.

.PARAMETER Governance
    The `Get-ScoutGovernanceDataset` output object. Any absent key degrades that one envelope to
    zero rows.

.PARAMETER Subscriptions
    Subscription objects with `.id`/`.name`, used to name the subscription a scope belongs to.

.OUTPUTS
    Exactly four PSCustomObjects, in stable type order, each with `type` and `properties`:
      AZSC/Governance/RoleAssignment
      AZSC/Governance/PolicyAssignment
      AZSC/Governance/ResourceLock
      AZSC/Governance/Budget

.NOTES
    Tracks ADO Task AB#6780/AB#6781/AB#6782/AB#6783 (Story AB#6779).
#>


function Get-ScoutGovernanceValue {
    <#
        StrictMode-safe read of a dotted path off an arbitrarily-shaped payload.

        Under `Set-StrictMode -Version Latest` a missing property on an EXISTING object throws
        rather than returning $null, and a governance payload is exactly where that bites: an
        assignment with no `condition`, a budget with no `forecastSpend`, a lock with no `notes`.
        Every read below goes through here for that reason.
    #>

    [CmdletBinding()]
    param([object] $InputObject, [Parameter(Mandatory)][string] $Path)

    $cursor = $InputObject
    foreach ($segment in ($Path -split '\.')) {
        if ($null -eq $cursor) { return $null }
        if ($cursor -is [System.Collections.IDictionary]) {
            $key = @($cursor.Keys) | Where-Object { [string]$_ -ieq $segment } | Select-Object -First 1
            if ($null -eq $key) { return $null }
            $cursor = $cursor[$key]
            continue
        }
        $property = $cursor.PSObject.Properties | Where-Object { $_.Name -ieq $segment } | Select-Object -First 1
        if ($null -eq $property) { return $null }
        $cursor = $property.Value
    }
    return $cursor
}

function Get-ScoutGovernanceScopeKind {
    <#
        What a governance scope is ATTACHED to, derived from the scope string alone.

        This is the column that makes a role-assignment worksheet readable: an Owner assignment at
        a management group and an Owner assignment on one storage account are the same row shape
        and wildly different findings.
    #>

    [CmdletBinding()]
    param([string] $Scope)

    if ([string]::IsNullOrWhiteSpace($Scope)) { return 'Unknown' }
    $trimmed = $Scope.Trim().TrimEnd('/')
    if ($trimmed -match '(?i)^/providers/Microsoft\.Management/managementGroups/[^/]+$') { return 'Management Group' }
    if ($trimmed -match '(?i)^/$' -or $trimmed -eq '') { return 'Tenant Root' }

    $segments = @($trimmed -split '/' | Where-Object { $_ })
    if ($segments.Count -eq 0) { return 'Tenant Root' }
    if ($segments[0] -ieq 'subscriptions') {
        if ($segments.Count -eq 2) { return 'Subscription' }
        if ($segments.Count -eq 4 -and $segments[2] -ieq 'resourceGroups') { return 'Resource Group' }
        return 'Resource'
    }
    return 'Other'
}

function Get-ScoutGovernanceSubscriptionId {
    <# The subscription a scope/ARM id belongs to, or $null for a management-group scope. #>
    [CmdletBinding()]
    param([string] $Scope)

    if ([string]::IsNullOrWhiteSpace($Scope)) { return $null }
    $match = [regex]::Match($Scope, '(?i)/subscriptions/([0-9a-f-]{36})')
    if ($match.Success) { return $match.Groups[1].Value }
    return $null
}

function ConvertTo-ScoutGovernanceResource {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [object] $Governance,

        [Parameter()]
        [AllowEmptyCollection()]
        [object[]] $Subscriptions = @()
    )

    # ---- lookups ------------------------------------------------------------------------------
    $subscriptionNames = @{}
    foreach ($subscription in @($Subscriptions)) {
        if ($null -eq $subscription) { continue }
        $id = Get-ScoutGovernanceValue -InputObject $subscription -Path 'id'
        if ([string]::IsNullOrWhiteSpace([string]$id)) { continue }
        $name = Get-ScoutGovernanceValue -InputObject $subscription -Path 'name'
        $subscriptionNames[[string]$id] = if ([string]::IsNullOrWhiteSpace([string]$name)) { [string]$id } else { [string]$name }
    }

    function Resolve-ScoutGovernanceSubscriptionName([string] $Scope) {
        $id = Get-ScoutGovernanceSubscriptionId -Scope $Scope
        if (-not $id) { return $null }
        if ($subscriptionNames.ContainsKey($id)) { return $subscriptionNames[$id] }
        return $id
    }

    $dataset = if ($null -ne $Governance) { $Governance } else { [pscustomobject]@{} }
    function Get-ScoutGovernanceSet([string] $Name) {
        $value = Get-ScoutGovernanceValue -InputObject $dataset -Path $Name
        return @($value | Where-Object { $null -ne $_ })
    }

    # Role definitions, keyed by the LAST segment of their ARM id -- the GUID a role assignment's
    # roleDefinitionId ends with. Keying on the whole id would miss every assignment made at a
    # different scope than the definition was read at, which is most of them.
    $roleNames = @{}
    foreach ($definition in (Get-ScoutGovernanceSet 'roleDefinitions')) {
        $id = [string](Get-ScoutGovernanceValue -InputObject $definition -Path 'id')
        if ([string]::IsNullOrWhiteSpace($id)) { continue }
        $guid = @($id.TrimEnd('/') -split '/')[-1]
        if ([string]::IsNullOrWhiteSpace($guid)) { continue }
        $roleNames[$guid.ToLowerInvariant()] = [pscustomobject]@{
            Name = [string](Get-ScoutGovernanceValue -InputObject $definition -Path 'properties.roleName')
            Type = [string](Get-ScoutGovernanceValue -InputObject $definition -Path 'properties.type')
        }
    }

    # ---- Role assignments ---------------------------------------------------------------------
    $roleAssignmentRows = @(
        foreach ($assignment in (Get-ScoutGovernanceSet 'roleAssignments')) {
            $scope = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.scope')
            $assignmentId = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'id')
            # A role assignment's own id is always rooted at the scope it applies to, so it is a
            # sound fallback when the payload omits properties.scope.
            if ([string]::IsNullOrWhiteSpace($scope) -and -not [string]::IsNullOrWhiteSpace($assignmentId)) {
                $scope = ($assignmentId -split '(?i)/providers/Microsoft\.Authorization/roleAssignments/')[0]
            }

            $roleDefinitionId = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.roleDefinitionId')
            $roleGuid = if ([string]::IsNullOrWhiteSpace($roleDefinitionId)) { '' } else { @($roleDefinitionId.TrimEnd('/') -split '/')[-1] }
            $role = if ($roleGuid -and $roleNames.ContainsKey($roleGuid.ToLowerInvariant())) { $roleNames[$roleGuid.ToLowerInvariant()] } else { $null }
            # Never blank. An unresolved role reads as the GUID it actually is, so the row says
            # "we could not name this" instead of looking like an assignment to nothing.
            $roleName = if ($role -and -not [string]::IsNullOrWhiteSpace($role.Name)) { $role.Name }
                        elseif ($roleGuid) { "Unresolved ($roleGuid)" }
                        else { $null }

            [pscustomobject]@{
                'Subscription'       = Resolve-ScoutGovernanceSubscriptionName $scope
                'Scope Type'         = Get-ScoutGovernanceScopeKind -Scope $scope
                'Scope'              = $scope
                'Role Name'          = $roleName
                'Role Type'          = if ($role) { $role.Type } else { $null }
                'Principal ID'       = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.principalId')
                'Principal Type'     = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.principalType')
                'Condition'          = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.condition')
                'Description'        = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.description')
                'Created On'         = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.createdOn')
                'Assignment Name'    = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'name')
                'Role Definition ID' = $roleDefinitionId
                'ID'                 = $assignmentId
            }
        }
    )

    # ---- Policy assignments -------------------------------------------------------------------
    $policyAssignmentRows = @(
        foreach ($assignment in (Get-ScoutGovernanceSet 'policyAssignments')) {
            $scope = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.scope')
            $assignmentId = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'id')
            if ([string]::IsNullOrWhiteSpace($scope) -and -not [string]::IsNullOrWhiteSpace($assignmentId)) {
                $scope = ($assignmentId -split '(?i)/providers/Microsoft\.Authorization/policyAssignments/')[0]
            }

            $definitionId = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.policyDefinitionId')
            # Initiative vs single policy is the first thing anyone asks of this sheet, and the
            # only place the answer lives is the provider segment of the definition id.
            $definitionKind = if ($definitionId -match '(?i)/policySetDefinitions/') { 'Initiative' }
                              elseif ($definitionId -match '(?i)/policyDefinitions/') { 'Policy' }
                              else { $null }

            $parameters = Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.parameters'
            $parameterCount = if ($null -eq $parameters) { 0 }
                              elseif ($parameters -is [System.Collections.IDictionary]) { @($parameters.Keys).Count }
                              else { @($parameters.PSObject.Properties).Count }

            $notScopes = @(Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.notScopes' | Where-Object { $_ })

            [pscustomobject]@{
                'Subscription'      = Resolve-ScoutGovernanceSubscriptionName $scope
                'Scope Type'        = Get-ScoutGovernanceScopeKind -Scope $scope
                'Scope'             = $scope
                'Assignment Name'   = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'name')
                'Display Name'      = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.displayName')
                'Assigned'          = $definitionKind
                'Definition'        = if ($definitionId) { @($definitionId.TrimEnd('/') -split '/')[-1] } else { $null }
                'Enforcement Mode'  = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.enforcementMode')
                'Excluded Scopes'   = $notScopes.Count
                'Parameters'        = $parameterCount
                'Description'       = [string](Get-ScoutGovernanceValue -InputObject $assignment -Path 'properties.description')
                'Definition ID'     = $definitionId
                'ID'                = $assignmentId
            }
        }
    )

    # ---- Resource locks -----------------------------------------------------------------------
    $resourceLockRows = @(
        foreach ($lock in (Get-ScoutGovernanceSet 'resourceLocks')) {
            $lockId = [string](Get-ScoutGovernanceValue -InputObject $lock -Path 'id')
            # A lock's ARM id is `<locked scope>/providers/Microsoft.Authorization/locks/<name>`,
            # so the scope it protects is the id with that suffix removed. This is the whole point
            # of the sheet -- "what is protected from deletion" is a question about the SCOPE, not
            # about the lock resource.
            $scope = if ([string]::IsNullOrWhiteSpace($lockId)) { '' }
                     else { ($lockId -split '(?i)/providers/Microsoft\.Authorization/locks/')[0] }

            [pscustomobject]@{
                'Subscription'    = Resolve-ScoutGovernanceSubscriptionName $scope
                'Scope Type'      = Get-ScoutGovernanceScopeKind -Scope $scope
                'Protects'        = $scope
                'Lock Name'       = [string](Get-ScoutGovernanceValue -InputObject $lock -Path 'name')
                'Lock Level'      = [string](Get-ScoutGovernanceValue -InputObject $lock -Path 'properties.level')
                'Notes'           = [string](Get-ScoutGovernanceValue -InputObject $lock -Path 'properties.notes')
                'ID'              = $lockId
            }
        }
    )

    # ---- Budgets ------------------------------------------------------------------------------
    $budgetRows = @(
        foreach ($budget in (Get-ScoutGovernanceSet 'budgets')) {
            $budgetId = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'id')
            $amount = Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.amount'
            $spent = Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.currentSpend.amount'

            # The one number an operator actually reads. Computed here and not in a field
            # expression because a budget with amount 0 is legal ARM and a divide there would take
            # the worksheet down rather than blank one cell.
            $usedPct = $null
            if ($null -ne $amount -and $null -ne $spent) {
                $amountValue = 0.0; $spentValue = 0.0
                if ([double]::TryParse([string]$amount, [ref]$amountValue) -and
                    [double]::TryParse([string]$spent, [ref]$spentValue) -and
                    $amountValue -gt 0) {
                    $usedPct = [math]::Round(($spentValue / $amountValue) * 100, 1)
                }
            }

            $notifications = Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.notifications'
            $notificationCount = if ($null -eq $notifications) { 0 }
                                 elseif ($notifications -is [System.Collections.IDictionary]) { @($notifications.Keys).Count }
                                 else { @($notifications.PSObject.Properties).Count }

            [pscustomobject]@{
                'Subscription'      = Resolve-ScoutGovernanceSubscriptionName $budgetId
                'Name'              = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'name')
                'Category'          = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.category')
                'Amount'            = $amount
                'Currency'          = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.currentSpend.unit')
                'Time Grain'        = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.timeGrain')
                'Start Date'        = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.timePeriod.startDate')
                'End Date'          = [string](Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.timePeriod.endDate')
                'Current Spend'     = $spent
                'Forecast Spend'    = Get-ScoutGovernanceValue -InputObject $budget -Path 'properties.forecastSpend.amount'
                'Budget Used %'     = $usedPct
                'Alerts Configured' = $notificationCount
                'ID'                = $budgetId
            }
        }
    )

    @(
        [PSCustomObject]@{
            type       = 'AZSC/Governance/RoleAssignment'
            properties = @($roleAssignmentRows)
        }
        [PSCustomObject]@{
            type       = 'AZSC/Governance/PolicyAssignment'
            properties = @($policyAssignmentRows)
        }
        [PSCustomObject]@{
            type       = 'AZSC/Governance/ResourceLock'
            properties = @($resourceLockRows)
        }
        [PSCustomObject]@{
            type       = 'AZSC/Governance/Budget'
            properties = @($budgetRows)
        }
    )
}