Private/api-helpers.ps1

# Shared response/async-job helpers used by the public network functions.

function ConvertFrom-CSResponse {
    <#
    .SYNOPSIS
        Unwraps a CloudStack API response down to the useful payload.

    .DESCRIPTION
        CloudStack wraps every response as <command>response. List responses hold
        a 'count' plus one array property (e.g. physicalnetwork); create/update
        responses hold one object property (e.g. network). When the body holds
        exactly one object/array property (ignoring 'count') that value is
        returned; otherwise the body itself is returned (e.g. { success = true }
        or an async { jobid, id } pair). An empty list returns nothing.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [object]$Response,

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

    if ($null -eq $Response) { return }
    $body = $Response.("$($Command.ToLowerInvariant())response")
    if ($null -eq $body) { return }

    $props = @($body.PSObject.Properties | Where-Object { $_.Name -ne 'count' })
    if ($props.Count -eq 0) { return }
    if ($props.Count -eq 1 -and $null -ne $props[0].Value -and
        -not ($props[0].Value -is [string] -or $props[0].Value -is [ValueType])) {
        return $props[0].Value
    }
    return $body
}

function Wait-CSAsyncJob {
    <#
    .SYNOPSIS
        Polls queryAsyncJobResult until a CloudStack async job finishes.

    .DESCRIPTION
        Returns the unwrapped job result on success (e.g. the created physical
        network) and throws with the CloudStack error text on failure. Writes a
        warning and returns nothing if the job is still pending at the timeout.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$JobId,

        [int]$TimeoutSeconds = 300,

        [int]$PollSeconds = 3
    )

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ($true) {
        $status = ConvertFrom-CSResponse -Response (Invoke-CSApiRequest -Command 'queryAsyncJobResult' -Parameters @{ jobid = $JobId }) -Command 'queryAsyncJobResult'

        switch ([int]$status.jobstatus) {
            1 {
                $result = $status.jobresult
                if ($null -eq $result) { return }
                $props = @($result.PSObject.Properties)
                if ($props.Count -eq 1 -and $null -ne $props[0].Value -and
                    -not ($props[0].Value -is [string] -or $props[0].Value -is [ValueType])) {
                    return $props[0].Value
                }
                return $result
            }
            2 {
                throw "CloudStack async job $JobId failed: $($status.jobresult.errortext)"
            }
        }

        if ((Get-Date) -ge $deadline) {
            Write-Warning "CloudStack async job $JobId did not finish within $TimeoutSeconds seconds."
            return
        }
        Write-Verbose "Async job $JobId still pending; checking again in $PollSeconds seconds."
        Start-Sleep -Seconds $PollSeconds
    }
}

function Invoke-CSAsyncApiRequest {
    <#
    .SYNOPSIS
        Sends an asynchronous CloudStack command, optionally waiting for the result.

    .DESCRIPTION
        Without -Wait, returns the { jobid, id } job handle immediately (the same
        behaviour as the module's other async wrappers). With -Wait, polls the job
        and returns the finished result instead.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$Command,

        [hashtable]$Parameters = @{},

        [switch]$Wait
    )

    $body = ConvertFrom-CSResponse -Response (Invoke-CSApiRequest -Command $Command -Parameters $Parameters) -Command $Command
    if ($null -eq $body -or -not $body.jobid) { return $body }

    Write-Verbose "$Command started. Job ID: $($body.jobid)"
    if ($Wait) { return Wait-CSAsyncJob -JobId $body.jobid }
    return $body
}

function Add-CSOptionalParameter {
    <#
    .SYNOPSIS
        Copies bound optional parameters into a CloudStack API parameter table.

    .DESCRIPTION
        Map is PowerShellParameterName -> apiparametername. Only parameters the
        caller actually supplied are copied. Arrays are comma-joined, booleans and
        switches are sent as 'true'/'false'.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$ApiParameters,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$BoundParameters,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Map
    )

    foreach ($name in $Map.Keys) {
        if (-not $BoundParameters.ContainsKey($name)) { continue }
        $value = $BoundParameters[$name]
        if ($value -is [System.Management.Automation.SwitchParameter]) { $value = $value.IsPresent }
        if ($value -is [bool]) { $value = $value.ToString().ToLowerInvariant() }
        elseif ($value -is [array]) { $value = ($value -join ',') }
        $ApiParameters[$Map[$name]] = $value
    }
}

