Modules/Private/20-Config.ps1

function Get-RangerDefaultConfig {
    # Issue #300 — same bug class as #292 (kv-ranger leak): scaffold placeholder
    # values in the runtime default config survive the deep merge whenever a
    # user config or CLI invocation omits them. The v2.6.3 cluster auto-select
    # gate in Invoke-RangerAzureAutoDiscovery uses [string]::IsNullOrWhiteSpace
    # on clusterName, which returns false for the placeholder 'azlocal-prod-01'
    # — so the cluster-select path was skipped on bare `Invoke-AzureLocalRanger`
    # and the user got stuck in the old prompt loop for env.name / cluster.fqdn
    # / resourceGroup with no auto-discovery help.
    #
    # Fix: null out every structural placeholder here. The annotated YAML
    # template (Get-RangerAnnotatedConfigYaml, below) keeps its '[REQUIRED]'
    # sample values for human editors — that template is still how operators
    # see a bootstrapped config file.
    [ordered]@{
        environment = [ordered]@{
            name        = $null
            clusterName = $null
            description = $null
        }
        targets = [ordered]@{
            cluster = [ordered]@{
                fqdn  = $null
                nodes = @()
            }
            azure = [ordered]@{
                subscriptionId = $null
                resourceGroup  = $null
                tenantId       = $null
            }
            bmc = [ordered]@{
                endpoints = @()
            }
            switches  = @()
            firewalls = @()
        }
        credentials = [ordered]@{
            azure = [ordered]@{
                method             = 'existing-context'
                useAzureCliFallback = $true
            }
            # Issue #292 — credential blocks intentionally carry no placeholder
            # passwordRef / username. A placeholder like 'keyvault://kv-ranger/...'
            # would survive the deep merge when a user config omits these fields
            # and cause the pre-check to fail against a Key Vault the operator
            # never configured. Null values fall through to the interactive
            # prompt (or fail cleanly under -Unattended).
            cluster = [ordered]@{
                username    = $null
                passwordRef = $null
            }
            domain = [ordered]@{
                username    = $null
                passwordRef = $null
            }
            bmc = [ordered]@{
                username    = $null
                passwordRef = $null
            }
        }
        domains = [ordered]@{
            include = @()
            exclude = @()
            hints   = [ordered]@{
                fixtures           = [ordered]@{}
                networkDeviceConfigs = @()
            }
        }
        output = [ordered]@{
            mode            = 'current-state'
            formats         = @('html', 'markdown', 'json', 'svg')
            rootPath        = 'C:\AzureLocalRanger'
            diagramFormat   = 'svg'
            keepRawEvidence = $true
            # Issue #76: enable Spectre.Console live progress bars; suppressed automatically
            # in CI and Unattended mode even when set to $true.
            showProgress    = $true
        }
        behavior = [ordered]@{
            promptForMissingCredentials    = $true
            promptForMissingRequired       = $true
            skipUnavailableOptionalDomains = $true
            failOnSchemaViolation          = $true
            logLevel                       = 'info'
            retryCount                     = 2
            timeoutSeconds                 = 60
            continueToRendering            = $true
            # Issue #30: 'graceful' skips collectors whose transport is unreachable rather
            # than failing; 'strict' fails the run if any core collector cannot reach its target.
            degradationMode                = 'graceful'
            # Issue #26: 'auto' tries WinRM first, falls back to Arc Run Command when WinRM
            # is blocked; 'winrm' forces WinRM only; 'arc' forces Arc Run Command only.
            transport                      = 'auto'
            # v1.6.0 (#212): when true the pre-run permission audit is skipped.
            skipPreCheck                   = $false
            # v1.6.0 (#206): when true, any skipped subscription / resource
            # raises an error at end-of-run. When false (default), discovery
            # skips on auth/network errors and continues with partial data.
            failOnPartialDiscovery         = $false
        }
    }
}

function Get-RangerAnnotatedConfigYaml {
    # Returns a self-documenting YAML template with discovery-friendly blank values.
    # Used by New-AzureLocalRangerConfig -Format yaml (the default).
    @'
# AzureLocalRanger configuration file
# Generated by New-AzureLocalRangerConfig
#
# Start with either Azure identifiers (subscriptionId, optionally tenantId) or a
# cluster FQDN/node list. Ranger discovers the remaining values where possible.
# Blank values are prompted for during interactive runs and rejected by -Unattended
# only when the selected collectors actually require them.
# Run: Invoke-AzureLocalRanger -ConfigPath <this file>
# Resolution precedence: parameter > config file > auto-discovery > prompt > default > error
# Docs: https://azurelocal.github.io/azurelocal-ranger

environment:
  name: # Optional; derived from the selected cluster when blank
  clusterName: # Optional; selected from Azure Arc when blank
  description: # Optional report description

targets:
  cluster:
    fqdn: # Optional when Azure Arc can discover the cluster
    nodes: [] # Optional when Azure Arc can discover the nodes
  azure:
    subscriptionId: # Azure subscription containing the cluster
    resourceGroup: # Optional; discovered from the selected cluster
    tenantId: # Optional with an active Az context; otherwise prompted
  bmc:
    endpoints:
      - host: idrac-node-01.contoso.com # BMC hostname or IP for first node (optional)
        node: azl-node-01.contoso.com
      - host: idrac-node-02.contoso.com # BMC hostname or IP for second node (optional)
        node: azl-node-02.contoso.com
  switches: [] # Add network switch targets here (optional)
  firewalls: [] # Add firewall targets here (optional)

credentials:
  azure:
    method: existing-context # Options: existing-context | device-code | service-principal | managed-identity | azure-cli
    useAzureCliFallback: true # Fall back to az cli token if Connect-AzAccount context is missing
  cluster:
    username: # Optional; interactive runs prompt when cluster access is needed
    passwordRef: # keyvault:// reference; never store a plain password in this file
  domain:
    username: # Blank reuses the cluster credential
    passwordRef:
  bmc:
    username: # Needed only when BMC targets are configured
    passwordRef:

domains:
  include: [] # Limit collection to these domain FQDNs (empty = auto-detect from Arc/AD)
  exclude: [] # Skip these domain FQDNs during collection
  hints:
    fixtures: {} # Static override values supplied directly (rarely needed)
    networkDeviceConfigs: [] # Paths to switch / firewall configuration files

output:
  mode: current-state # Options: current-state | as-built
  formats: # Report formats to generate
    - html
    - markdown
    - json
    - svg
  rootPath: 'C:\AzureLocalRanger' # Output directory; each run creates a dated sub-folder
  diagramFormat: svg # Options: svg | png
  keepRawEvidence: true # Keep raw JSON evidence alongside reports

behavior:
  promptForMissingCredentials: true # Prompt interactively when a credential cannot be resolved
  promptForMissingRequired: true # Prompt interactively for missing required structural values
  skipUnavailableOptionalDomains: true # Skip optional collectors (BMC, switches) if unreachable
  failOnSchemaViolation: true # Abort if config fails schema validation
  logLevel: info # Options: debug | info | warning | error
  retryCount: 2 # WinRM retry attempts per command
  timeoutSeconds: 60 # WinRM operation timeout in seconds
  continueToRendering: true # Generate reports even when some collectors partially fail
'@

}

