Modules/Private/10-Utilities.ps1

function Get-RangerTimestamp {
    (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
}

function Resolve-RangerLogLevel {
    param(
        [AllowNull()]
        [string]$Level
    )

    switch (($Level ?? 'info').ToLowerInvariant()) {
        'verbose' { 'debug' }
        'warning' { 'warn' }
        default { ($Level ?? 'info').ToLowerInvariant() }
    }
}

function Get-RangerLogLevelRank {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Level
    )

    switch (Resolve-RangerLogLevel -Level $Level) {
        'debug' { 0 }
        'info' { 1 }
        'warn' { 2 }
        'error' { 3 }
        default { 1 }
    }
}

function ConvertTo-RangerLogMessage {
    param(
        [AllowNull()]
        $InputObject
    )

    if ($null -eq $InputObject) {
        return ''
    }

    if ($InputObject -is [System.Management.Automation.WarningRecord]) {
        return [string]$InputObject.Message
    }

    if ($InputObject -is [System.Management.Automation.ErrorRecord]) {
        return [string]$InputObject.ToString()
    }

    if ($InputObject -is [System.Management.Automation.InformationRecord]) {
        return [string]$InputObject.MessageData
    }

    return [string]$InputObject
}

function Write-RangerLog {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Message,

        [ValidateSet('debug', 'info', 'warn', 'error')]
        [string]$Level = 'info'
    )

    $normalizedLevel = Resolve-RangerLogLevel -Level $Level
    $currentLevel = Resolve-RangerLogLevel -Level $(if ($script:RangerLogLevel) { $script:RangerLogLevel } else { 'info' })
    if ((Get-RangerLogLevelRank -Level $normalizedLevel) -lt (Get-RangerLogLevelRank -Level $currentLevel)) {
        return
    }

    $timestamp = (Get-Date).ToString('s')
    $levelTag = $normalizedLevel.ToUpperInvariant().PadRight(5)
    $line = "[$timestamp][$levelTag] $Message"

    Write-Verbose $line

    # Issue #109: write to file log when package root is known
    if ($script:RangerLogPath) {
        try {
            Add-Content -LiteralPath $script:RangerLogPath -Value $line -Encoding UTF8 -ErrorAction Stop
        }
        catch {
            # Swallow file write errors — never let logging crash the run
        }
    }
}

function Initialize-RangerFileLog {
    param(
        [Parameter(Mandatory = $true)]
        [string]$PackageRoot
    )

    $logPath = Join-Path -Path $PackageRoot -ChildPath 'ranger.log'
    $script:RangerLogPath = $logPath
    $header = "# AzureLocalRanger log — started $(Get-Date -Format 'o')"
    try {
        Set-Content -LiteralPath $logPath -Value $header -Encoding UTF8 -ErrorAction Stop
    }
    catch {
        $script:RangerLogPath = $null
    }
    return $logPath
}

function ConvertTo-RangerHashtable {
    param(
        [AllowNull()]
        $InputObject
    )

    if ($null -eq $InputObject) {
        return $null
    }

    if ($InputObject -is [System.Collections.IDictionary]) {
        $result = [ordered]@{}
        foreach ($key in $InputObject.Keys) {
            $result[$key] = ConvertTo-RangerHashtable -InputObject $InputObject[$key]
        }

        return $result
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        $items = @()
        foreach ($item in $InputObject) {
            $items += ConvertTo-RangerHashtable -InputObject $item
        }

        return $items
    }

    if ($InputObject -is [string] -or $InputObject -is [char] -or $InputObject -is [System.ValueType] -or $InputObject -is [datetime] -or $InputObject -is [version] -or $InputObject -is [guid]) {
        return $InputObject
    }

    $properties = $InputObject.PSObject.Properties
    if ($properties -and $properties.Count -gt 0) {
        $result = [ordered]@{}
        foreach ($property in $properties) {
            $result[$property.Name] = ConvertTo-RangerHashtable -InputObject $property.Value
        }

        return $result
    }

    return $InputObject
}

function Get-RangerSafeName {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$Value
    )

    if ([string]::IsNullOrWhiteSpace($Value)) { return 'unnamed' }
    ($Value -replace '[^A-Za-z0-9._-]', '-').Trim('-')
}

