Private/Transforms.ps1

# Catalog transforms and the helpers that place a value at a property path.
#
# idOf:<type> a name as that type's id, through POST /deployments/resources/read
# idsOf:<type> names as ids
# relationOf:<kind> names as [{id, type: <kind>}], resolved through VirtualMetric/<kind>s
# secure a SecureString, kept as one until the body is serialized
# merge a hashtable deep-merged into the object at the target
# status enable/disable: properties.status active or passive

function Resolve-VMetricResourceId {
    # The ids of resources of one type, by name, in the order given. A name nothing answers to is an error.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [string] $Type,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]] $Name,

        [AllowNull()]
        [System.Collections.IDictionary] $Parent
    )

    if ($Name.Count -eq 0) {
        return , ([string[]]@())
    }
    $resources = foreach ($item in $Name) {
        $lookup = [ordered]@{ type = $Type; name = $item }
        if ($Parent) {
            $lookup['parent'] = $Parent
        }
        $lookup
    }
    $answer = Invoke-VMetricRequest -Method POST -Path '/deployments/resources/read' -Body ([ordered]@{ resources = @($resources) })
    $found = ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $answer -Name 'resources')

    $ids = [System.Collections.Generic.List[string]]::new()
    for ($i = 0; $i -lt $Name.Count; $i++) {
        $match = $null
        foreach ($candidate in $found) {
            if ([string](Get-VMetricMember -InputObject $candidate -Name 'name') -ceq $Name[$i]) {
                $match = $candidate
                break
            }
        }
        if (-not $match -and $i -lt $found.Count) {
            $match = $found[$i]
        }
        if (-not $match -or -not (Get-VMetricMember -InputObject $match -Name 'exists')) {
            throw (New-VMetricException -Message "No $Type named '$(ConvertTo-VMetricSafeText -Text $Name[$i])' was found." -Code 'ResourceNotFound' -Category ObjectNotFound)
        }
        $ids.Add([string](Get-VMetricMember -InputObject $match -Name 'id'))
    }
    return , $ids.ToArray()
}