function ConvertTo-RangerYaml {
    param(
        [Parameter(Mandatory = $true)]
        $InputObject,

        [int]$Indent = 0
    )

    $prefix = ' ' * $Indent
    $lines = New-Object System.Collections.Generic.List[string]

    if ($InputObject -is [System.Collections.IDictionary]) {
        foreach ($key in $InputObject.Keys) {
            $value = $InputObject[$key]
            if ($value -is [System.Collections.IDictionary]) {
                $lines.Add(('{0}{1}:' -f $prefix, $key))
                foreach ($line in (ConvertTo-RangerYaml -InputObject $value -Indent ($Indent + 2))) {
                    $lines.Add($line)
                }
                continue
            }

            if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) {
                $lines.Add(('{0}{1}:' -f $prefix, $key))
                foreach ($item in $value) {
                    if ($item -is [System.Collections.IDictionary]) {
                        $first = $true
                        foreach ($itemKey in $item.Keys) {
                            $itemValue = $item[$itemKey]
                            if ($itemValue -is [System.Collections.IDictionary] -or ($itemValue -is [System.Collections.IEnumerable] -and $itemValue -isnot [string])) {
                                $lines.Add((if ($first) { ('{0} - {1}:' -f $prefix, $itemKey) } else { ('{0} {1}:' -f $prefix, $itemKey) }))
                                foreach ($childLine in (ConvertTo-RangerYaml -InputObject $itemValue -Indent ($Indent + 6))) {
                                    $lines.Add($childLine)
                                }
                            }
                            else {
                                $scalar = ConvertTo-RangerYamlScalar -Value $itemValue
                                $lines.Add((if ($first) { ('{0} - {1}: {2}' -f $prefix, $itemKey, $scalar) } else { ('{0} {1}: {2}' -f $prefix, $itemKey, $scalar) }))
                            }
                            $first = $false
                        }
                    }
                    else {
                        $lines.Add("$prefix - $(ConvertTo-RangerYamlScalar -Value $item)")
                    }
                }
                continue
            }

            $lines.Add(('{0}{1}: {2}' -f $prefix, $key, (ConvertTo-RangerYamlScalar -Value $value)))
        }

        return $lines
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        foreach ($item in $InputObject) {
            $lines.Add("$prefix- $(ConvertTo-RangerYamlScalar -Value $item)")
        }

        return $lines
    }

    return @("$prefix$(ConvertTo-RangerYamlScalar -Value $InputObject)")
}

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

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

    if ($Value -is [bool]) {
        return $Value.ToString().ToLowerInvariant()
    }

    if ($Value -is [int] -or $Value -is [long] -or $Value -is [double]) {
        return [string]$Value
    }

    $text = [string]$Value
    if ($text -match '^[A-Za-z0-9._/-]+$') {
        return $text
    }

    return "'$($text -replace '''', '''''')'"
}

function Merge-RangerConfiguration {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$BaseConfig,

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

    $result = ConvertTo-RangerHashtable -InputObject $BaseConfig
    foreach ($key in $OverrideConfig.Keys) {
        $overrideValue = $OverrideConfig[$key]
        if ($result.Contains($key) -and $result[$key] -is [System.Collections.IDictionary] -and $overrideValue -is [System.Collections.IDictionary]) {
            $result[$key] = Merge-RangerConfiguration -BaseConfig $result[$key] -OverrideConfig $overrideValue
            continue
        }

        $result[$key] = ConvertTo-RangerHashtable -InputObject $overrideValue
    }

    $result
}

function Normalize-RangerConfiguration {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config
    )

    $Config.targets.cluster.nodes = @($Config.targets.cluster.nodes | Where-Object {
        $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
    })

    $Config.targets.bmc.endpoints = @(
        foreach ($endpoint in @($Config.targets.bmc.endpoints)) {
            if ($null -eq $endpoint) {
                continue
            }

            if ($endpoint -is [string]) {
                $endpointHost = [string]$endpoint
                if ([string]::IsNullOrWhiteSpace($endpointHost) -or $endpointHost -in @('[]', '{}')) {
                    continue
                }

                [ordered]@{
                    host = $endpointHost
                    node = $null
                }
                continue
            }

            $endpointHost = if ($endpoint -is [System.Collections.IDictionary]) { $endpoint['host'] } else { $endpoint.host }
            $node = if ($endpoint -is [System.Collections.IDictionary]) { $endpoint['node'] } else { $endpoint.node }
            if ([string]::IsNullOrWhiteSpace([string]$endpointHost)) {
                continue
            }

            [ordered]@{
                host = [string]$endpointHost
                node = if ([string]::IsNullOrWhiteSpace([string]$node)) { $null } else { [string]$node }
            }
        }
    )

    $Config.targets.switches = @($Config.targets.switches | Where-Object {
        $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
    })

    $Config.targets.firewalls = @($Config.targets.firewalls | Where-Object {
        $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
    })

    $Config.domains.include = @($Config.domains.include | Where-Object {
        $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
    })

    $Config.domains.exclude = @($Config.domains.exclude | Where-Object {
        $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
    })

    $Config.domains.hints.networkDeviceConfigs = @(
        foreach ($hint in @($Config.domains.hints.networkDeviceConfigs)) {
            if ($null -eq $hint) {
                continue
            }

            if ($hint -is [string]) {
                $path = [string]$hint
                if ([string]::IsNullOrWhiteSpace($path) -or $path -in @('[]', '{}')) {
                    continue
                }

                [ordered]@{
                    path = $path
                }
                continue
            }

            $path = if ($hint -is [System.Collections.IDictionary]) { $hint['path'] } else { $hint.path }
            if ([string]::IsNullOrWhiteSpace([string]$path)) {
                continue
            }

            [ordered]@{
                path   = [string]$path
                vendor = if ($hint -is [System.Collections.IDictionary]) { $hint['vendor'] } else { $hint.vendor }
                role   = if ($hint -is [System.Collections.IDictionary]) { $hint['role'] } else { $hint.role }
            }
        }
    )

    $Config.output.formats = @($Config.output.formats | Where-Object {
        $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
    })

    return $Config
}

function Get-RangerCompanionVariablesPath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigPath
    )

    $configDirectory = Split-Path -Parent $ConfigPath
    foreach ($candidateName in @('variables.yml', 'variables.yaml')) {
        $candidatePath = Join-Path -Path $configDirectory -ChildPath $candidateName
        if (Test-Path -Path $candidatePath) {
            return $candidatePath
        }
    }

    return $null
}

function Resolve-RangerBmcEndpointsFromVariablesData {
    param(
        $VariablesData
    )

    if ($null -eq $VariablesData) {
        return @()
    }

    $variables = ConvertTo-RangerHashtable -InputObject $VariablesData
    $devices = @()

    if ($variables.security -and $variables.security.infrastructure_credentials -and $variables.security.infrastructure_credentials.idrac) {
        $devices = @($variables.security.infrastructure_credentials.idrac.devices)
    }

    $endpoints = @(
        foreach ($device in $devices) {
            $text = [string]$device
            if ([string]::IsNullOrWhiteSpace($text)) {
                continue
            }

            if ($text -match '^\s*(?<node>[^()]+?)\s*\((?<host>[^()]+)\)\s*$') {
                [ordered]@{
                    host = $matches.host.Trim()
                    node = $matches.node.Trim()
                }
                continue
            }

            [ordered]@{
                host = $text.Trim()
                node = $null
            }
        }
    )

    return @($endpoints)
}

