Private/Resolve-TagValue.ps1

function Resolve-TagValue {
    <#
    .SYNOPSIS
        Determines the tag value(s) for a VM based on a category definition and VM metadata.
    .DESCRIPTION
        Core resolution engine for VM-AutoTagger. Given a VM metadata object and a
        category definition from the tag profile, determines what tag value(s) should
        be assigned.

        Resolution strategies (based on the category source):
        - guest_family: Match GuestFamily against wildcard values
        - guest_fullname: Use GuestFullName directly as the tag value (or pass-through)
        - cpu_count: Match NumCPU against numeric tiers
        - memory_gb: Match MemoryGB against numeric tiers
        - tools_status: Match ToolsStatus against wildcard values
        - power_state: Match PowerState against wildcard values
        - snapshot_risk: Use the pre-computed SnapshotRisk value directly
        - vm_age: Use the pre-computed VMAge category directly
        - network: Return all network names (multi-value)
        - datastore: Return all datastore names (multi-value)
        - vm_name: Match VM Name against wildcard values
        - annotation: Extract value from VM Notes using a regex pattern
        - custom_attribute: Read a vSphere custom attribute by name
        - folder: Map vSphere folder path to a tag value
        - direct: Direct match of a source value against values list
    .PARAMETER VMMetadata
        A VM metadata object from Get-VMMetadata.
    .PARAMETER CategoryDefinition
        A category definition object from the parsed tag profile, containing
        Name, Source, Cardinality, Values, Tiers, Pattern, AttributeName, etc.
    .EXAMPLE
        $tagValues = Resolve-TagValue -VMMetadata $meta -CategoryDefinition $catDef
    .NOTES
        Author: Larry Roberts
        Part of the VM-AutoTagger module.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$VMMetadata,

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

    process {
        $source = $CategoryDefinition.Source
        $catName = $CategoryDefinition.Name

        Write-Verbose "Resolving tag for category '$catName' (source: $source) on VM '$($VMMetadata.Name)'"

        switch ($source) {
            'guest_family' {
                $sourceValue = Get-SourceValue -VMMetadata $VMMetadata -Source 'guest_family'
                return @(Resolve-WildcardValue -SourceValue $sourceValue -Values $CategoryDefinition.Values)
            }

            'guest_fullname' {
                $sourceValue = Get-SourceValue -VMMetadata $VMMetadata -Source 'guest_fullname'
                # If values are defined, match against them; otherwise use the raw value
                if ($CategoryDefinition.Values -and $CategoryDefinition.Values.Count -gt 0) {
                    return @(Resolve-WildcardValue -SourceValue $sourceValue -Values $CategoryDefinition.Values)
                }
                # Direct pass-through: use the OS version string as the tag name
                if (-not [string]::IsNullOrWhiteSpace($sourceValue)) {
                    # Sanitize for tag name (max 256 chars, remove problematic characters)
                    $sanitized = $sourceValue -replace '[^\w\s\.\-\(\)]', ''
                    if ($sanitized.Length -gt 128) { $sanitized = $sanitized.Substring(0, 128) }
                    if (-not [string]::IsNullOrWhiteSpace($sanitized)) { return @($sanitized) }
                }
                return @()
            }

            'cpu_count' {
                $sourceValue = $VMMetadata.NumCPU
                return @(Resolve-TierValue -SourceValue $sourceValue -Tiers $CategoryDefinition.Tiers)
            }

            'memory_gb' {
                $sourceValue = $VMMetadata.MemoryGB
                return @(Resolve-TierValue -SourceValue $sourceValue -Tiers $CategoryDefinition.Tiers)
            }

            'tools_status' {
                $sourceValue = Get-SourceValue -VMMetadata $VMMetadata -Source 'tools_status'
                return @(Resolve-WildcardValue -SourceValue $sourceValue -Values $CategoryDefinition.Values)
            }

            'power_state' {
                $sourceValue = Get-SourceValue -VMMetadata $VMMetadata -Source 'power_state'
                return @(Resolve-WildcardValue -SourceValue $sourceValue -Values $CategoryDefinition.Values)
            }

            'snapshot_risk' {
                # Direct mapping from metadata
                $risk = $VMMetadata.SnapshotRisk
                if (-not [string]::IsNullOrWhiteSpace($risk)) {
                    return @($risk)
                }
                return @('Clean')
            }

            'vm_age' {
                $age = $VMMetadata.VMAge
                if (-not [string]::IsNullOrWhiteSpace($age) -and $age -ne 'Unknown') {
                    return @($age)
                }
                return @()
            }

            'network' {
                # Multi-value: return all network names
                if ($VMMetadata.Networks -and $VMMetadata.Networks.Count -gt 0) {
                    $results = @()
                    foreach ($net in $VMMetadata.Networks) {
                        if (-not [string]::IsNullOrWhiteSpace($net) -and $net -ne 'Disconnected') {
                            $results += $net
                        }
                    }
                    if ($results.Count -gt 0) { return $results }
                }
                return @()
            }

            'datastore' {
                # Multi-value: return all datastore names
                if ($VMMetadata.Datastores -and $VMMetadata.Datastores.Count -gt 0) {
                    $results = @()
                    foreach ($ds in $VMMetadata.Datastores) {
                        if (-not [string]::IsNullOrWhiteSpace($ds)) {
                            $results += $ds
                        }
                    }
                    if ($results.Count -gt 0) { return $results }
                }
                return @()
            }

            'vm_name' {
                # Match VM name against wildcard value patterns
                $sourceValue = $VMMetadata.Name
                return @(Resolve-WildcardValue -SourceValue $sourceValue -Values $CategoryDefinition.Values)
            }

            'annotation' {
                # Extract from VM notes using regex pattern
                return @(Resolve-PatternValue -Notes $VMMetadata.Notes -Pattern $CategoryDefinition.Pattern -Values $CategoryDefinition.Values)
            }

            'custom_attribute' {
                # Read from VM custom attributes
                $attrName = $CategoryDefinition.AttributeName
                if ([string]::IsNullOrWhiteSpace($attrName)) {
                    Write-Verbose " Category '$catName' source is custom_attribute but no attribute_name defined."
                    return @()
                }
                $attrValue = $null
                if ($VMMetadata.CustomAttributes -and $VMMetadata.CustomAttributes.ContainsKey($attrName)) {
                    $attrValue = $VMMetadata.CustomAttributes[$attrName]
                }
                if (-not [string]::IsNullOrWhiteSpace($attrValue)) {
                    # If values are defined, match against them; otherwise use raw attribute value
                    if ($CategoryDefinition.Values -and $CategoryDefinition.Values.Count -gt 0) {
                        return @(Resolve-WildcardValue -SourceValue $attrValue -Values $CategoryDefinition.Values)
                    }
                    return @($attrValue)
                }
                return @()
            }

            'folder' {
                # Map folder path to a tag value
                if ($CategoryDefinition.FolderMapping) {
                    $folderName = $VMMetadata.Folder
                    if (-not [string]::IsNullOrWhiteSpace($folderName)) {
                        foreach ($mapping in $CategoryDefinition.FolderMapping) {
                            if ($folderName -like $mapping.match) {
                                return @($mapping.tag)
                            }
                        }
                    }
                }
                return @()
            }

            'name_pattern' {
                # Extract from VM name using regex
                return @(Resolve-NamePatternValue -VMName $VMMetadata.Name -Pattern $CategoryDefinition.NamePattern)
            }

            'direct' {
                # Generic direct matching -- try values list with the category name as source key
                if ($CategoryDefinition.Values -and $CategoryDefinition.Values.Count -gt 0) {
                    # Use the first non-null field that makes sense
                    $testValue = $VMMetadata.Name
                    return @(Resolve-WildcardValue -SourceValue $testValue -Values $CategoryDefinition.Values)
                }
                return @()
            }

            default {
                Write-Verbose " Unknown source type '$source' for category '$catName'."
                return @()
            }
        }
    }
}