function Resolve-VMetricParamValue {
    # A bound value as the catalog param's transform makes it. Switches become booleans.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Param,

        [AllowNull()]
        [object] $Value
    )

    if ($Value -is [System.Management.Automation.SwitchParameter]) {
        $Value = $Value.IsPresent
    }
    $transform = [string](Get-VMetricMember -InputObject $Param -Name 'transform')
    # field and fieldValue resolve against the resource a set reads (Resolve-VMetricField), not here.
    if (-not $transform -or $transform -eq 'secure' -or $transform -eq 'merge' -or $transform -like 'field:*' -or $transform -eq 'fieldValue') {
        if ($Value -is [array]) {
            return , $Value
        }
        return $Value
    }
    if ($transform -eq 'status') {
        if ($Value -is [bool]) {
            return $(if ($Value) { 'active' } else { 'passive' })
        }
        return [string]$Value
    }

    $separator = $transform.IndexOf(':')
    $name = if ($separator -gt 0) { $transform.Substring(0, $separator) } else { $transform }
    $argument = if ($separator -gt 0) { $transform.Substring($separator + 1) } else { '' }
    if ($name -eq 'idOf') {
        if ($null -eq $Value -or [string]$Value -eq '') {
            return ''
        }
        return (Resolve-VMetricResourceId -Type $argument -Name @([string]$Value))[0]
    }
    if ($name -eq 'idsOf' -or $name -eq 'relationOf') {
        $names = [System.Collections.Generic.List[string]]::new()
        foreach ($item in (ConvertTo-VMetricArray -InputObject $Value)) {
            if ([string]$item -ne '') {
                $names.Add([string]$item)
            }
        }
        if ($name -eq 'idsOf') {
            return , (Resolve-VMetricResourceId -Type $argument -Name $names.ToArray())
        }
        $relations = [System.Collections.Generic.List[object]]::new()
        foreach ($id in (Resolve-VMetricResourceId -Type "VirtualMetric/$($argument)s" -Name $names.ToArray())) {
            $relations.Add([ordered]@{ id = $id; type = $argument })
        }
        return , $relations.ToArray()
    }
    throw (New-VMetricException -Message "The cmdlet catalog uses a transform this module does not know ($transform). Update the VirtualMetric module." `
            -Code 'UnknownTransform' -Category NotImplemented -Terminating)
}

function Get-VMetricEntryList {
    # The name/value pairs of a dictionary or a PSCustomObject.
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $entries = [System.Collections.Generic.List[object]]::new()
    if ($InputObject -is [System.Collections.IDictionary]) {
        foreach ($key in $InputObject.Keys) {
            $entries.Add([pscustomobject]@{ Name = [string]$key; Value = $InputObject[$key] })
        }
    }
    elseif (Test-VMetricObjectLike -InputObject $InputObject) {
        foreach ($property in $InputObject.psobject.Properties) {
            if ($property.MemberType -eq 'NoteProperty') {
                $entries.Add([pscustomobject]@{ Name = $property.Name; Value = $property.Value })
            }
        }
    }
    return , $entries.ToArray()
}

function Get-VMetricChildDictionary {
    # The dictionary under a key, created (or converted from a PSCustomObject) when needed. The existing key's
    # spelling is kept: the engine reads property names case-sensitively.
    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Parent,

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

    $key = Get-VMetricDictionaryKey -Dictionary $Parent -Name $Name
    if ($null -eq $key) {
        $key = $Name
        $Parent[$key] = [ordered]@{}
    }
    elseif ($Parent[$key] -isnot [System.Collections.IDictionary]) {
        if (Test-VMetricObjectLike -InputObject $Parent[$key]) {
            $Parent[$key] = Copy-VMetricData -InputObject $Parent[$key]
        }
        else {
            $Parent[$key] = [ordered]@{}
        }
    }
    return $Parent[$key]
}

function Set-VMetricPathValue {
    # Sets the value at a dotted path (properties.properties.port), creating the objects on the way.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes an in-memory object only.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path,

        [AllowNull()]
        [object] $Value
    )

    $segments = $Path.Split('.')
    $node = $Target
    for ($i = 0; $i -lt $segments.Length - 1; $i++) {
        $node = Get-VMetricChildDictionary -Parent $node -Name $segments[$i]
    }
    $last = $segments[$segments.Length - 1]
    $key = Get-VMetricDictionaryKey -Dictionary $node -Name $last
    if ($null -eq $key) {
        $key = $last
    }
    $node[$key] = $Value
}

function Get-VMetricPathValue {
    # The value at a dotted path, matching keys ignoring case; $null when a step is missing.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

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

    $node = $Target
    foreach ($segment in $Path.Split('.')) {
        $node = Get-VMetricMember -InputObject $node -Name $segment
        if ($null -eq $node) {
            return $null
        }
    }
    if ($node -is [array]) {
        return , $node
    }
    return $node
}

# -Field and -Value (transforms field and fieldValue) change one field of a set's resource by the name
# Format-List * shows it under. The resource is its own schema: a name resolves against the properties the
# engine read, a property first and then a key of one of the output type's flatten objects (a device's own
# settings), the way the object copies those keys onto itself; and the value takes the type of the value it
# replaces. Cloud Shell's runtime/fields.ts does the same, message for message.

# How many names an unknown field's message lists before it stops.
$script:VMetricFieldNamesShown = 20
# A number -Value may be written as: digits, an optional fraction and exponent. No hex, no thousands
# separators, whatever the culture.
$script:VMetricFieldNumberPattern = '^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$'

function Test-VMetricFieldLeaf {
    # A value -Field can set: one value, not an object or a list.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()]
        [object] $Value
    )

    return ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType])
}

function Test-VMetricFieldNumber {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()]
        [object] $Value
    )

    return ($Value -is [long] -or $Value -is [int] -or $Value -is [short] -or $Value -is [byte] -or $Value -is [sbyte] -or
        $Value -is [ulong] -or $Value -is [uint] -or $Value -is [ushort] -or $Value -is [double] -or $Value -is [single] -or
        $Value -is [decimal])
}

function Get-VMetricFieldContainer {
    # The flatten objects of a resource's properties, spelled as the read spells them: where a field that is no
    # property of its own is looked for.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.IDictionary] $Properties,

        [AllowEmptyCollection()]
        [string[]] $Flatten = @()
    )

    $containers = [System.Collections.Generic.List[string]]::new()
    foreach ($name in $Flatten) {
        $key = Get-VMetricDictionaryKey -Dictionary $Properties -Name $name
        if ($null -ne $key -and $Properties[$key] -is [System.Collections.IDictionary] -and -not $containers.Contains($key)) {
            $containers.Add($key)
        }
    }
    return , $containers.ToArray()
}

function Get-VMetricFieldName {
    # The names -Field takes on a resource, as Format-List * shows them: each property that holds one value,
    # then each key of the flatten objects that no property shadows, and the fields inside an object as dotted
    # paths (tls.status). A list is left out: -Field sets one value.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.IDictionary] $Properties,

        [AllowEmptyCollection()]
        [string[]] $Flatten = @()
    )

    $names = [System.Collections.Generic.List[string]]::new()
    $walk = {
        param([string] $Prefix, $Value, [int] $Depth)

        if (Test-VMetricFieldLeaf -Value $Value) {
            $names.Add($Prefix)
            return
        }
        if ($Value -is [System.Collections.IDictionary] -and $Depth -lt 5) {
            foreach ($key in $Value.Keys) {
                & $walk "$Prefix.$key" $Value[$key] ($Depth + 1)
            }
        }
    }
    $containers = Get-VMetricFieldContainer -Properties $Properties -Flatten $Flatten
    $own = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($key in $Properties.Keys) {
        $name = [string]$key
        if ($containers -contains $name) {
            continue
        }
        $null = $own.Add($name)
        & $walk $name $Properties[$key] 1
    }
    foreach ($container in $containers) {
        $settings = $Properties[$container]
        foreach ($key in $settings.Keys) {
            if (-not $own.Contains([string]$key)) {
                & $walk ([string]$key) $settings[$key] 1
            }
        }
    }
    return , $names.ToArray()
}

function Format-VMetricFieldNameList {
    # The names an unknown field's message offers, the first few of them.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowEmptyCollection()]
        [string[]] $Name = @()
    )

    if ($Name.Count -eq 0) {
        return 'none'
    }
    $shown = @($Name | Select-Object -First $script:VMetricFieldNamesShown | ForEach-Object { ConvertTo-VMetricSafeText -Text $_ })
    $text = $shown -join ', '
    if ($Name.Count -gt $script:VMetricFieldNamesShown) {
        $text += ', ...'
    }
    return $text
}

function Resolve-VMetricField {
    # -Field in a resource's current properties: the dotted path under properties it names, matched ignoring
    # case and spelled as the read spells it, and the value there now.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.IDictionary] $Properties,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Field,

        [AllowEmptyCollection()]
        [string[]] $Flatten = @(),

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

    $shown = ConvertTo-VMetricSafeText -Text $Field
    $notFound = {
        $list = Format-VMetricFieldNameList -Name (Get-VMetricFieldName -Properties $Properties -Flatten $Flatten)
        New-VMetricException -Message "'$shown' is not a field of $Label that -Field can change. Its fields: $list." -Code 'FieldNotFound' -Category InvalidArgument
    }
    $segments = $Field.Split('.')
    foreach ($segment in $segments) {
        if ($segment.Trim() -eq '') {
            throw (& $notFound)
        }
    }
    $containers = Get-VMetricFieldContainer -Properties $Properties -Flatten $Flatten
    $path = [System.Collections.Generic.List[string]]::new()
    $node = $null
    $first = Get-VMetricDictionaryKey -Dictionary $Properties -Name $segments[0]
    # A flatten object named alone is no one field (the resolution below says so); followed by a key, it is
    # the path it spells.
    if ($null -ne $first -and ($containers -notcontains $first -or $segments.Length -gt 1)) {
        $path.Add($first)
        $node = $Properties[$first]
    }
    else {
        foreach ($container in $containers) {
            $key = Get-VMetricDictionaryKey -Dictionary $Properties[$container] -Name $segments[0]
            if ($null -ne $key) {
                $path.Add($container)
                $path.Add($key)
                $node = $Properties[$container][$key]
                break
            }
        }
        if ($path.Count -eq 0) {
            if ($null -eq $first) {
                throw (& $notFound)
            }
            $path.Add($first)
            $node = $Properties[$first]
        }
    }
    for ($i = 1; $i -lt $segments.Length; $i++) {
        $key = $null
        if ($node -is [System.Collections.IDictionary]) {
            $key = Get-VMetricDictionaryKey -Dictionary $node -Name $segments[$i]
        }
        if ($null -eq $key) {
            throw (& $notFound)
        }
        $path.Add($key)
        $node = $node[$key]
    }
    if (-not (Test-VMetricFieldLeaf -Value $node)) {
        throw (New-VMetricException -Message "'$shown' of $Label holds more than one value, and -Field sets one: name one of its fields, or use -Settings or -Property." `
                -Code 'FieldNotSingle' -Category InvalidArgument)
    }
    [pscustomobject]@{
        Path    = 'properties.' + ($path -join '.')
        Current = $node
    }
}