function Apply-RangerCompanionConfigurationFallbacks {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

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

    if (@($Config.targets.bmc.endpoints).Count -gt 0) {
        return $Config
    }

    $variablesPath = Get-RangerCompanionVariablesPath -ConfigPath $ConfigPath
    if ([string]::IsNullOrWhiteSpace($variablesPath)) {
        return $Config
    }

    $variablesData = Import-RangerYamlFile -Path $variablesPath
    $fallbackEndpoints = @(Resolve-RangerBmcEndpointsFromVariablesData -VariablesData $variablesData)
    if ($fallbackEndpoints.Count -gt 0) {
        $Config.targets.bmc.endpoints = $fallbackEndpoints
    }

    return $Config
}

function Import-RangerConfiguration {
    param(
        [string]$ConfigPath,
        $ConfigObject
    )

    if ($ConfigObject) {
        return Normalize-RangerConfiguration -Config (Merge-RangerConfiguration -BaseConfig (Get-RangerDefaultConfig) -OverrideConfig (ConvertTo-RangerHashtable -InputObject $ConfigObject))
    }

    # v2.6.3 (#296): ConfigPath and ConfigObject are no longer hard requirements.
    # When neither is supplied, we return the built-in defaults so that the
    # caller's structural overrides (-SubscriptionId / -TenantId / -ClusterName)
    # plus Arc auto-discovery can populate a runnable config. The prior behavior
    # — throwing when both were absent — forced every ad-hoc run through a
    # config file, which is the opposite of the UX goal for the first-run flow.
    if (-not $ConfigPath) {
        return Normalize-RangerConfiguration -Config (Get-RangerDefaultConfig)
    }

    $resolvedConfigPath = Resolve-RangerPath -Path $ConfigPath
    if (-not (Test-Path -Path $resolvedConfigPath)) {
        throw "Configuration file not found: $resolvedConfigPath"
    }

    $extension = [System.IO.Path]::GetExtension($resolvedConfigPath).ToLowerInvariant()
    switch ($extension) {
        '.json' {
            $loaded = Get-Content -Path $resolvedConfigPath -Raw | ConvertFrom-Json -Depth 100
        }
        '.psd1' {
            $loaded = Import-PowerShellDataFile -Path $resolvedConfigPath
        }
        '.yml' {
            $loaded = Import-RangerYamlFile -Path $resolvedConfigPath
        }
        '.yaml' {
            $loaded = Import-RangerYamlFile -Path $resolvedConfigPath
        }
        default {
            throw "Unsupported configuration format: $extension"
        }
    }

    $config = Normalize-RangerConfiguration -Config (Merge-RangerConfiguration -BaseConfig (Get-RangerDefaultConfig) -OverrideConfig (ConvertTo-RangerHashtable -InputObject $loaded))
    return Apply-RangerCompanionConfigurationFallbacks -Config $config -ConfigPath $resolvedConfigPath
}

