Modules/Private/40-Execution.ps1

function Invoke-RangerRetry {
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,

        [int]$RetryCount = 2,
        [int]$DelaySeconds = 1,
        [string]$Label = 'operation',
        [string]$Target,
        [string[]]$RetryOnExceptionType = @(),
        [switch]$Exponential
    )

    $attempt = 0
    do {
        try {
            return & $ScriptBlock
        }
        catch {
            $attempt++
            $exceptionType = if ($_.Exception) { $_.Exception.GetType().FullName } else { 'UnknownException' }
            $shouldRetry = $RetryOnExceptionType.Count -eq 0 -or $exceptionType -in $RetryOnExceptionType
            if (-not $shouldRetry -or $attempt -gt $RetryCount) {
                throw
            }

            $delay = if ($Exponential) { [math]::Pow(2, $attempt - 1) * $DelaySeconds } else { $DelaySeconds }
            Write-RangerLog -Level debug -Message "Retry attempt $attempt/$RetryCount for '$Label'$(if ($Target) { " on '$Target'" }) after ${exceptionType}: $($_.Exception.Message)"
            Add-RangerRetryDetail -Target $Target -Label $Label -Attempt $attempt -ExceptionType $exceptionType -Message $_.Exception.Message
            Start-Sleep -Seconds $delay
        }
    } while ($true)
}

function Get-RangerWinRmProbeCacheKey {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ComputerName,

        [PSCredential]$Credential
    )

    $userName = if ($Credential -and $Credential.UserName) { [string]$Credential.UserName } else { '<default>' }
    return '{0}|{1}' -f $ComputerName.Trim().ToLowerInvariant(), $userName.Trim().ToLowerInvariant()
}

function Test-RangerWinRmTarget {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ComputerName,

        [PSCredential]$Credential
    )

    if (-not $script:RangerWinRmProbeCache) {
        $script:RangerWinRmProbeCache = @{}
    }

    $cacheKey = Get-RangerWinRmProbeCacheKey -ComputerName $ComputerName -Credential $Credential
    if ($script:RangerWinRmProbeCache.ContainsKey($cacheKey)) {
        return $script:RangerWinRmProbeCache[$cacheKey]
    }

    $probeMessages = New-Object System.Collections.Generic.List[string]
    $probeOptions = @(
        [ordered]@{ transport = 'http';  port = 5985; useSsl = $false },
        [ordered]@{ transport = 'https'; port = 5986; useSsl = $true }
    )

    foreach ($probe in $probeOptions) {
        $portReachable = $true
        if (Test-RangerCommandAvailable -Name 'Test-NetConnection') {
            try {
                $connection = Test-NetConnection -ComputerName $ComputerName -Port $probe.port -WarningAction SilentlyContinue
                $portReachable = [bool]$connection.TcpTestSucceeded
            }
            catch {
                $portReachable = $false
            }

            if (-not $portReachable) {
                $probeMessages.Add("TCP $($probe.port) unreachable")
                continue
            }
        }

        if (Test-RangerCommandAvailable -Name 'Test-WSMan') {
            $wsmanParams = @{
                ComputerName   = $ComputerName
                Authentication = 'Negotiate'
                ErrorAction    = 'Stop'
            }

            if ($Credential) {
                $wsmanParams.Credential = $Credential
            }

            if ($probe.useSsl) {
                $wsmanParams.UseSSL = $true
            }

            try {
                $null = Test-WSMan @wsmanParams
                $state = [ordered]@{
                    Reachable = $true
                    Transport = $probe.transport
                    Port      = $probe.port
                    Message   = "WinRM preflight succeeded over $($probe.transport.ToUpperInvariant())"
                }
                $script:RangerWinRmProbeCache[$cacheKey] = $state
                Write-RangerLog -Level debug -Message "WinRM preflight succeeded for '$ComputerName' over $($probe.transport.ToUpperInvariant())"
                return $state
            }
            catch {
                $probeMessages.Add("WSMan $($probe.transport) failed: $($_.Exception.Message)")
                continue
            }
        }

        $state = [ordered]@{
            Reachable = $true
            Transport = $probe.transport
            Port      = $probe.port
            Message   = 'WinRM preflight tooling unavailable; assuming target is reachable.'
        }
        $script:RangerWinRmProbeCache[$cacheKey] = $state
        Write-RangerLog -Level debug -Message "WinRM preflight skipped for '$ComputerName' because Test-WSMan is unavailable"
        return $state
    }

    $state = [ordered]@{
        Reachable = $false
        Transport = $null
        Port      = $null
        Message   = "WinRM preflight failed for '$ComputerName': $($probeMessages -join ' | ')"
    }
    $script:RangerWinRmProbeCache[$cacheKey] = $state
    Write-RangerLog -Level warn -Message $state.Message
    return $state
}