function ConvertTo-VMetricFieldValue {
    # -Value as the type of the value it replaces: true or false for a boolean (from $true or the text true), a
    # number for a number (a whole one for a whole one), text for text, and as given where the field is empty.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $Current,

        [AllowNull()]
        [object] $Value,

        [Parameter(Mandatory)]
        [string] $Field,

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

    if ($Value -is [System.Management.Automation.PSObject]) {
        $Value = $Value.psobject.BaseObject
    }
    $shown = ConvertTo-VMetricSafeText -Text $Field
    # A SecureString stays one: the request sends its text, and a preview masks it wherever it appears.
    if ($Value -is [System.Security.SecureString] -and ($null -eq $Current -or $Current -is [string])) {
        return $Value
    }
    if (-not (Test-VMetricFieldLeaf -Value $Value) -and $Value -isnot [System.Security.SecureString]) {
        throw (New-VMetricException -Message "-Value for '$shown' is one value: text, a number, or true or false." -Code 'FieldValueInvalid' -Category InvalidArgument)
    }
    $valueText = ConvertTo-VMetricSafeText -Text ([string]$Value)
    if ($Value -is [bool]) {
        $valueText = if ($Value) { 'true' } else { 'false' }
    }
    $text = if ($Value -is [string]) { $Value.Trim() } else { $null }

    if ($Current -is [bool]) {
        if ($Value -is [bool]) {
            return $Value
        }
        if ($text -eq 'true') {
            return $true
        }
        if ($text -eq 'false') {
            return $false
        }
        throw (New-VMetricException -Message "'$shown' of $Label is true or false, and -Value '$valueText' is neither." -Code 'FieldValueInvalid' -Category InvalidArgument)
    }

    if (Test-VMetricFieldNumber -Value $Current) {
        $whole = [math]::Truncate([double]$Current) -eq [double]$Current
        $invariant = [System.Globalization.CultureInfo]::InvariantCulture
        if ($whole) {
            # Exactly, not through a double: an id is larger than a double holds to the unit.
            $exact = 0L
            if ($Value -is [long] -or $Value -is [int] -or $Value -is [short] -or $Value -is [byte] -or $Value -is [sbyte] -or
                $Value -is [uint] -or $Value -is [ushort]) {
                return [long]$Value
            }
            if ($null -ne $text -and [long]::TryParse($text, [System.Globalization.NumberStyles]::AllowLeadingSign, $invariant, [ref] $exact)) {
                return $exact
            }
        }
        $number = $null
        if (Test-VMetricFieldNumber -Value $Value) {
            $number = [double]$Value
        }
        elseif ($null -ne $text -and $text -match $script:VMetricFieldNumberPattern) {
            $number = [double]::Parse($text, [System.Globalization.NumberStyles]::Float, $invariant)
        }
        if ($null -eq $number -or [double]::IsNaN($number) -or [double]::IsInfinity($number)) {
            throw (New-VMetricException -Message "'$shown' of $Label is a number, and -Value '$valueText' is not." -Code 'FieldValueInvalid' -Category InvalidArgument)
        }
        if (-not $whole) {
            return $number
        }
        if ([math]::Truncate($number) -ne $number -or [math]::Abs($number) -gt 9007199254740991) {
            throw (New-VMetricException -Message "'$shown' of $Label is a whole number, and -Value '$valueText' is not." -Code 'FieldValueInvalid' -Category InvalidArgument)
        }
        return [long]$number
    }

    if ($Current -is [string]) {
        if ($Value -is [bool] -or $Value -is [string]) {
            return $(if ($Value -is [bool]) { $valueText } else { $Value })
        }
        return [System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture)
    }

    # An empty field: nothing to take a type from, so the value as given.
    return $Value
}

