modules/Azure/Discovery/Private/InvokeCIEMGraphNodeBuild.ps1

function InvokeCIEMGraphNodeBuild {
    <#
    .SYNOPSIS
        Transforms ARM and Entra resources into graph_nodes rows.
    .DESCRIPTION
        Iterates over ARM and Entra resource collections, resolves each to a graph node kind,
        packs ARM-specific metadata into a properties JSON blob, and saves to the graph_nodes table.
        Also creates two singleton nodes: '__internet__' (global) and the Azure tenant node.
    .PARAMETER ArmResources
        Array of ARM resource objects (from azure_arm_resources or Resource Graph).
    .PARAMETER EntraResources
        Array of Entra resource objects (from azure_entra_resources or Graph API).
    .PARAMETER Connection
        SQLite connection for transaction support. Pass $null for standalone operations.
    .PARAMETER CollectedAt
        ISO 8601 timestamp for the collection time.
    .OUTPUTS
        [int] Total number of nodes created.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$ArmResources,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$EntraResources,

        [Parameter(Mandatory)]
        [AllowNull()]
        [object]$Connection,

        [Parameter(Mandatory)]
        [string]$CollectedAt
    )

    $ErrorActionPreference = 'Stop'

    $nodeCount = 0
    $tenantId = $null

    # Build Save-CIEMGraphNode splat base (conditionally includes -Connection)
    $baseSplat = @{ CollectedAt = $CollectedAt }
    if ($Connection) { $baseSplat.Connection = $Connection }

    # Process ARM resources
    foreach ($r in $ArmResources) {
        $kind = ResolveCIEMNodeKind -Type $r.Type -Source 'ARM'

        # Pack ARM-specific fields into properties JSON
        $propsHash = @{}
        if ($r.Type)       { $propsHash['arm_type']    = $r.Type }
        if ($r.Location)   { $propsHash['location']    = $r.Location }
        if ($r.TenantId)   { $propsHash['tenant_id']   = $r.TenantId; if (-not $tenantId) { $tenantId = $r.TenantId } }
        if ($r.Kind)       { $propsHash['kind']        = $r.Kind }
        if ($r.Sku)        { $propsHash['sku']         = $r.Sku }
        if ($r.Identity)   { $propsHash['identity']    = $r.Identity }
        if ($r.ManagedBy)  { $propsHash['managed_by']  = $r.ManagedBy }
        if ($r.Plan)       { $propsHash['plan']        = $r.Plan }
        if ($r.Zones)      { $propsHash['zones']       = $r.Zones }
        if ($r.Tags)       { $propsHash['tags']        = $r.Tags }
        if ($r.Properties) { $propsHash['properties']  = $r.Properties }
        $propertiesJson = if ($propsHash.Count -gt 0) { $propsHash | ConvertTo-Json -Depth 3 -Compress } else { $null }

        $splat = $baseSplat.Clone()
        $splat.Id             = $r.Id
        $splat.Kind           = $kind
        $splat.DisplayName    = $r.Name
        $splat.Provider       = 'azure'
        $splat.SubscriptionId = $r.SubscriptionId
        $splat.ResourceGroup  = $r.ResourceGroup
        $splat.Properties     = $propertiesJson

        Save-CIEMGraphNode @splat
        $nodeCount++
    }

    # Process Entra resources
    foreach ($e in $EntraResources) {
        $kind = ResolveCIEMNodeKind -Type $e.Type -Source 'Entra' -PropertiesJson $e.Properties

        # Enrich properties with computed daysSinceSignIn from signInActivity
        $enrichedProperties = $e.Properties
        if ($e.Properties -match '"signInActivity"') {
            try {
                $parsed = $e.Properties | ConvertFrom-Json -ErrorAction Stop
                if ($parsed.signInActivity) {
                    $dates = @()
                    if ($parsed.signInActivity.lastSignInDateTime) {
                        $dates += [datetime]$parsed.signInActivity.lastSignInDateTime
                    }
                    if ($parsed.signInActivity.lastNonInteractiveSignInDateTime) {
                        $dates += [datetime]$parsed.signInActivity.lastNonInteractiveSignInDateTime
                    }
                    if ($dates.Count -gt 0) {
                        $mostRecent = ($dates | Sort-Object -Descending)[0]
                        $refDate = [datetime]::Parse($CollectedAt)
                        $daysSince = [math]::Max(0, [math]::Floor(($refDate - $mostRecent).TotalDays))
                        $parsed | Add-Member -NotePropertyName 'daysSinceSignIn' -NotePropertyValue $daysSince -Force
                        $enrichedProperties = $parsed | ConvertTo-Json -Depth 10 -Compress
                    }
                }
            } catch {
                Write-CIEMLog -Message "signInActivity enrichment failed for $($e.Id): $($_.Exception.Message)" -Severity WARNING -Component 'GraphBuilder'
            }
        }

        $splat = $baseSplat.Clone()
        $splat.Id          = $e.Id
        $splat.Kind        = $kind
        $splat.DisplayName = $e.DisplayName
        $splat.Provider    = 'azure'
        $splat.Properties  = $enrichedProperties

        Save-CIEMGraphNode @splat
        $nodeCount++
    }

    # Singleton: Internet node
    $splat = $baseSplat.Clone()
    $splat.Id          = '__internet__'
    $splat.Kind        = 'Internet'
    $splat.DisplayName = 'Internet'
    $splat.Provider    = 'global'
    Save-CIEMGraphNode @splat
    $nodeCount++

    # Singleton: Tenant node
    $tid = if ($tenantId) { $tenantId } else { 'unknown-tenant' }
    $splat = $baseSplat.Clone()
    $splat.Id          = $tid
    $splat.Kind        = 'AzureTenant'
    $splat.DisplayName = 'Tenant'
    $splat.Provider    = 'azure'
    Save-CIEMGraphNode @splat
    $nodeCount++

    Write-CIEMLog "Graph node build: $nodeCount nodes created" -Component 'GraphBuilder'
    $nodeCount
}