function Invoke-RangerRemoteCommand {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$ComputerName,

        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,

        [PSCredential]$Credential,
        [object[]]$ArgumentList,
        [int]$RetryCount = 1,
        [int]$TimeoutSeconds = 0
    )

    $targetStates = @($ComputerName | ForEach-Object {
        [ordered]@{
            computerName = $_
            state        = Test-RangerWinRmTarget -ComputerName $_ -Credential $Credential
        }
    })

    $reachableStates = @($targetStates | Where-Object { $_.state.Reachable })
    if ($reachableStates.Count -eq 0) {
        $messages = @($targetStates | ForEach-Object { "$($_.computerName): $($_.state.Message)" })
        throw [System.InvalidOperationException]::new("No reachable WinRM targets available. $($messages -join ' ; ')")
    }

    $unreachableTargets = @($targetStates | Where-Object { -not $_.state.Reachable } | ForEach-Object { $_.computerName })
    if ($unreachableTargets.Count -gt 0) {
        Write-RangerLog -Level warn -Message "Skipping unreachable WinRM targets: $($unreachableTargets -join ', ')"
    }

    $targetGroups = @($reachableStates | Group-Object -Property { $_.state.Transport })

    $retryBlock = {
        $results = New-Object System.Collections.Generic.List[object]

        foreach ($group in $targetGroups) {
            $groupTargets = @($group.Group | ForEach-Object { $_.computerName })
            $invokeParams = @{
                ComputerName   = $groupTargets
                ScriptBlock    = $ScriptBlock
                Authentication = 'Negotiate'
            }

            if ($Credential) {
                $invokeParams.Credential = $Credential
            }

            if ($ArgumentList) {
                $invokeParams.ArgumentList = $ArgumentList
            }

            if ($group.Name -eq 'https') {
                $invokeParams.UseSSL = $true
            }

            # Issue #113: apply per-session operation timeout when configured
            if ($TimeoutSeconds -gt 0) {
                $sessionOption = New-PSSessionOption -OperationTimeout ($TimeoutSeconds * 1000) -OpenTimeout ($TimeoutSeconds * 1000)
                $invokeParams.SessionOption = $sessionOption
            }

            $rangerRemoteWarnings = @()
            $rangerLogPath = $script:RangerLogPath
            $rangerCurrentLogLevel = if ($script:RangerLogLevel) { [string]$script:RangerLogLevel } else { 'info' }
            $rangerShouldLogWarn = $rangerCurrentLogLevel -in @('debug', 'info', 'warn')
            $rangerRemoteResult = Invoke-Command @invokeParams -WarningAction SilentlyContinue -WarningVariable +rangerRemoteWarnings -ErrorAction Stop
            foreach ($w in @($rangerRemoteWarnings)) {
                $warningMessage = if ($w -is [System.Management.Automation.WarningRecord]) { [string]$w.Message } else { [string]$w }
                if ($rangerShouldLogWarn -and -not [string]::IsNullOrWhiteSpace($warningMessage) -and $rangerLogPath) {
                    try {
                        Add-Content -LiteralPath $rangerLogPath -Value "[$((Get-Date).ToString('s'))][WARN] [$($groupTargets -join ',')] $warningMessage" -Encoding UTF8 -ErrorAction Stop
                    }
                    catch {
                    }
                }
            }

            foreach ($item in @($rangerRemoteResult)) {
                [void]$results.Add($item)
            }
        }

        return $results.ToArray()
    }.GetNewClosure()

    $transientExceptions = @(
        'System.Net.WebException',
        'System.TimeoutException',
        'System.Net.Http.HttpRequestException',
        'Microsoft.Management.Infrastructure.CimException',
        'System.Management.Automation.Remoting.PSRemotingTransportException'
    )

    Invoke-RangerRetry -RetryCount $RetryCount -DelaySeconds 1 -Exponential -ScriptBlock $retryBlock -Label 'Invoke-RangerRemoteCommand' -Target ($ComputerName -join ',') -RetryOnExceptionType $transientExceptions
}