function ConvertTo-CSDateParameter {
    <#
    .SYNOPSIS
        Formats a date for a CloudStack API date parameter.

    .DESCRIPTION
        Strings are passed through unchanged, so CloudStack's own rules apply
        (listUsageRecords, for example, reads a bare '2026-09-30' end date as the
        end of that day). DateTime values are formatted with -Format; the default
        includes the UTC offset (e.g. 2026-09-30T18:00:00-0400) so the server does
        not reinterpret the time in its own time zone.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [object]$Value,

        [string]$Format = 'Offset'
    )

    if ($Value -is [string]) { return $Value }
    if ($Value -isnot [datetime]) {
        throw "Expected a date string or DateTime, got a $($Value.GetType().Name)."
    }
    if ($Format -ne 'Offset') { return $Value.ToString($Format, [System.Globalization.CultureInfo]::InvariantCulture) }

    $offset = if ($Value.Kind -eq [System.DateTimeKind]::Utc) { [TimeSpan]::Zero } else { [System.TimeZoneInfo]::Local.GetUtcOffset($Value) }
    $sign = if ($offset -lt [TimeSpan]::Zero) { '-' } else { '+' }
    $abs = $offset.Duration()
    return $Value.ToString("yyyy-MM-dd'T'HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture) +
        ('{0}{1:00}{2:00}' -f $sign, $abs.Hours, $abs.Minutes)
}

$script:CSResourceTypeIds = [ordered]@{
    VirtualMachine   = 0
    PublicIp         = 1
    Volume           = 2
    Snapshot         = 3
    Template         = 4
    Project          = 5
    Network          = 6
    Vpc              = 7
    Cpu              = 8
    Memory           = 9
    PrimaryStorage   = 10
    SecondaryStorage = 11
}

function ConvertTo-CSResourceTypeId {
    <#
    .SYNOPSIS
        Maps a friendly resource-type name to its CloudStack resourcetype number.

    .DESCRIPTION
        CloudStack's limit APIs key resources by an integer resourcetype
        (0 = VirtualMachine, 1 = PublicIp, 2 = Volume, 3 = Snapshot,
        4 = Template, 5 = Project, 6 = Network, 7 = Vpc, 8 = Cpu, 9 = Memory,
        10 = PrimaryStorage, 11 = SecondaryStorage). A name from that list is
        translated; a value that is already the number is returned as-is.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [object]$ResourceType
    )

    if ($script:CSResourceTypeIds.Contains([string]$ResourceType)) {
        return $script:CSResourceTypeIds[[string]$ResourceType]
    }
    return $ResourceType
}

function Add-CSMapParameter {
    <#
    .SYNOPSIS
        Expands a hashtable into CloudStack's indexed map parameter syntax.

    .DESCRIPTION
        CloudStack takes map parameters in two shapes:
          - keyed: details[0].cpuNumber=2&details[0].memory=4096
            (used when -KeyField/-ValueField are omitted)
          - paired: nicnetworklist[0].nic=NIC1&nicnetworklist[0].network=net-uuid
            (one index per entry, with the hashtable key and value stored under
            -KeyField and -ValueField)
        Does nothing when -Map is null or empty.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$ApiParameters,

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

        [System.Collections.IDictionary]$Map,

        [string]$KeyField,

        [string]$ValueField
    )

    if ($null -eq $Map -or $Map.Count -eq 0) { return }

    $index = 0
    foreach ($key in $Map.Keys) {
        if ($KeyField) {
            $ApiParameters["$Name[$index].$KeyField"] = [string]$key
            $ApiParameters["$Name[$index].$ValueField"] = [string]$Map[$key]
            $index++
        }
        else {
            $ApiParameters["$Name[0].$key"] = [string]$Map[$key]
        }
    }
}