function Get-SourceValue {
    <#
    .SYNOPSIS
        Retrieves the raw metadata value for a given source type.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$VMMetadata,

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

    switch ($Source) {
        'guest_family'   { return [string]$VMMetadata.GuestFamily }
        'guest_fullname' { return [string]$VMMetadata.GuestFullName }
        'guest_id'       { return [string]$VMMetadata.GuestId }
        'tools_status'   { return [string]$VMMetadata.ToolsStatus }
        'power_state'    { return [string]$VMMetadata.PowerState }
        'vm_name'        { return [string]$VMMetadata.Name }
        'snapshot_risk'  { return [string]$VMMetadata.SnapshotRisk }
        'vm_age'         { return [string]$VMMetadata.VMAge }
        default          { return '' }
    }
}

function Resolve-TierValue {
    <#
    .SYNOPSIS
        Resolves a numeric value against tier thresholds to determine a tag.
    .DESCRIPTION
        Iterates through tier definitions in order. Each tier has a Max threshold;
        if the source value is less than or equal to Max, that tier's tag is returned.
        Falls back to the default tier if no threshold matches.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [double]$SourceValue,

        [Parameter()]
        [array]$Tiers
    )

    if (-not $Tiers -or $Tiers.Count -eq 0) { return @() }

    $defaultTag = ''
    foreach ($tier in $Tiers) {
        if ($tier.Default -eq $true) {
            $defaultTag = $tier.Tag
            continue
        }

        $max = if ($null -ne $tier.Max) { [double]$tier.Max } else { [double]::MaxValue }
        if ($SourceValue -le $max) {
            if (-not [string]::IsNullOrWhiteSpace($tier.Tag)) {
                return @($tier.Tag)
            }
        }
    }

    # If no tier matched, return default
    if (-not [string]::IsNullOrWhiteSpace($defaultTag)) {
        return @($defaultTag)
    }

    return @()
}