function Invoke-RangerRedfishRequest {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter(Mandatory = $true)]
        [PSCredential]$Credential,

        [string]$Method = 'Get'
    )

    Invoke-RangerRetry -RetryCount 1 -ScriptBlock {
        Invoke-RestMethod -Uri $Uri -Method $Method -Credential $Credential -SkipCertificateCheck -ContentType 'application/json' -ErrorAction Stop
    }
}

function Invoke-RangerRedfishCollection {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CollectionUri,

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

        [Parameter(Mandatory = $true)]
        [PSCredential]$Credential
    )

    $results = @()
    $collection = Invoke-RangerRedfishRequest -Uri $CollectionUri -Credential $Credential
    foreach ($member in @($collection.Members)) {
        $path = $member.'@odata.id'
        if (-not $path) {
            continue
        }

        $uri = if ($path -match '^https?://') { $path } else { "https://$Host$path" }
        try {
            $results += Invoke-RangerRedfishRequest -Uri $uri -Credential $Credential
        }
        catch {
            Write-RangerLog -Level warn -Message "Redfish member retrieval failed for '$uri': $($_.Exception.Message)"
        }
    }

    return @($results)
}

function Connect-RangerAzureContext {
    param(
        $AzureCredentialSettings
    )

    $settings = if ($AzureCredentialSettings) { ConvertTo-RangerHashtable -InputObject $AzureCredentialSettings } else { [ordered]@{ method = 'existing-context' } }
    $method = if ($settings.method) { [string]$settings.method } else { 'existing-context' }

    if (-not (Test-RangerCommandAvailable -Name 'Get-AzContext')) {
        return $false
    }

    $context = Get-AzContext -ErrorAction SilentlyContinue
    if ($context) {
        if ($settings.subscriptionId -and $context.Subscription -and $context.Subscription.Id -ne $settings.subscriptionId -and (Test-RangerCommandAvailable -Name 'Set-AzContext')) {
            try {
                Set-AzContext -SubscriptionId $settings.subscriptionId -ErrorAction Stop | Out-Null
            }
            catch {
                Write-RangerLog -Level warn -Message "Failed to switch Az context to subscription '$($settings.subscriptionId)': $($_.Exception.Message)"
            }
        }

        return $true
    }

    switch ($method) {
        'managed-identity' {
            $connectParams = @{ Identity = $true; ErrorAction = 'Stop' }
            if ($settings.clientId) {
                $connectParams.AccountId = $settings.clientId
            }
            if ($settings.subscriptionId) {
                $connectParams.Subscription = $settings.subscriptionId
            }

            Connect-AzAccount @connectParams | Out-Null
            return $true
        }
        'device-code' {
            $connectParams = @{ UseDeviceAuthentication = $true; ErrorAction = 'Stop' }
            if ($settings.tenantId) {
                $connectParams.Tenant = $settings.tenantId
            }
            if ($settings.subscriptionId) {
                $connectParams.Subscription = $settings.subscriptionId
            }

            Connect-AzAccount @connectParams | Out-Null
            return $true
        }
        'service-principal' {
            if (-not $settings.clientSecretSecureString) {
                throw 'Azure service-principal authentication requires a resolved client secret.'
            }

            $credential = [PSCredential]::new([string]$settings.clientId, $settings.clientSecretSecureString)
            $connectParams = @{
                ServicePrincipal = $true
                Tenant           = $settings.tenantId
                Credential       = $credential
                ErrorAction      = 'Stop'
            }
            if ($settings.subscriptionId) {
                $connectParams.Subscription = $settings.subscriptionId
            }

            Connect-AzAccount @connectParams | Out-Null
            return $true
        }
        default {
            return $false
        }
    }
}

function Invoke-RangerAzureQuery {
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,

        [object[]]$ArgumentList,
        $AzureCredentialSettings
    )

    if (-not (Connect-RangerAzureContext -AzureCredentialSettings $AzureCredentialSettings)) {
        return $null
    }

    & $ScriptBlock @ArgumentList
}

function Test-RangerAzureCliAuthenticated {
    if (-not (Test-RangerCommandAvailable -Name 'az')) {
        return $false
    }

    & az account show --output json 2>$null | Out-Null
    return $LASTEXITCODE -eq 0
}

function Get-RangerFixtureData {
    param(
        [string]$Path
    )

    if ([string]::IsNullOrWhiteSpace($Path)) {
        return $null
    }

    $resolvedPath = Resolve-RangerPath -Path $Path
    if (-not (Test-Path -Path $resolvedPath)) {
        throw "Fixture file not found: $resolvedPath"
    }

    return Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 100
}