function Set-RangerStructuralOverrides {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [hashtable]$StructuralOverrides
    )

    if (-not $StructuralOverrides -or $StructuralOverrides.Count -eq 0) {
        return $Config
    }

    if ($StructuralOverrides.ContainsKey('ClusterFqdn') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['ClusterFqdn'])) {
        $Config.targets.cluster.fqdn = $StructuralOverrides['ClusterFqdn']
    }
    if ($StructuralOverrides.ContainsKey('ClusterNodes') -and @($StructuralOverrides['ClusterNodes']).Count -gt 0) {
        $Config.targets.cluster.nodes = @($StructuralOverrides['ClusterNodes'])
    }
    if ($StructuralOverrides.ContainsKey('EnvironmentName') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['EnvironmentName'])) {
        $Config.environment.name = $StructuralOverrides['EnvironmentName']
    }
    if ($StructuralOverrides.ContainsKey('SubscriptionId') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['SubscriptionId'])) {
        $Config.targets.azure.subscriptionId = $StructuralOverrides['SubscriptionId']
    }
    if ($StructuralOverrides.ContainsKey('TenantId') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['TenantId'])) {
        $Config.targets.azure.tenantId = $StructuralOverrides['TenantId']
    }
    if ($StructuralOverrides.ContainsKey('ResourceGroup') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['ResourceGroup'])) {
        $Config.targets.azure.resourceGroup = $StructuralOverrides['ResourceGroup']
    }

    if ($StructuralOverrides.ContainsKey('ClusterName') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['ClusterName'])) {
        $Config.environment.clusterName = $StructuralOverrides['ClusterName']
    }

    # v2.6.3 (#296): when env.name is still the scaffold placeholder and the
    # operator has provided a clusterName (either via override or config),
    # derive env.name from clusterName. This keeps the 3-field invocation
    # (-SubscriptionId / -TenantId / -ClusterName) from tripping on the
    # "environment.name is a placeholder" validation error.
    if ((Test-RangerPlaceholderValue -Value $Config.environment.name -FieldName 'environment.name') -and `
        -not [string]::IsNullOrWhiteSpace($Config.environment.clusterName)) {
        $Config.environment.name = [string]$Config.environment.clusterName
    }

    # Issue #76: -ShowProgress switch override
    if ($StructuralOverrides.ContainsKey('ShowProgress')) {
        if (-not ($Config.output -is [System.Collections.IDictionary])) { $Config.output = [ordered]@{} }
        $Config.output.showProgress = [bool]$StructuralOverrides['ShowProgress']
    }

    # Issue #171: output section overrides
    if ($StructuralOverrides.ContainsKey('OutputMode') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['OutputMode'])) {
        if (-not ($Config.output -is [System.Collections.IDictionary])) { $Config.output = [ordered]@{} }
        $Config.output.mode = $StructuralOverrides['OutputMode']
    }
    if ($StructuralOverrides.ContainsKey('OutputFormats') -and @($StructuralOverrides['OutputFormats']).Count -gt 0) {
        if (-not ($Config.output -is [System.Collections.IDictionary])) { $Config.output = [ordered]@{} }
        $Config.output.formats = @($StructuralOverrides['OutputFormats'])
    }

    # Issue #171: behavior section overrides
    if ($StructuralOverrides.ContainsKey('Transport') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['Transport'])) {
        if (-not ($Config.behavior -is [System.Collections.IDictionary])) { $Config.behavior = [ordered]@{} }
        $Config.behavior.transport = $StructuralOverrides['Transport']
    }
    if ($StructuralOverrides.ContainsKey('DegradationMode') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['DegradationMode'])) {
        if (-not ($Config.behavior -is [System.Collections.IDictionary])) { $Config.behavior = [ordered]@{} }
        $Config.behavior.degradationMode = $StructuralOverrides['DegradationMode']
    }
    if ($StructuralOverrides.ContainsKey('RetryCount') -and $StructuralOverrides['RetryCount'] -gt 0) {
        if (-not ($Config.behavior -is [System.Collections.IDictionary])) { $Config.behavior = [ordered]@{} }
        $Config.behavior.retryCount = [int]$StructuralOverrides['RetryCount']
    }
    if ($StructuralOverrides.ContainsKey('TimeoutSeconds') -and $StructuralOverrides['TimeoutSeconds'] -gt 0) {
        if (-not ($Config.behavior -is [System.Collections.IDictionary])) { $Config.behavior = [ordered]@{} }
        $Config.behavior.timeoutSeconds = [int]$StructuralOverrides['TimeoutSeconds']
    }

    # Issue #171: Azure credential method override
    if ($StructuralOverrides.ContainsKey('AzureMethod') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['AzureMethod'])) {
        if (-not ($Config.credentials -is [System.Collections.IDictionary])) { $Config.credentials = [ordered]@{} }
        if (-not ($Config.credentials.azure -is [System.Collections.IDictionary])) { $Config.credentials.azure = [ordered]@{} }
        $Config.credentials.azure.method = $StructuralOverrides['AzureMethod']
    }

    # v1.6.0 (#212): -SkipPreCheck parameter overrides behavior.skipPreCheck
    if ($StructuralOverrides.ContainsKey('SkipPreCheck')) {
        if (-not ($Config.behavior -is [System.Collections.IDictionary])) { $Config.behavior = [ordered]@{} }
        $Config.behavior.skipPreCheck = [bool]$StructuralOverrides['SkipPreCheck']
    }

    # Issue #322: -Debug / -Verbose inject LogLevel='debug' via structural overrides
    if ($StructuralOverrides.ContainsKey('LogLevel') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['LogLevel'])) {
        if (-not ($Config.behavior -is [System.Collections.IDictionary])) { $Config.behavior = [ordered]@{} }
        $Config.behavior.logLevel = $StructuralOverrides['LogLevel']
    }

    # Issue #314 / #315: -NetworkDeviceConfigs — validate paths, expand directories recursively,
    # then merge into domains.hints.networkDeviceConfigs for the networking collector parser.
    if ($StructuralOverrides.ContainsKey('NetworkDeviceConfigs') -and @($StructuralOverrides['NetworkDeviceConfigs']).Count -gt 0) {
        $validPaths = [System.Collections.Generic.List[string]]::new()
        $configExtensions = @('.txt', '.cfg', '.conf', '.log')
        foreach ($p in @($StructuralOverrides['NetworkDeviceConfigs'])) {
            if ([string]::IsNullOrWhiteSpace($p)) { continue }
            $resolved = try { (Resolve-Path -Path $p -ErrorAction Stop).Path } catch { $null }
            if (-not $resolved) {
                throw "NetworkDeviceConfigs: path not found — '$p'. Verify the path exists before running."
            }
            if (Test-Path $resolved -PathType Container) {
                # Issue #315: directory supplied — expand recursively to all switch-config files.
                $expanded = @(Get-ChildItem -Path $resolved -Recurse -File -ErrorAction SilentlyContinue |
                    Where-Object { $_.Extension -in $configExtensions } |
                    Select-Object -ExpandProperty FullName)
                if ($expanded.Count -eq 0) {
                    Write-RangerLog -Level warn -Message "NetworkDeviceConfigs: directory '$resolved' contained no files with extensions $($configExtensions -join ', '). No configs loaded from this path."
                } else {
                    Write-RangerLog -Level debug -Message "NetworkDeviceConfigs: expanded directory '$resolved' to $($expanded.Count) file(s): $($expanded -join '; ')"
                    # Issue #325: wrap as { path } objects — plain strings bypass normalization and the parser reads .path
                    foreach ($f in $expanded) { $validPaths.Add([ordered]@{ path = $f }) }
                }
            } else {
                # Issue #325: same — wrap as { path } object
                $validPaths.Add([ordered]@{ path = $resolved })
            }
        }
        if (-not ($Config.domains -is [System.Collections.IDictionary])) { $Config.domains = [ordered]@{} }
        if (-not ($Config.domains.hints -is [System.Collections.IDictionary])) { $Config.domains.hints = [ordered]@{} }
        $existing = @($Config.domains.hints.networkDeviceConfigs | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
        $Config.domains.hints.networkDeviceConfigs = @($existing) + @($validPaths)
    }

    return $Config
}

function Test-RangerInteractivePromptAvailable {
    if (Get-Module -Name Pester -ErrorAction SilentlyContinue) {
        return $false
    }

    if (Get-Variable -Name PesterPreference -Scope Global -ErrorAction SilentlyContinue) {
        return $false
    }

    # $Host.Name is the most reliable indicator of an interactive shell. ConsoleHost is
    # the standard pwsh/powershell.exe terminal; ISE and VS Code Host are also interactive.
    # [Environment]::UserInteractive returns $false on Windows multi-session (AVD) hosts
    # even when a real user is present at the prompt, so it cannot be used as the sole gate.
    $interactiveHosts = @('ConsoleHost', 'Windows PowerShell ISE Host', 'Visual Studio Code Host')
    if ($Host.Name -in $interactiveHosts) {
        return $true
    }

    return [Environment]::UserInteractive
}

function Test-RangerPlaceholderValue {
    param(
        [AllowNull()]
        $Value,

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

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

    $text = [string]$Value
    if ([string]::IsNullOrWhiteSpace($text)) {
        return $true
    }

    switch ($FieldName) {
        'environment.name'            { return $text -eq 'prod-azlocal-01' }
        'targets.cluster.fqdn'        { return $text -eq 'azlocal-prod-01.contoso.com' }
        'targets.cluster.node'        { return $text -match '^azl-node-0[1-9]\.contoso\.com$' }
        'targets.azure.subscriptionId' { return $text -eq '00000000-0000-0000-0000-000000000000' }
        'targets.azure.tenantId'      { return $text -eq '11111111-1111-1111-1111-111111111111' }
        'targets.azure.resourceGroup' { return $text -eq 'rg-azlocal-prod-01' }
        default                       { return $false }
    }
}

function Get-RangerMissingRequiredInputs {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [object[]]$SelectedCollectors
    )

    $collectors = if ($SelectedCollectors) { @($SelectedCollectors) } else { Resolve-RangerSelectedCollectors -Config $Config }
    $fixtureMap = if ($Config.domains -and $Config.domains.hints -and $Config.domains.hints.fixtures) { $Config.domains.hints.fixtures } else { $null }
    $fixtureMode = $false
    if ($fixtureMap -is [System.Collections.IDictionary] -and $collectors.Count -gt 0) {
        $fixtureMode = @($collectors | Where-Object { -not $fixtureMap.Contains($_.Id) -or [string]::IsNullOrWhiteSpace([string]$fixtureMap[$_.Id]) }).Count -eq 0
    }

    if ($fixtureMode) {
        return @()
    }

    $requiresAzure = @($collectors | Where-Object { 'azure' -in @($_.RequiredTargets) }).Count -gt 0
    $missing = New-Object System.Collections.ArrayList

    # Issue #300: Azure identifiers come first. Once subscriptionId and tenantId
    # are known, the interactive loop re-runs Invoke-RangerAzureAutoDiscovery
    # between prompts, so Select-RangerCluster can fill clusterName,
    # resourceGroup, cluster.fqdn, and nodes from Arc — removing those fields
    # from subsequent passes of this missing-list check automatically. The
    # operator only ends up answering the fields auto-discovery genuinely
    # can't cover.
    if ($requiresAzure) {
        if (Test-RangerPlaceholderValue -Value $Config.targets.azure.subscriptionId -FieldName 'targets.azure.subscriptionId') {
            [void]$missing.Add([ordered]@{ Path = 'targets.azure.subscriptionId'; Prompt = 'Azure subscription ID'; Example = '22222222-2222-2222-2222-222222222222'; Description = 'Azure subscription that contains the Arc-enabled HCI resource' })
        }
        if (Test-RangerPlaceholderValue -Value $Config.targets.azure.tenantId -FieldName 'targets.azure.tenantId') {
            [void]$missing.Add([ordered]@{ Path = 'targets.azure.tenantId'; Prompt = 'Azure tenant ID'; Example = '33333333-3333-3333-3333-333333333333'; Description = 'Microsoft Entra tenant ID' })
        }
    }

    # Cluster identity — only prompted when both FQDN and nodes are empty AND
    # clusterName is empty. When clusterName is set (either by the operator or
    # by Select-RangerCluster on a prior auto-discovery pass),
    # Resolve-RangerClusterArcResource and Resolve-RangerNodeInventory fill the
    # rest.
    $clusterNodes   = @($Config.targets.cluster.nodes | Where-Object { -not (Test-RangerPlaceholderValue -Value $_ -FieldName 'targets.cluster.node') })
    $hasClusterFqdn = -not (Test-RangerPlaceholderValue -Value $Config.targets.cluster.fqdn -FieldName 'targets.cluster.fqdn')
    $hasClusterName = -not [string]::IsNullOrWhiteSpace([string]$Config.environment.clusterName)
    if (-not $hasClusterFqdn -and $clusterNodes.Count -eq 0 -and -not $hasClusterName) {
        [void]$missing.Add([ordered]@{ Path = 'targets.cluster.fqdn'; Prompt = 'Cluster FQDN'; Example = 'mycluster.contoso.com'; Description = 'Cluster name object FQDN or first reachable node FQDN' })
    }

    if ($requiresAzure -and (Test-RangerPlaceholderValue -Value $Config.targets.azure.resourceGroup -FieldName 'targets.azure.resourceGroup')) {
        [void]$missing.Add([ordered]@{ Path = 'targets.azure.resourceGroup'; Prompt = 'Azure resource group'; Example = 'rg-azlocal-prod-01'; Description = 'Resource group that contains the Arc-enabled HCI cluster' })
    }

    # Environment name comes last — by the time we check this, auto-discovery
    # may have already derived it from clusterName (see the tail of
    # Invoke-RangerAzureAutoDiscovery and Set-RangerStructuralOverrides).
    if (Test-RangerPlaceholderValue -Value $Config.environment.name -FieldName 'environment.name') {
        [void]$missing.Add([ordered]@{ Path = 'environment.name'; Prompt = 'Environment name'; Example = 'tplabs-prod-01'; Description = 'Short identifier used in output file names' })
    }

    return @($missing)
}

function Set-RangerConfigValueByPath {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

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

        [AllowNull()]
        $Value
    )

    switch ($Path) {
        'environment.name'             { $Config.environment.name = $Value }
        'targets.cluster.fqdn'         { $Config.targets.cluster.fqdn = $Value }
        'targets.azure.subscriptionId' { $Config.targets.azure.subscriptionId = $Value }
        'targets.azure.tenantId'       { $Config.targets.azure.tenantId = $Value }
        'targets.azure.resourceGroup'  { $Config.targets.azure.resourceGroup = $Value }
    }
}

function Invoke-RangerInteractiveInput {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config
    )

    # v1.6.0 (#196/#197): auto-discovery runs unconditionally (headless and
    # interactive) so missing resourceGroup/FQDN are filled from Azure Arc
    # before we ever prompt or fail validation.
    Invoke-RangerAzureAutoDiscovery -Config $Config | Out-Null

    if (-not [bool]$Config.behavior.promptForMissingRequired) {
        return $Config
    }

    if (-not (Test-RangerInteractivePromptAvailable)) {
        return $Config
    }

    # Issue #300: iterate one prompt at a time and re-run auto-discovery
    # after each answer. The critical case: user running bare
    # `Invoke-AzureLocalRanger` supplies subscriptionId + tenantId at the
    # first two prompts, which unlocks Select-RangerCluster on the next
    # auto-discovery pass — filling clusterName, resourceGroup, FQDN, and
    # nodes without any more prompts. The 8-iteration cap is a safety net;
    # real flows complete in 1-2 passes.
    $tipShown = $false
    for ($pass = 0; $pass -lt 8; $pass++) {
        $missingList = @(Get-RangerMissingRequiredInputs -Config $Config)
        if ($missingList.Count -eq 0) { break }

        if (-not $tipShown) {
            # v1.6.0 (#211): surface the inline wizard as the recommended alternative
            # to field-by-field prompting.
            Write-Host ' Tip: Run Invoke-AzureLocalRanger -Wizard to create a config file interactively.' -ForegroundColor DarkGray
            $tipShown = $true
        }

        $missing = $missingList[0]
        $prompt = "[Ranger] $($missing.Prompt) is required.`nEnter $($missing.Prompt) (e.g., $($missing.Example)):"
        $value = Read-Host -Prompt $prompt
        if ([string]::IsNullOrWhiteSpace($value)) {
            # Operator pressed Enter without answering — stop to avoid looping.
            break
        }

        Set-RangerConfigValueByPath -Config $Config -Path $missing.Path -Value $value.Trim()

        # Re-run auto-discovery. When the operator just answered
        # subscriptionId or tenantId, this fires Select-RangerCluster and
        # auto-fills everything else on the next pass.
        Invoke-RangerAzureAutoDiscovery -Config $Config | Out-Null
    }

    return $Config
}