function Resolve-WildcardValue {
    <#
    .SYNOPSIS
        Matches a source string against wildcard patterns to determine a tag.
    .DESCRIPTION
        Iterates through value definitions in order. Each value has a Match pattern
        (supporting wildcards like * and ?). If the source value matches the pattern
        (-like), that value's tag is returned. Falls back to the default value if
        no pattern matches.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$SourceValue,

        [Parameter()]
        [array]$Values
    )

    if (-not $Values -or $Values.Count -eq 0) { return @() }

    $defaultTag = ''
    foreach ($v in $Values) {
        if ($v.Default -eq $true) {
            $defaultTag = $v.Tag
            continue
        }

        $pattern = $v.Match
        if ($null -eq $pattern -or [string]::IsNullOrWhiteSpace([string]$pattern)) {
            continue
        }

        if ($SourceValue -like $pattern) {
            if (-not [string]::IsNullOrWhiteSpace($v.Tag)) {
                return @($v.Tag)
            }
        }
    }

    # Fallback to default
    if (-not [string]::IsNullOrWhiteSpace($defaultTag)) {
        return @($defaultTag)
    }

    return @()
}

function Resolve-PatternValue {
    <#
    .SYNOPSIS
        Extracts a tag value from VM notes using a regex pattern.
    .DESCRIPTION
        If a regex pattern is defined on the category, applies it to the VM notes.
        If a capture group is present, returns the first capture group value.
        Otherwise returns the full match.
        If values are also defined, the extracted value is matched against them.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$Notes,

        [Parameter()]
        [string]$Pattern,

        [Parameter()]
        [array]$Values
    )

    if ([string]::IsNullOrWhiteSpace($Notes)) { return @() }

    if (-not [string]::IsNullOrWhiteSpace($Pattern)) {
        if ($Notes -match $Pattern) {
            $extracted = ''
            if ($Matches.Count -gt 1) {
                $extracted = $Matches[1].Trim()
            }
            else {
                $extracted = $Matches[0].Trim()
            }

            if (-not [string]::IsNullOrWhiteSpace($extracted)) {
                # If values list is provided, match against it
                if ($Values -and $Values.Count -gt 0) {
                    return @(Resolve-WildcardValue -SourceValue $extracted -Values $Values)
                }
                return @($extracted)
            }
        }
    }
    elseif ($Values -and $Values.Count -gt 0) {
        # No pattern but values defined -- match notes content directly
        return @(Resolve-WildcardValue -SourceValue $Notes -Values $Values)
    }

    return @()
}

function Resolve-NamePatternValue {
    <#
    .SYNOPSIS
        Extracts a tag value from the VM name using a regex pattern.
    .DESCRIPTION
        Applies a regex pattern to the VM name and returns the first capture group
        or the full match if no capture groups are defined.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$VMName,

        [Parameter()]
        [string]$Pattern
    )

    if ([string]::IsNullOrWhiteSpace($VMName) -or [string]::IsNullOrWhiteSpace($Pattern)) {
        return @()
    }

    if ($VMName -match $Pattern) {
        $extracted = ''
        if ($Matches.Count -gt 1) {
            $extracted = $Matches[1].Trim()
        }
        else {
            $extracted = $Matches[0].Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($extracted)) {
            return @($extracted)
        }
    }

    return @()
}