function Get-VMetricFieldCompletion {
    # What Tab offers after -Field: the fields of the resource the command line names, read through the engine.
    # A completion never fails and never signs in: without a session, a name or a resource, it offers nothing.
    [CmdletBinding()]
    [OutputType([System.Management.Automation.CompletionResult])]
    param(
        [Parameter(Mandatory)]
        [string] $CommandName,

        [AllowNull()]
        [System.Collections.IDictionary] $Bound,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $WordToComplete
    )

    if ($null -eq $script:VMetricSession -or $null -eq $Bound) {
        return
    }
    try {
        $entry = Get-VMetricCatalogCmdlet -Name $CommandName
        $resourceType = [string](Get-VMetricMember -InputObject $entry -Name 'resourceType')
        $lookup = [ordered]@{ type = $resourceType; name = $null }
        foreach ($param in (Get-VMetricCatalogParameter -Entry $entry)) {
            $target = [string](Get-VMetricMember -InputObject $param -Name 'target')
            $value = $Bound[[string]$param.name]
            if ($value -isnot [string] -or $value -eq '') {
                continue
            }
            if ($target -eq 'name') {
                $lookup['name'] = $value
            }
            elseif ($target -eq 'parent.name') {
                $lookup['parent'] = [ordered]@{ type = [string](Get-VMetricMember -InputObject $entry -Name 'parentType'); name = $value }
            }
        }
        if (-not $resourceType -or -not $lookup['name']) {
            return
        }
        $read = Invoke-VMetricRequest -Method POST -Path '/deployments/resources/read' -Body ([ordered]@{ resources = @($lookup) }) -AsHashtable
        $current = (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $read -Name 'resources')) | Select-Object -First 1
        $properties = Get-VMetricMember -InputObject $current -Name 'properties'
        if (-not [bool](Get-VMetricMember -InputObject $current -Name 'exists') -or $properties -isnot [System.Collections.IDictionary]) {
            return
        }
        $flatten = [System.Collections.Generic.List[string]]::new()
        foreach ($field in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject (Get-VMetricCatalogType -Name ([string]$entry.output)) -Name 'flatten'))) {
            $flatten.Add([string]$field)
        }
        $prefix = ([string]$WordToComplete).Trim([char[]]"'`"")
        foreach ($name in (Get-VMetricFieldName -Properties $properties -Flatten $flatten.ToArray())) {
            if ($name.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
                $text = ConvertTo-VMetricSafeText -Text $name
                [System.Management.Automation.CompletionResult]::new($text, $text, [System.Management.Automation.CompletionResultType]::ParameterValue, $text)
            }
        }
    }
    catch {
        return
    }
}

