Modules/Private/20-Config.ps1

function Get-RangerDefaultConfig {
    [ordered]@{
        environment = [ordered]@{
            name        = 'prod-azlocal-01'
            clusterName = 'azlocal-prod-01'
            description = 'Primary production Azure Local instance'
        }
        targets = [ordered]@{
            cluster = [ordered]@{
                fqdn  = 'azlocal-prod-01.contoso.com'
                nodes = @(
                    'azl-node-01.contoso.com',
                    'azl-node-02.contoso.com'
                )
            }
            azure = [ordered]@{
                subscriptionId = '00000000-0000-0000-0000-000000000000'
                resourceGroup  = 'rg-azlocal-prod-01'
                tenantId       = '11111111-1111-1111-1111-111111111111'
            }
            bmc = [ordered]@{
                endpoints = @(
                    [ordered]@{ host = 'idrac-node-01.contoso.com'; node = 'azl-node-01.contoso.com' },
                    [ordered]@{ host = 'idrac-node-02.contoso.com'; node = 'azl-node-02.contoso.com' }
                )
            }
            switches  = @()
            firewalls = @()
        }
        credentials = [ordered]@{
            azure = [ordered]@{
                method             = 'existing-context'
                useAzureCliFallback = $true
            }
            cluster = [ordered]@{
                username    = 'CONTOSO\ranger-read'
                passwordRef = 'keyvault://kv-ranger/cluster-read'
            }
            domain = [ordered]@{
                username    = 'CONTOSO\ranger-read'
                passwordRef = 'keyvault://kv-ranger/domain-read'
            }
            bmc = [ordered]@{
                username    = 'root'
                passwordRef = 'keyvault://kv-ranger/idrac-root'
            }
        }
        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
        }
        behavior = [ordered]@{
            promptForMissingCredentials   = $true
            promptForMissingRequired      = $true
            skipUnavailableOptionalDomains = $true
            failOnSchemaViolation         = $true
            logLevel                      = 'info'
            retryCount                    = 2
            timeoutSeconds                = 60
            continueToRendering           = $true
        }
    }
}

function Get-RangerAnnotatedConfigYaml {
    # Returns a self-documenting YAML template with [REQUIRED] markers and inline comments.
    # Used by New-AzureLocalRangerConfig -Format yaml (the default).
    @'
# AzureLocalRanger configuration file
# Generated by New-AzureLocalRangerConfig
#
# Fields marked [REQUIRED] must be updated before running Invoke-AzureLocalRanger.
# Run: Invoke-AzureLocalRanger -ConfigPath <this file>
# Resolution precedence: parameter > config file > interactive prompt > default > error
# Docs: https://azurelocal.github.io/azurelocal-ranger

environment:
  name: prod-azlocal-01 # [REQUIRED] Short identifier used in report filenames
  clusterName: azlocal-prod-01 # Friendly cluster display name for reports
  description: Primary production Azure Local instance

targets:
  cluster:
    fqdn: azlocal-prod-01.contoso.com # [REQUIRED] FQDN of the cluster name object (CNO)
    nodes:
      - azl-node-01.contoso.com # [REQUIRED] At least one node FQDN or NetBIOS name
      - azl-node-02.contoso.com # Add/remove entries to match your cluster
  azure:
    subscriptionId: 00000000-0000-0000-0000-000000000000 # [REQUIRED] Azure subscription ID
    resourceGroup: rg-azlocal-prod-01 # [REQUIRED] Resource group of the Arc-enabled HCI resource
    tenantId: 11111111-1111-1111-1111-111111111111 # [REQUIRED] Azure AD / Entra tenant ID
  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: 'CONTOSO\ranger-read' # [REQUIRED] Account with WinRM / WS-Man read access to cluster nodes
    passwordRef: keyvault://kv-ranger/cluster-read # Vault reference, plain password, or leave blank to be prompted
  domain:
    username: 'CONTOSO\ranger-read' # Account for AD queries; leave blank to reuse cluster credential
    passwordRef: keyvault://kv-ranger/domain-read
  bmc:
    username: root # BMC credential (iDRAC / iLO); only needed if BMC targets are set
    passwordRef: keyvault://kv-ranger/idrac-root

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 | compliance | drift
  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]) {
                $host = [string]$endpoint
                if ([string]::IsNullOrWhiteSpace($host) -or $host -in @('[]', '{}')) {
                    continue
                }

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

            $host = 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]$host)) {
                continue
            }

            [ordered]@{
                host = [string]$host
                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 Import-RangerConfiguration {
    param(
        [string]$ConfigPath,
        $ConfigObject
    )

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

    if (-not $ConfigPath) {
        throw 'Either ConfigPath or ConfigObject must be supplied.'
    }

    $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"
        }
    }

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

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']
    }

    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
    }

    try {
        return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected
    }
    catch {
        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

    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' })
    }

    $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')
    if (-not $hasClusterFqdn -and $clusterNodes.Count -eq 0) {
        [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) {
        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' })
        }
        if (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' })
        }
    }

    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
    )

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

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

    foreach ($missing in @(Get-RangerMissingRequiredInputs -Config $Config)) {
        $prompt = "[Ranger] $($missing.Prompt) is required.`nEnter $($missing.Prompt) (e.g., $($missing.Example)):"
        $value = Read-Host -Prompt $prompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            Set-RangerConfigValueByPath -Config $Config -Path $missing.Path -Value $value.Trim()
        }
    }

    return $Config
}

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

    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
        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')
    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', '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)")
            }
        }
    }

    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 @('[]', '{}')
                }

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