function New-RangerFinding {
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('critical', 'warning', 'informational', 'good')]
        [string]$Severity,

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

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

        [string[]]$AffectedComponents = @(),
        [string]$CurrentState,
        [string]$Recommendation,
        [string[]]$EvidenceReferences = @()
    )

    [ordered]@{
        severity           = $Severity
        title              = $Title
        description        = $Description
        affectedComponents = @($AffectedComponents)
        currentState       = $CurrentState
        recommendation     = $Recommendation
        evidenceReferences = @($EvidenceReferences)
    }
}

function New-RangerArtifactRecord {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Type,

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

        [Parameter(Mandatory = $true)]
        [ValidateSet('generated', 'skipped', 'planned')]
        [string]$Status,

        [string]$Audience,
        [string]$Reason
    )

    [ordered]@{
        type         = $Type
        relativePath = $RelativePath
        status       = $Status
        audience     = $Audience
        reason       = $Reason
    }
}

function Resolve-RangerPath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [string]$BasePath = (Get-Location).Path
    )

    if ([System.IO.Path]::IsPathRooted($Path)) {
        return [System.IO.Path]::GetFullPath($Path)
    }

    return [System.IO.Path]::GetFullPath((Join-Path -Path $BasePath -ChildPath $Path))
}

function Test-RangerCommandAvailable {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    [bool](Get-Command -Name $Name -ErrorAction SilentlyContinue)
}

function ConvertTo-RangerPlainText {
    param(
        [AllowNull()]
        $Value
    )

    if ($null -eq $Value) {
        return $null
    }

    if ($Value -is [securestring]) {
        $credential = [System.Management.Automation.PSCredential]::new('ignored', $Value)
        return $credential.GetNetworkCredential().Password
    }

    return [string]$Value
}

function ConvertTo-RangerGiB {
    param(
        [AllowNull()]
        $Value
    )

    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
        return $null
    }

    try {
        return [math]::Round(([double]$Value / 1GB), 2)
    }
    catch {
        return $null
    }
}

function Get-RangerFlattenedCollection {
    param(
        [AllowNull()]
        $Items
    )

    $results = New-Object System.Collections.ArrayList
    foreach ($item in @($Items)) {
        if ($null -eq $item) {
            continue
        }

        if ($item -is [System.Collections.IEnumerable] -and $item -isnot [string] -and $item -isnot [System.Collections.IDictionary]) {
            foreach ($child in $item) {
                if ($null -ne $child) {
                    [void]$results.Add($child)
                }
            }

            continue
        }

        [void]$results.Add($item)
    }

    return @($results)
}

function Get-RangerGroupedCount {
    param(
        [AllowNull()]
        $Items,

        [Parameter(Mandatory = $true)]
        [string]$PropertyName
    )

    return @(
        @($Items | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_.($PropertyName)) }) |
            Group-Object -Property $PropertyName |
            Sort-Object -Property @(
                @{ Expression = 'Count'; Descending = $true },
                @{ Expression = 'Name'; Descending = $false }
            ) |
            ForEach-Object {
                [ordered]@{
                    name  = $_.Name
                    count = $_.Count
                }
            }
    )
}

function Get-RangerAverageValue {
    param(
        [AllowNull()]
        $Values
    )

    $numbers = @($Values | Where-Object { $null -ne $_ } | ForEach-Object { [double]$_ })
    if ($numbers.Count -eq 0) {
        return $null
    }

    return [math]::Round((($numbers | Measure-Object -Average).Average), 2)
}

function Add-RangerRetryDetail {
    param(
        [string]$Target,
        [string]$Label,
        [int]$Attempt,
        [string]$ExceptionType,
        [string]$Message
    )

    if ($script:RangerRetryDetails -isnot [System.Collections.IList]) {
        return
    }

    [void]$script:RangerRetryDetails.Add([ordered]@{
        timestampUtc  = (Get-Date).ToUniversalTime().ToString('o')
        target        = $Target
        label         = $Label
        attempt       = $Attempt
        exceptionType = $ExceptionType
        message       = $Message
    })
}