function Register-VMetricFieldCompleter {
    # Every generated set that takes -Field (transform field) completes it with Get-VMetricFieldCompletion.
    [CmdletBinding()]
    param()

    $completer = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
        $null = $parameterName, $commandAst
        Get-VMetricFieldCompletion -CommandName $commandName -Bound $fakeBoundParameters -WordToComplete $wordToComplete
    }
    try {
        $entries = @((Get-VMetricCatalog).Cmdlets.Values)
    }
    catch {
        # No catalog, no completion: the cmdlets say why when they are called.
        return
    }
    foreach ($entry in $entries) {
        foreach ($param in (Get-VMetricCatalogParameter -Entry $entry)) {
            if ([string](Get-VMetricMember -InputObject $param -Name 'transform') -like 'field:*') {
                Register-ArgumentCompleter -CommandName ([string]$entry.name) -ParameterName ([string]$param.name) -ScriptBlock $completer
            }
        }
    }
}

function Set-VMetricRelationValue {
    # relationOf: the relations of its own kind (director or cluster) are replaced and the others kept, so
    # -Director and -Cluster combine, and Set -Director keeps a device's clusters.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes an in-memory object only.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Kind,

        [AllowNull()]
        [object] $Value
    )

    $relations = [System.Collections.Generic.List[object]]::new()
    foreach ($relation in (ConvertTo-VMetricArray -InputObject (Get-VMetricPathValue -Target $Target -Path $Path))) {
        if ([string](Get-VMetricMember -InputObject $relation -Name 'type') -ne $Kind) {
            $relations.Add($relation)
        }
    }
    foreach ($relation in (ConvertTo-VMetricArray -InputObject $Value)) {
        $relations.Add($relation)
    }
    Set-VMetricPathValue -Target $Target -Path $Path -Value $relations.ToArray()
}