function Show-RangerResolvedTargetSummary {
    <#
    .SYNOPSIS
        Shows the non-secret target values Ranger resolved before collection.
    #>

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

    $nodes = @($Config.targets.cluster.nodes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
    $valueOrPending = {
        param($Value)
        if ([string]::IsNullOrWhiteSpace([string]$Value)) { return '<not resolved>' }
        return [string]$Value
    }

    Write-Host ''
    Write-Host '[Ranger] Resolved target' -ForegroundColor Cyan
    Write-Host (" Tenant : {0}" -f (& $valueOrPending $Config.targets.azure.tenantId))
    Write-Host (" Subscription : {0}" -f (& $valueOrPending $Config.targets.azure.subscriptionId))
    Write-Host (" Resource group: {0}" -f (& $valueOrPending $Config.targets.azure.resourceGroup))
    Write-Host (" Cluster : {0}" -f (& $valueOrPending $Config.environment.clusterName))
    Write-Host (" Cluster target: {0}" -f (& $valueOrPending $Config.targets.cluster.fqdn))
    Write-Host (" Nodes : {0}" -f $(if ($nodes.Count -gt 0) { $nodes -join ', ' } else { '<not resolved>' }))

    if ([string]::IsNullOrWhiteSpace([string]$Config.targets.cluster.fqdn) -and $nodes.Count -eq 0) {
        Write-Warning '[Ranger] No local cluster endpoint was resolved. Azure-only collectors can continue, but cluster/WinRM detail will be skipped or fail validation depending on the selected scope.'
    }
}

function Invoke-RangerAzureAutoDiscovery {
    <#
    .SYNOPSIS
        v1.6.0 (#196/#197) / v2.6.3 (#294, #297): populate missing clusterName,
        resourceGroup, cluster FQDN, and cluster nodes from Azure Arc when
        subscriptionId (+/- clusterName) are present.
    .DESCRIPTION
        Runs before interactive prompts and before the headless required-input
        check. Silently returns when Az is unavailable, when credentials are not
        logged in, or when the cluster cannot be found — the caller's existing
        prompt-or-fail path takes over.
    #>

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

        [switch]$Unattended
    )

    # v2.6.3 (#297): when clusterName is absent but subscriptionId is set,
    # enumerate HCI clusters in the subscription and select one (auto-pick a
    # single hit, prompt on multiples, fail fast under -Unattended). Skips
    # silently when Az isn't available or the caller can't authenticate — the
    # existing prompt-or-fail path still catches the missing clusterName.
    if ([string]::IsNullOrWhiteSpace($Config.environment.clusterName)) {
        $hasSub = -not [string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId) -and
                  $Config.targets.azure.subscriptionId -ne '00000000-0000-0000-0000-000000000000'
        if ($hasSub) {
            try {
                $null = Select-RangerCluster -Config $Config -Unattended:$Unattended
            } catch {
                # Re-throw well-coded errors; swallow transient Az failures so
                # the normal validation path can surface a more specific message.
                if ($_.Exception.Data -and $_.Exception.Data.Contains('RangerErrorCode')) {
                    throw
                }
                $message = "Azure cluster discovery could not complete: $($_.Exception.Message)"
                Write-RangerLog -Level warn -Message "Invoke-RangerAzureAutoDiscovery: $message"
                Write-Warning "[Ranger] $message"
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($Config.environment.clusterName)) {
        return $false
    }

    # Issue #317: fill tenantId from the active Az session whenever it is absent.
    # Arc auto-discovery already proves the session is authenticated; querying for
    # tenantId again after this point is redundant and confusing to the operator.
    if ([string]::IsNullOrWhiteSpace($Config.targets.azure.tenantId) -or
        $Config.targets.azure.tenantId -eq '00000000-0000-0000-0000-000000000000') {
        try {
            $ctx = Get-AzContext -ErrorAction SilentlyContinue
            if ($ctx -and -not [string]::IsNullOrWhiteSpace($ctx.Tenant.Id)) {
                $Config.targets.azure.tenantId = $ctx.Tenant.Id
                Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: tenantId '$($ctx.Tenant.Id)' sourced from active Az session (issue #317)."
            }
        } catch {
            Write-RangerLog -Level debug -Message "Invoke-RangerAzureAutoDiscovery: could not read tenantId from AzContext — $($_.Exception.Message)"
        }
    }

    $configNodes = @($Config.targets.cluster.nodes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
    $needRg    = [string]::IsNullOrWhiteSpace($Config.targets.azure.resourceGroup)
    $needFqdn  = [string]::IsNullOrWhiteSpace($Config.targets.cluster.fqdn)
    $needNodes = $configNodes.Count -eq 0
    if (-not $needRg -and -not $needFqdn -and -not $needNodes) {
        return $false
    }

    $filled = $false
    $hasSub = -not [string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId) -and
              $Config.targets.azure.subscriptionId -ne '00000000-0000-0000-0000-000000000000'

    $arc = $null
    if ($hasSub) {
        try {
            $arc = Resolve-RangerClusterArcResource -Config $Config
        } catch {
            $message = "Azure Arc lookup could not complete: $($_.Exception.Message). Ranger will try local DNS/WinRM discovery where possible."
            Write-RangerLog -Level warn -Message "Invoke-RangerAzureAutoDiscovery: $message"
            Write-Warning "[Ranger] $message"
        }
    }

    if (-not $arc) {
        # v1.6.0 (#203): Arc unavailable or not configured — try on-prem
        # TrustedHosts/DNS fallback for FQDN. RG is Arc-only, so skip when no Arc.
        if ($needFqdn) {
            try {
                $resolvedName = Resolve-RangerClusterFqdn -Name $Config.environment.clusterName
                if (-not [string]::IsNullOrWhiteSpace($resolvedName)) {
                    $Config.targets.cluster.fqdn = $resolvedName
                    Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: resolved cluster FQDN '$resolvedName' via TrustedHosts/DNS fallback"
                    $filled = $true
                }
            } catch {
                $message = "Local cluster name resolution failed: $($_.Exception.Message). Provide -ClusterFqdn or cluster node names if needed."
                Write-RangerLog -Level warn -Message "Invoke-RangerAzureAutoDiscovery: $message"
                Write-Warning "[Ranger] $message"
            }
        }
        return $filled
    }

    # #196: Resolve-RangerClusterArcResource already writes resourceGroup back
    # into Config when it auto-discovers. Record that we filled it here.
    if ($needRg -and -not [string]::IsNullOrWhiteSpace($Config.targets.azure.resourceGroup)) {
        $filled = $true
    }

    # #197: derive cluster FQDN from the Arc resource when missing.
    if ($needFqdn) {
        $fqdn = $null
        # Prefer an explicit dnsName / clusterName on properties, then compose
        # {resource-name}.{domainName} when both are known.
        try {
            if ($arc.Properties) {
                if ($arc.Properties.reportedProperties -and $arc.Properties.reportedProperties.clusterId) {
                    # clusterId often carries a DNS-style name; only accept when it looks like one
                    $candidate = [string]$arc.Properties.reportedProperties.clusterId
                    if ($candidate -match '\.') { $fqdn = $candidate }
                }
                if (-not $fqdn -and $arc.Properties.dnsName) { $fqdn = [string]$arc.Properties.dnsName }
                if (-not $fqdn -and $arc.Properties.domainName -and $arc.Name) {
                    $dn = [string]$arc.Properties.domainName
                    if ($dn -match '\.') { $fqdn = "$($arc.Name).$dn" }
                }
            }
        } catch {
            Write-RangerLog -Level warn -Message "Invoke-RangerAzureAutoDiscovery: FQDN extraction failed — $($_.Exception.Message)"
        }

        if (-not [string]::IsNullOrWhiteSpace($fqdn)) {
            $Config.targets.cluster.fqdn = $fqdn
            Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: auto-discovered cluster FQDN '$fqdn' from Azure Arc"
            $filled = $true
        }
        else {
            # v1.6.0 (#203): Arc didn't yield an FQDN — try on-prem fallback chain
            # (TrustedHosts + DNS) using the cluster short name.
            try {
                $resolvedName = Resolve-RangerClusterFqdn -Name $Config.environment.clusterName
                if (-not [string]::IsNullOrWhiteSpace($resolvedName)) {
                    $Config.targets.cluster.fqdn = $resolvedName
                    Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: resolved cluster FQDN '$resolvedName' via TrustedHosts/DNS fallback"
                    $filled = $true
                }
            } catch {
                $message = "Local cluster name resolution failed: $($_.Exception.Message). Provide -ClusterFqdn if needed."
                Write-RangerLog -Level warn -Message "Invoke-RangerAzureAutoDiscovery: $message"
                Write-Warning "[Ranger] $message"
            }
        }
    }

    # v2.6.3 (#294): discover cluster nodes when the config didn't supply any.
    # Priority: Arc cluster properties.nodes[] → Arc machines in cluster RG →
    # subscription-wide Arc machines search. We intentionally do not fall back
    # to direct WinRM here — node discovery runs before credentials have been
    # resolved, so a WinRM path would force a credential prompt just to list
    # nodes. The per-collector Resolve-RangerNodeInventory still owns the
    # WinRM fallback when this auto-populated list is empty.
    if ($needNodes) {
        $discoveredNodes = @()

        try {
            if ($arc.Properties -and $arc.Properties.nodes) {
                $discoveredNodes = @(@($arc.Properties.nodes) | ForEach-Object {
                    if ($_ -is [string]) { $_ }
                    elseif ($_ -and $_.name) { [string]$_.name }
                    else { $null }
                } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
                if ($discoveredNodes.Count -gt 0) {
                    Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: Arc HCI cluster surfaced $($discoveredNodes.Count) node(s) in properties.nodes"
                }
            }
        } catch {
            Write-RangerLog -Level debug -Message "Invoke-RangerAzureAutoDiscovery: reading Arc properties.nodes threw — $($_.Exception.Message)"
        }

        # Issue #308: build a nodeFqdns map from Arc machine properties.dnsFqdn
        # so WinRM connections use FQDNs discovered in Azure before any on-prem
        # session opens. Short names are preserved in nodes[] for report labels.
        $nodeFqdnMap = @{}

        if ($discoveredNodes.Count -eq 0) {
            try {
                $arcMachines = Resolve-RangerArcMachinesForCluster -Config $Config -NodeHints @()
                if ($arcMachines -and $arcMachines.Machines -and @($arcMachines.Machines).Count -gt 0) {
                    $discoveredNodes = @($arcMachines.Machines | ForEach-Object { [string]$_.Name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
                    if ($discoveredNodes.Count -gt 0) {
                        Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: Arc machines query returned $($discoveredNodes.Count) node(s) in cluster resource group"
                    }

                    # Extract FQDN from each Arc machine: prefer properties.dnsFqdn,
                    # fall back to shortname + properties.domainName.
                    foreach ($machine in $arcMachines.Machines) {
                        $shortName = [string]$machine.Name
                        if ([string]::IsNullOrWhiteSpace($shortName)) { continue }
                        $fqdn = if (-not [string]::IsNullOrWhiteSpace([string]$machine.Properties.DnsFqdn)) {
                            [string]$machine.Properties.DnsFqdn
                        } elseif (-not [string]::IsNullOrWhiteSpace([string]$machine.Properties.DomainName)) {
                            "$shortName.$([string]$machine.Properties.DomainName)"
                        } else { $null }
                        if (-not [string]::IsNullOrWhiteSpace($fqdn)) {
                            $nodeFqdnMap[$shortName] = $fqdn
                        }
                    }

                    if ($nodeFqdnMap.Count -gt 0) {
                        Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: extracted FQDNs for $($nodeFqdnMap.Count) node(s) from Arc properties.dnsFqdn"
                        $Config.targets.cluster.nodeFqdns = $nodeFqdnMap
                    }

                    # Populate config.targets.cluster.domain from first machine with a domainName
                    if ([string]::IsNullOrWhiteSpace([string]$Config.targets.cluster.domain)) {
                        $domainFromArc = [string]($arcMachines.Machines | Where-Object {
                            -not [string]::IsNullOrWhiteSpace([string]$_.Properties.DomainName)
                        } | Select-Object -First 1).Properties.DomainName
                        if (-not [string]::IsNullOrWhiteSpace($domainFromArc)) {
                            $Config.targets.cluster.domain = $domainFromArc
                            Write-RangerLog -Level info -Message "Invoke-RangerAzureAutoDiscovery: AD domain '$domainFromArc' sourced from Arc properties.domainName"
                        }
                    }
                }
            } catch {
                Write-RangerLog -Level debug -Message "Invoke-RangerAzureAutoDiscovery: Arc machines node discovery threw — $($_.Exception.Message)"
            }
        }

        if ($discoveredNodes.Count -gt 0) {
            # Promote short NetBIOS names to FQDNs when the cluster FQDN gives
            # us a domain suffix to append. Arc-sourced nodeFqdnMap is checked
            # first (step 2 of Resolve-RangerNodeFqdn), so nodes that have an
            # explicit FQDN from Arc always win over suffix-derived guesses.
            $clusterFqdn = [string]$Config.targets.cluster.fqdn
            $fqdnNodes = @($discoveredNodes | ForEach-Object {
                $resolved = try { Resolve-RangerNodeFqdn -Name $_ -ClusterFqdn $clusterFqdn -NodeFqdnMap $nodeFqdnMap } catch { $null }
                if (-not [string]::IsNullOrWhiteSpace($resolved)) { $resolved } else { $_ }
            })
            $Config.targets.cluster.nodes = @($fqdnNodes | Select-Object -Unique)
            $filled = $true
        }
    }

    # v2.6.3 (#296/#297): if env.name is still the scaffold placeholder and
    # we now know a clusterName (either from the structural override or from
    # the cluster auto-select above), derive env.name from it so validation
    # doesn't trip on the placeholder.
    if ((Test-RangerPlaceholderValue -Value $Config.environment.name -FieldName 'environment.name') -and `
        -not [string]::IsNullOrWhiteSpace($Config.environment.clusterName)) {
        $Config.environment.name = [string]$Config.environment.clusterName
        $filled = $true
    }

    return $filled
}

function Import-RangerYamlFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (Test-RangerCommandAvailable -Name 'ConvertFrom-Yaml') {
        return Get-Content -Path $Path -Raw | ConvertFrom-Yaml
    }

    if (Get-Module -ListAvailable -Name 'powershell-yaml') {
        Import-Module powershell-yaml -ErrorAction Stop
        return Get-Content -Path $Path -Raw | ConvertFrom-Yaml
    }

    throw 'YAML parsing requires the powershell-yaml module or a runtime that provides ConvertFrom-Yaml.'
}

function Resolve-RangerCanonicalDomainName {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    $aliases = Get-RangerDomainAliases
    $key = $Name.Trim().ToLowerInvariant()
    if ($aliases.Keys -contains $key) {
        return $aliases[$key]
    }

    return $key
}

function Resolve-RangerSelectedCollectors {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config
    )

    $definitions = Get-RangerCollectorDefinitions
    $include = @($Config.domains.include | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ })
    $exclude = @($Config.domains.exclude | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ })
    $selected = New-Object System.Collections.ArrayList

    $hasBmcEndpoints = @($Config.targets.bmc.endpoints | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0

    foreach ($definition in $definitions.Values) {
        $covers = @($definition.Covers | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ })
        $isIncluded = $include.Count -eq 0 -or @($covers | Where-Object { $_ -in $include }).Count -gt 0
        $isExcluded = @($covers | Where-Object { $_ -in $exclude }).Count -gt 0

        # Issue #316: auto-deselect collectors that require BMC when no endpoints are configured,
        # unless the operator explicitly listed one of the collector's domains in domains.include.
        if ($definition.RequiredTargets -contains 'bmc' -and -not $hasBmcEndpoints -and $include.Count -eq 0) {
            continue
        }

        if ($isIncluded -and -not $isExcluded) {
            [void]$selected.Add($definition)
        }
    }

    return @($selected)
}

function Test-RangerConfiguration {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [switch]$PassThru
    )

    $errors = New-Object System.Collections.Generic.List[string]
    $warnings = New-Object System.Collections.Generic.List[string]
    $include = @($Config.domains.include | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ })
    $exclude = @($Config.domains.exclude | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ })
    $selectedCollectors = Resolve-RangerSelectedCollectors -Config $Config

    foreach ($domain in $include) {
        if ($domain -in $exclude) {
            $errors.Add("Domain '$domain' cannot appear in both include and exclude.")
        }
    }

    if ($Config.output.mode -notin @('current-state', 'as-built')) {
        $errors.Add("Output mode '$($Config.output.mode)' is not supported.")
    }

    $supportedFormats = @('html', 'markdown', 'md', 'svg', 'drawio', 'xml', 'json', 'docx', 'xlsx', 'pdf', 'powerbi', 'pptx', 'json-evidence')
    foreach ($format in @($Config.output.formats)) {
        if ($format -notin $supportedFormats) {
            $errors.Add("Output format '$format' is not supported.")
        }
    }

    $validLogLevels = @('debug', 'info', 'warn', 'warning', 'error', 'verbose')
    if ([string]::IsNullOrWhiteSpace($Config.behavior.logLevel) -or $Config.behavior.logLevel -notin $validLogLevels) {
        $errors.Add("behavior.logLevel '$($Config.behavior.logLevel)' is invalid. Valid values: debug, info, warn, warning, error, verbose.")
    }

    foreach ($missing in @(Get-RangerMissingRequiredInputs -Config $Config -SelectedCollectors $selectedCollectors)) {
        $errors.Add("Required input '$($missing.Path)' is missing or still set to its scaffold placeholder value.")
    }

    $azureSettings = Resolve-RangerAzureCredentialSettings -Config $Config -SkipSecretResolution
    $supportedAzureMethods = @('existing-context', 'managed-identity', 'device-code', 'service-principal', 'service-principal-cert', 'azure-cli')
    if ($azureSettings.method -notin $supportedAzureMethods) {
        $errors.Add("Azure credential method '$($azureSettings.method)' is not supported.")
    }

    if ($azureSettings.method -eq 'service-principal') {
        if ([string]::IsNullOrWhiteSpace($azureSettings.clientId)) {
            $errors.Add('Azure service-principal authentication requires credentials.azure.clientId.')
        }

        if ([string]::IsNullOrWhiteSpace($azureSettings.tenantId)) {
            $errors.Add('Azure service-principal authentication requires a tenantId in targets.azure or credentials.azure.')
        }

        if ([string]::IsNullOrWhiteSpace($Config.credentials.azure.clientSecret) -and [string]::IsNullOrWhiteSpace($Config.credentials.azure.clientSecretRef)) {
            $errors.Add('Azure service-principal authentication requires credentials.azure.clientSecret or credentials.azure.clientSecretRef.')
        }
    }

    foreach ($credentialName in @('cluster', 'domain', 'bmc', 'firewall', 'switch')) {
        $credentialBlock = $Config.credentials[$credentialName]
        if ($credentialBlock -and $credentialBlock.passwordRef) {
            try {
                ConvertFrom-RangerKeyVaultUri -Uri $credentialBlock.passwordRef | Out-Null
            }
            catch {
                $errors.Add("Credential '$credentialName' has an invalid Key Vault reference: $($_.Exception.Message)")
            }
        }
    }

    # Issue #300: fixture-mode bypass. When every selected collector has a
    # fixture path, the run reads from local JSON files and never touches the
    # cluster or Azure — so the required-target check is moot. This mirrors
    # the fixture-mode short-circuit in Get-RangerMissingRequiredInputs.
    $fixtureMap = if ($Config.domains -and $Config.domains.hints -and $Config.domains.hints.fixtures) { $Config.domains.hints.fixtures } else { $null }
    $fixtureMode = $false
    if ($fixtureMap -is [System.Collections.IDictionary] -and $selectedCollectors.Count -gt 0) {
        $fixtureMode = @($selectedCollectors | Where-Object { -not $fixtureMap.Contains($_.Id) -or [string]::IsNullOrWhiteSpace([string]$fixtureMap[$_.Id]) }).Count -eq 0
    }

    if (-not $fixtureMode) {
        foreach ($collector in $selectedCollectors) {
            foreach ($requiredTarget in $collector.RequiredTargets) {
                if (-not (Test-RangerTargetConfigured -Config $Config -TargetName $requiredTarget)) {
                    if ($collector.Class -eq 'core') {
                        $errors.Add("Collector '$($collector.Id)' requires target '$requiredTarget'.")
                    }
                    else {
                        $warnings.Add("Collector '$($collector.Id)' will be skipped because target '$requiredTarget' is not configured.")
                    }
                }
            }
        }
    }

    if ($azureSettings.method -eq 'azure-cli' -and -not [bool]$Config.credentials.azure.useAzureCliFallback) {
        $warnings.Add('Azure CLI authentication was selected without CLI fallback enabled; Azure resource enumeration may be incomplete.')
    }

    $result = [ordered]@{
        IsValid  = $errors.Count -eq 0
        Errors   = @($errors)
        Warnings = @($warnings)
    }

    if ($PassThru) {
        return $result
    }

    if (-not $result.IsValid) {
        throw ($result.Errors -join [Environment]::NewLine)
    }
}

function Test-RangerTargetConfigured {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

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

    switch ($TargetName) {
        'cluster' {
            $clusterTarget = $Config.targets.cluster
            if ($null -eq $clusterTarget) { return $false }
            return -not [string]::IsNullOrWhiteSpace($clusterTarget.fqdn) -or @($clusterTarget.nodes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0
        }
        'azure' {
            return -not [string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId) -or -not [string]::IsNullOrWhiteSpace($Config.targets.azure.resourceGroup)
        }
        'bmc' {
            return @($Config.targets.bmc.endpoints | Where-Object {
                if ($null -eq $_) {
                    return $false
                }

                if ($_ -is [string]) {
                    return -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}')
                }

                $endpointHost = if ($_ -is [System.Collections.IDictionary]) { $_['host'] } else { $_.host }
                return -not [string]::IsNullOrWhiteSpace([string]$endpointHost)
            }).Count -gt 0
        }
        default {
            return $false
        }
    }
}