function Merge-VMetricDictionary {
    # Deep-merges Source into Target: objects merge key by key, anything else (a list included) replaces.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [object] $Source
    )

    foreach ($entry in (Get-VMetricEntryList -InputObject $Source)) {
        $key = Get-VMetricDictionaryKey -Dictionary $Target -Name $entry.Name
        if ($null -ne $key -and (Test-VMetricObjectLike -InputObject $entry.Value) -and (Test-VMetricObjectLike -InputObject $Target[$key])) {
            $child = Get-VMetricChildDictionary -Parent $Target -Name $key
            Merge-VMetricDictionary -Target $child -Source $entry.Value
            continue
        }
        if ($null -eq $key) {
            $key = $entry.Name
        }
        $Target[$key] = Copy-VMetricData -InputObject $entry.Value
    }
}

function Merge-VMetricPathValue {
    # The merge transform: deep-merges a hashtable into the object at a dotted path.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path,

        [AllowNull()]
        [object] $Value,

        [string] $ParameterName = 'Property'
    )

    if ($null -eq $Value) {
        return
    }
    if (-not (Test-VMetricObjectLike -InputObject $Value)) {
        throw (New-VMetricException -Message "-$ParameterName takes a hashtable of the properties to change, such as @{ name = 'value' }." `
                -Code 'InvalidMergeValue' -Category InvalidArgument)
    }
    $node = $Target
    foreach ($segment in $Path.Split('.')) {
        $node = Get-VMetricChildDictionary -Parent $node -Name $segment
    }
    Merge-VMetricDictionary -Target $node -Source $Value
}

function Get-VMetricSecretText {
    # The text of every SecureString in a value, whatever its length: what a preview must never print, whatever
    # the server echoes.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $found = [System.Collections.Generic.List[string]]::new()
    $walk = {
        param($Value)

        if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType]) {
            return
        }
        if ($Value -is [System.Security.SecureString]) {
            $text = [System.Net.NetworkCredential]::new('', $Value).Password
            if ($text.Length -gt 0) {
                $found.Add($text)
            }
            return
        }
        foreach ($entry in (Get-VMetricEntryList -InputObject $Value)) {
            & $walk $entry.Value
        }
        if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [System.Collections.IDictionary]) {
            foreach ($item in $Value) {
                & $walk $item
            }
        }
    }
    & $walk $InputObject
    return , $found.ToArray()
}

function Hide-VMetricSecretInObject {
    # Masks, in place, every string of an emitted object (a server's diagnostic, a what-if delta) that carries
    # the text of a secret the request held.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject,

        [AllowNull()]
        [string[]] $Secret
    )

    if (-not $Secret -or $null -eq $InputObject) {
        return
    }
    $walk = {
        param($Value)

        if ($Value -is [System.Collections.IList]) {
            for ($i = 0; $i -lt $Value.Count; $i++) {
                if ($Value[$i] -is [string]) {
                    $Value[$i] = Hide-VMetricSecretText -Text $Value[$i] -Secret $Secret
                }
                else {
                    & $walk $Value[$i]
                }
            }
            return
        }
        if (-not (Test-VMetricObjectLike -InputObject $Value)) {
            return
        }
        if ($Value -is [System.Collections.IDictionary]) {
            foreach ($key in @($Value.Keys)) {
                if ($Value[$key] -is [string]) {
                    $Value[$key] = Hide-VMetricSecretText -Text $Value[$key] -Secret $Secret
                }
                else {
                    & $walk $Value[$key]
                }
            }
            return
        }
        foreach ($property in $Value.psobject.Properties) {
            if ($property.MemberType -ne 'NoteProperty') {
                continue
            }
            if ($property.Value -is [string]) {
                $property.Value = Hide-VMetricSecretText -Text $property.Value -Secret $Secret
            }
            else {
                & $walk $property.Value
            }
        }
    }
    & $walk $InputObject
}

function ConvertTo-VMetricJsonStringText {
    # A string as it reads between the quotes of a JSON string: as ConvertTo-Json writes it, or with -Html as
    # Go's encoding/json does (<, > and & escaped too), which is how the API echoes one inside JSON text.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text,

        [switch] $Html
    )

    $escape = '\' + 'u'
    $builder = [System.Text.StringBuilder]::new()
    foreach ($character in $Text.ToCharArray()) {
        $code = [int]$character
        switch ($code) {
            0x22 { [void]$builder.Append('\"') }
            0x5C { [void]$builder.Append('\\') }
            0x0A { [void]$builder.Append('\n') }
            0x0D { [void]$builder.Append('\r') }
            0x09 { [void]$builder.Append('\t') }
            0x08 { [void]$builder.Append('\b') }
            0x0C { [void]$builder.Append('\f') }
            default {
                if ($code -lt 0x20 -or ($Html -and ($code -in @(0x3C, 0x3E, 0x26, 0x2028, 0x2029)))) {
                    [void]$builder.Append($escape).Append($code.ToString('x4'))
                }
                else {
                    [void]$builder.Append($character)
                }
            }
        }
    }
    $builder.ToString()
}

function Get-VMetricSecretSpelling {
    # Every spelling of the secrets a message may carry: as they are, and as they read inside JSON text (a
    # secret holding a quote or a backslash is echoed as \" or \\), longest first.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [string[]] $Secret
    )

    $spellings = [System.Collections.Generic.List[string]]::new()
    foreach ($value in $Secret) {
        if (-not $value) {
            continue
        }
        foreach ($spelling in @($value, (ConvertTo-VMetricJsonStringText -Text $value), (ConvertTo-VMetricJsonStringText -Text $value -Html))) {
            if (-not $spellings.Contains($spelling)) {
                $spellings.Add($spelling)
            }
        }
    }
    return , @($spellings | Sort-Object -Property Length -Descending)
}

function Hide-VMetricSecretText {
    # Masks the secrets in a message: a text that is a secret, of any length, becomes ***; a secret of three
    # characters or more is replaced wherever the text holds it, as it is or as JSON escapes it. (A shorter one
    # would mask every word it happens to spell; a value that holds one is masked where it sits, by
    # Hide-VMetricSecretValue.)
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Text,

        [AllowNull()]
        [string[]] $Secret
    )

    if ([string]::IsNullOrEmpty($Text) -or -not $Secret) {
        return $Text
    }
    $spellings = Get-VMetricSecretSpelling -Secret $Secret
    foreach ($spelling in $spellings) {
        if ([string]::Equals($Text, $spelling, [System.StringComparison]::Ordinal)) {
            return '***'
        }
    }
    foreach ($spelling in $spellings) {
        if ($spelling.Length -ge 3) {
            $Text = $Text.Replace($spelling, '***')
        }
    }
    return $Text
}

function Get-VMetricSecretPath {
    # Where a value holds a SecureString, as the engine writes a what-if delta's path: properties.value,
    # properties.headers[0].value. A resource's secret paths are masked in its preview whatever the server
    # answers there.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $paths = [System.Collections.Generic.List[string]]::new()
    $walk = {
        param($Value, [string] $Path)

        if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType]) {
            return
        }
        if ($Value -is [System.Security.SecureString]) {
            $paths.Add($Path)
            return
        }
        if ($Value -is [System.Collections.IDictionary] -or (Test-VMetricObjectLike -InputObject $Value)) {
            foreach ($entry in (Get-VMetricEntryList -InputObject $Value)) {
                & $walk $entry.Value $(if ($Path) { "$Path.$($entry.Name)" } else { $entry.Name })
            }
            return
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $index = 0
            foreach ($item in $Value) {
                & $walk $item "$Path[$index]"
                $index++
            }
        }
    }
    & $walk $InputObject ''
    return , $paths.ToArray()
}

function Hide-VMetricSecretValue {
    # A copy of a what-if value for display, masked before it is encoded: a value at one of SecretPath (the
    # request's secrets' own paths) is *** whatever it holds, and every other string goes through
    # Hide-VMetricSecretText. Masking the encoded text instead would miss a secret JSON escapes, or a short one.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject,

        [AllowNull()]
        [string[]] $Secret,

        # The value's own path (properties.value).
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Path,

        [AllowNull()]
        [string[]] $SecretPath
    )

    $secrets = @($Secret)
    $secretPaths = @($SecretPath)
    $walk = {
        param($Value, [string] $Here)

        if ($null -eq $Value) {
            return $null
        }
        $atSecret = $false
        foreach ($candidate in $secretPaths) {
            if ($candidate -and [string]::Equals($candidate, $Here, [System.StringComparison]::OrdinalIgnoreCase)) {
                $atSecret = $true
            }
        }
        if ($Value -is [System.Security.SecureString]) {
            return '***'
        }
        if ($Value -is [string]) {
            if ($atSecret -and $Value -ne '') {
                return '***'
            }
            return (Hide-VMetricSecretText -Text $Value -Secret $secrets)
        }
        if ($Value -is [System.ValueType]) {
            if ($atSecret) {
                return '***'
            }
            return $Value
        }
        if ($Value -is [System.Collections.IDictionary] -or (Test-VMetricObjectLike -InputObject $Value)) {
            $map = [ordered]@{}
            foreach ($entry in (Get-VMetricEntryList -InputObject $Value)) {
                $map[$entry.Name] = & $walk $entry.Value $(if ($Here) { "$Here.$($entry.Name)" } else { $entry.Name })
            }
            return $map
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $list = [System.Collections.Generic.List[object]]::new()
            $index = 0
            foreach ($item in $Value) {
                $list.Add((& $walk $item "$Here[$index]"))
                $index++
            }
            return , $list.ToArray()
        }
        return $Value
    }
    $masked = & $walk $InputObject ([string]$Path)
    if ($masked -is [array]) {
        return , $masked
    }
    return $masked
}