configmap.ps1

#requires -version 7.0

$languages = @{
    "build" = @{
        reservedKeys = @("exec", "list")
    }
    "conf"  = @{
        reservedKeys = @("options", "exec", "list", "get", "set", "validate")
    }
}

function Get-MapLanguage {
    param([ValidateSet("build", "conf")]$language)
    return $languages.$language
}

# in order to make imports from the map file work globally, we have to call dot-source from top-level scope.
# hence this pattern:
# $map = Resolve-ConfigMap $map | % { . $_ } | Validate-ConfigMap
function Resolve-ConfigMap {
    [OutputType([System.Collections.IDictionary])]
    param(
        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [string]
        # somehow validateScript is throwing an error when $map is null
        # [ValidateScript({ $null -eq $_ -or $_ -is [string] -or $_ -is [System.Collections.IDictionary] })]
        $map,
        [Parameter(Mandatory = $false)]
        $fallback,
        [switch][bool]$lookUp = $true
    )
    
    # Set default map file if null
    if (!$map) {
        if (!$fallback) {
            throw "map is null and defaultMapFile is not provided"
            return $null
        }
        $map = $fallback 
    }
    
    # Load map from file if it's a string path
    if ($map -is [string]) {
        $fullPath = [System.IO.Path]::IsPathRooted($map) ? $map : (Join-Path $PWD.Path $map)
        $file = Split-Path $fullPath -Leaf
        $dir = Split-Path $fullPath -Parent
        
        do {
            $fullPath = Join-Path $dir $file
            if (Test-Path $fullPath) {
                return $fullPath
            }
            $dir = Split-Path $dir -Parent
        } while ($lookUp -and $dir)

        throw "map file '$map' not found"
        return $null
    }
    
    return $map
}

function Validate-ConfigMap {
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        $map
    )
    # Validate that we have a loaded map
    if (!$map) {
        throw "failed to load map"
        return $null
    }

    if ($map -isnot [System.Collections.IDictionary]) {
        throw "map is not a dictionary"
    }
    
    return $map
}


function Get-CompletionList {
    <#
    .SYNOPSIS
        Gets a flattened or hierarchical list of commands from a configuration map
    .PARAMETER map
        The configuration map to process. Can be a dictionary, array, scriptblock or string
    .PARAMETER flatten
        If true, flattens hierarchical commands into a single level. If false, maintains hierarchy with separators
    .PARAMETER separator
        The separator to use between parent and child command names when not flattened
    .PARAMETER groupMarker
        The marker to append to parent command names when flattened
    .PARAMETER listKey
        The key used to identify nested command lists
    .PARAMETER reservedKeys
        Array of reserved keys that should be skipped during processing
    .OUTPUTS
        [System.Collections.Specialized.OrderedDictionary] containing the processed command list
    #>

    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [ValidateScript({ 
                # the function do not suppurt strings, but ValidateScript iterates over the array, so for string[] we'll get string items here.
                # see: https://github.com/PowerShell/PowerShell/issues/6185
                $_ -is [System.Collections.IDictionary] -or $_ -is [array] -or $_ -is [scriptblock] -or $_ -is [string]
            })]
        $map,
        [switch][bool]$flatten = $false,
        [switch][bool]$leafsOnly = $false,
        $separator = ".",
        $groupMarker = $null, 
        $listKey = "list",
        $reservedKeys = $null,
        $maxDepth = -1
    )

    if ($maxDepth -eq 0) {
        return @{}
    }
    
    if (!$groupMarker) {
        $groupMarker = $flatten ? "*" : ""
    }

    $list = $map.$listKey ? $map.$listKey : $map
    $list = $list -is [scriptblock] ? (Invoke-Command -ScriptBlock $list) : $list
        
    $r = switch ($true) {
        { $list -is [System.Collections.IDictionary] } {
            $result = [ordered]@{}
    
            foreach ($kvp in $list.GetEnumerator()) {
                if ($kvp.key -in $reservedKeys -or $kvp.key -eq $listKey) {
                    continue
                }
                $entry = $kvp.value
                $entryInfo = Test-IsParentEntry $entry $listKey -reservedKeys $reservedKeys
                
                if (!$entryInfo.IsParent) {
                    $result["$($kvp.key)"] = $entry
                    continue
                }

                # Add parent marker
                if (!$leafsOnly) {
                    $result["$($kvp.key)$groupMarker"] = $entry
                }
                    
                # Get nested entries and add them with appropriate prefixes
                $subEntries = Get-CompletionList $entry -listKey $listKey -flatten:$flatten -leafsOnly:$leafsOnly -reservedKeys $reservedKeys -maxDepth ($maxDepth - 1)
                
                foreach ($sub in $subEntries.GetEnumerator()) {
                    $subKey = $flatten ? $sub.Key : "$($kvp.key)$separator$($sub.Key)"
                    $result[$subKey] = $sub.value
                }
            }

            return $result
        }
        { $list -is [array] } {
            $result = [ordered]@{}
            $subEntries = $list | ForEach-Object { 
                $r = [ordered]@{} 
            } { 
                $r[$_] = $_ 
            } { 
                $r 
            }
            
            if ($subEntries) {
                foreach ($sub in $subEntries.GetEnumerator()) {
                    if ($sub.key -in $reservedKeys -or $sub.key -eq $listKey) {
                        continue
                    }
                    $result[$sub.key] = $sub.value
                }
            }
            return $result
        }
        { $list -is [string] } {
            throw "string type not supported"
        }
        default {
            throw "$($list.GetType().FullName) type not supported"
        }
    }

    return $r
}

function Get-EntryCompletion(
    [ValidateScript({
            $_ -is [System.Collections.IDictionary]
        })]
    $map, 
    [ValidateSet("build", "conf")]
    $language,
    $commandName, 
    $parameterName, 
    $wordToComplete, 
    $commandAst, 
    $fakeBoundParameters
) {
    # For hierarchical completion, we need both flattened and tree structures
    $flatList = Get-CompletionList $map -flatten:$true -reservedKeys $languages.$language.reservedKeys
    $treeList = Get-CompletionList $map -flatten:$false -reservedKeys $languages.$language.reservedKeys
    
    # Combine both lists and remove duplicates
    $allKeys = @($flatList.Keys) + @($treeList.Keys) | Sort-Object -Unique
    
    return $allKeys | ? { $_.startswith($wordToComplete) }
}

function Get-EntryDynamicParam(
    [System.Collections.IDictionary] $map, 
    $key, 
    $command, 
    [int]$skip = 0,
    $bound
) {
    if (!$key) { return @() }

    $selectedEntry = Get-MapEntry $map $key
    if (!$selectedEntry) { return @() }
    
    # Use the command parameter to determine which command to extract, defaulting to "exec"
    $commandKey = $command ? $command : "exec"
    $entryCommand = Get-EntryCommand $selectedEntry $commandKey
    if (!$entryCommand) { return @() }
    $p = Get-ScriptArgs $entryCommand -skip $skip

    return $p
}

function Get-ScriptArgs {
    [OutputType([System.Management.Automation.RuntimeDefinedParameterDictionary])]
    param(
        [scriptblock]$func,
        [int]$skip = 0
    )
    function Get-SingleArg {
        [OutputType([System.Management.Automation.RuntimeDefinedParameter])]
        param([System.Management.Automation.Language.ParameterAst] $ast)
    
        $paramAttributesCollect = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
        
        $paramAttribute = New-Object -Type System.Management.Automation.ParameterAttribute
        $paramAttributesCollect.Add($paramAttribute)
    
        $paramType = $ast.StaticType
    
        foreach ($attr in $ast.Attributes) {
            if ($attr -is [System.Management.Automation.Language.TypeConstraintAst]) {
                if ($attr.TypeName.ToString() -eq "switch") {
                    $paramType = [switch]
                }
                else {
                    # $newAttr = New-Object -type System.Management.Automation.PSTypeNameAttribute($attr.TypeName.Name)
                    # $paramAttributesCollect.Add($newAttr)
                }
            }
        }
        
        # Create parameter with name, type, and attributes
        $name = $ast.Name.ToString().Trim("`$")
        $dynParam = New-Object -Type System.Management.Automation.RuntimeDefinedParameter($name, $paramType, $paramAttributesCollect)
    
        return $dynParam
    }

    $exclude = @("$_context", "$_self")

    # Add parameter to parameter dictionary and return the object
    $paramDictionary = New-Object `
        -Type System.Management.Automation.RuntimeDefinedParameterDictionary
    
    # Check if ParamBlock exists before accessing Parameters
    if ($func.AST.ParamBlock -and $func.AST.ParamBlock.Parameters) {
        $parameters = $func.AST.ParamBlock.Parameters
        
        $skipped = 0
        foreach ($param in $parameters) {
            if ("$($param.Name)" -in $exclude) {
                continue
            }
            if ($skipped -lt $skip) {
                $skipped++
                continue
            }
            $dynParam = Get-SingleArg $param
            $paramDictionary.Add($dynParam.Name, $dynParam)
        }
    }
    
    return $paramDictionary
}

function Get-MapEntries(
    [ValidateScript({
            $_ -is [System.Collections.IDictionary] -or $_ -is [array]
        })]
    $map, 
    $keys, 
    [switch][bool]$flatten = $false,
    [switch][bool]$leafsOnly = $false,
    $separator = ".",
    $reservedKeys = $null
) {
    $results = @()
    
    $completions = Get-CompletionList $map -flatten:$flatten -leafsOnly:$leafsOnly -separator:$separator -reservedKeys $reservedKeys

    foreach ($key in @($keys)) {        
        $found = $completions.GetEnumerator() | ? { $_.key -eq $key }
        if ($found) {
            $results += $found
        }        
    }

    if (!$results) {
        $completions = Get-CompletionList $map -flatten:$flatten -leafsOnly:$leafsOnly -separator:$separator -reservedKeys $reservedKeys
        Write-Verbose "entry '$keys' not found in ($($completions.Keys))"
    }

    return $results
}

function Get-MapEntry(
    [ValidateScript({
            $_ -is [System.Collections.IDictionary] -or $_ -is [array]
        })]
    $map, 
    $key,
    $separator = "."
) {
    return (Get-MapEntries $map $key -separator $separator).Value
}

# TODO: key should be a hidden property of $entry
function Get-EntryCommand(
    [ValidateScript({
            $_ -is [System.Collections.IDictionary] -or $_ -is [array] -or $_ -is [scriptblock]
        })]
    $entry, 
    $commandKey = "exec"
) {
    if (!$entry) { throw "entry is NULL" }
    if ($entry -is [scriptblock]) { return $entry }

    if ($entry -is [System.Collections.IDictionary] -or $entry -is [System.Collections.Hashtable]) {
        if (!$entry.$commandKey) {
            throw "Command '$commandKey' not found"
            return $null
        }
        return $entry.$commandKey
    }

    throw "Entry of type $($entry.GetType().Name) is not supported"
    return $null
}

function Invoke-EntryCommand($entry, $key, $ordered = @(), $bound = @{}) {
    $command = Get-EntryCommand $entry $key
    $scriptArgs = Get-ScriptArgs $command

    if (!$command) {
        throw "Command '$key' not found"
    }
    if ($command -isnot [scriptblock]) {
        throw "Entry '$key' of type $($command.GetType().Name) is not supported"
    }
    
    if (!$bound) { $bound = @{} }
    if (!$bound._context) { $bound._context = @{} }
    if (!$bound._context.self) { $bound._context.self = $entry }
    
    $filtered = @{}
    write-verbose "script args: $( $scriptArgs.Keys -join ', ' )"
    foreach ($boundKey in $bound.Keys) {        
        if ($boundKey -in $scriptArgs.Keys) {
            write-verbose "adding '$boundKey'"
            $filtered[$boundKey] = $bound[$boundKey]
        } else {
            write-verbose "skipping '$boundKey'"
        }
    }
    
    return & $command @ordered @filtered
}

# function Invoke-Entry(
# [ValidateScript({
# $_ -is [string] -or $_ -is [System.Collections.IDictionary]
# })]
# $map,
# $entry,
# $bound
# ) {
# $map = Import-ConfigMap $map

# $targets = Get-MapEntries $map $entry
# Write-Verbose "running targets: $($targets.Key)"

# @($targets) | % {
# Write-Verbose "running entry '$($_.key)'"
# Invoke-EntryCommand -entry $_.value -key "exec" -bound $bound
# }
# }

function Invoke-Set($entry, $bound = @{}) {
    # use ordered parameters, just in case the handler has different parameter names
    Invoke-EntryCommand $entry "set" -ordered @("", $bound.key, $bound.value) -bound $bound
}

function Invoke-Get($entry, $bound = @{}) {
    # use ordered parameters, just in case the handler has different parameter names
    Invoke-EntryCommand $entry "get" -ordered @("", $bound.options) -bound $bound
}

function Invoke-QBuild {
    [CmdletBinding()]
    param(
        [ArgumentCompleter({
                param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                try {
                    # ipmo configmap
                    $map = $fakeBoundParameters.map
                    $map = Resolve-ConfigMap $map -fallback "./.build.map.ps1"
                    if (!(Test-Path $map)) {
                        return @("init", "help", "list") | ? { $_.startswith($wordToComplete) }
                    }
                    $map = Resolve-ConfigMap $map | % { . $_ } | Validate-ConfigMap
                    return Get-EntryCompletion $map -language "build" @PSBoundParameters
                }
                catch {
                    return "ERROR [-entry]: $($_.Exception.Message) $($_.ScriptStackTrace)"
                }
            })]
        $entry = $null,
        $command = "exec",
        $map = "./.build.map.ps1"
    )
    dynamicparam {
        try {
            $map = Resolve-ConfigMap $map -fallback "./.build.map.ps1" | % { . $_ } | Validate-ConfigMap
            $result = Get-EntryDynamicParam $map $entry $command -skip 0 -bound $PSBoundParameters
            Write-Debug "Dynamic parameters for entry '$entry': $($result.Keys -join ', ')"
            return $result
        }
        catch {
            return "ERROR [dynamic]: $($_.Exception.Message) $($_.ScriptStackTrace)"
        }
    }

    process {
        if ($entry -eq "help") {
            Write-Host "QBUILD"
            Write-Host "A command line tool to manage build scripts"
            Write-Host ""
            Write-Host "Usage:"
            Write-Host "qbuild <your-script-name>"
            return
        }
        if ($entry -eq "list") {
            $map = Resolve-ConfigMap $map -fallback "./.build.map.ps1" | % { . $_ }
            if (!$map) {
                $invocation = $MyInvocation
                Write-Help -invocation $invocation -mapPath "./.build.map.ps1"
                return
            }
            Write-MapHelp -map $map -invocation $MyInvocation
            return
        }
        if ($entry -eq "init") {
            $loadedMap = Resolve-ConfigMap $map -ErrorAction Ignore | % { . $_ }
            if (!$loadedMap) {
                if ($map -isnot [string]) {
                    throw "Map appears to be an object, not a file"
                }
                if ((Test-Path $map)) {
                    throw "map file '$map' already exists"
                }

                Initialize-BuildMap -file $map

                return
            }
            else {
                $completionList = Get-CompletionList $loadedMap -reservedKeys $languages.build.reservedKeys
                if ($completionList.Keys -notcontains "init") {
                    throw "map file '$map' already exists"
                }
                else {
                    # continue with executing "init" command
                }
            }
             
        }

        $map = Resolve-ConfigMap $map -fallback "./.build.map.ps1" -ErrorAction Ignore | % { . $_ }
        if (!$map) {
            $invocation = $MyInvocation
            $commandName = $invocation.Statement
            
            Write-Help -invocation $invocation -mapPath "./.build.map.ps1"
            return
        }

        # If no entry is provided, list all available scripts
        if (-not $entry) {           
            Write-MapHelp -map $map -invocation $MyInvocation
            return
        }

        $targets = Get-MapEntries $map $entry
        Write-Verbose "running targets: $($targets.Key)"

        @($targets) | % {
            Write-Verbose "running entry '$($_.key)'"
            # FIXME: we already have the entry in $_.value, we know ITs own key, but we don't want to search for this key inside this object
            # we should pass null instead?
            #Invoke-EntryCommand -entry $_.value -key $_.Key $bound
            $bound = $PSBoundParameters
            $bound.Remove("entry") | out-null
            Invoke-EntryCommand -entry $_.value -key $command -bound $bound
        }
    
    }
}

function Invoke-QConf {
    [CmdletBinding()]
    param(
        [ValidateSet("set", "get", "list", "help", "init")]
        $command,
        
        [ArgumentCompleter({
                param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                try {
                    # ipmo configmap
                    $map = $fakeBoundParameters.map
                    $map = $map -is [System.Collections.IDictionary] ? $map : (Resolve-ConfigMap $map -fallback ".configuration.map.ps1" | % { . $_ } | Validate-ConfigMap)
                    if (!$map) {
                        return @("init", "help", "list") | ? { $_.startswith($wordToComplete) }
                    }
                    return Get-EntryCompletion $map -language "conf" @PSBoundParameters
                }
                catch {
                    return "ERROR [-entry]: $($_.Exception.Message) $($_.ScriptStackTrace)"
                }
            })]
        $entry = $null,
        
        [ArgumentCompleter({
                param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                try {
                    if ($fakeBoundParameters.command -in @("init", "help")) {
                        return @()
                    }

                    $map = $fakeBoundParameters.map
                    $map = Resolve-ConfigMap $map -fallback ".configuration.map.ps1" | % { . $_ } | Validate-ConfigMap
                    $entry = $fakeBoundParameters.entry
                    $entry = Get-MapEntry $map $entry
                    if (!$entry) {
                        throw "entry '$entry' not found"
                    }
                    $options = Get-CompletionList $entry -listKey "options" -reservedKeys $languages.conf.reservedKeys -maxDepth 1
                    return $options.Keys | ? { $_.startswith($wordToComplete) }
                }
                catch {
                    return "ERROR [-value]: $($_.Exception.Message) $($_.ScriptStackTrace)"
                }
            })] 
        $value = $null,
        $map = "./.configuration.map.ps1"
    )

    ## we need dynamic parameters for commands that have custom parameter list
    ## this assumes that -entry and -command are already provided
    dynamicparam {
        # ipmo configmap
        try {
            if ( !$entry) {
                return @()
            }
            $map = Resolve-ConfigMap $map -fallback ".configuration.map.ps1" | % { . $_ } | Validate-ConfigMap
            $skip = switch ($command) {
                "set" { 3 }
                default { 0 }
            }
            
            return Get-EntryDynamicParam $map "$entry.$command" -skip $skip -bound $PSBoundParameters
        }
        catch {
            return "ERROR [dynamic]: $($_.Exception.Message) $($_.ScriptStackTrace)"
        }
    }

    process {
        if ($command -eq "help") {
            Write-Host "QCONF"
            Write-Host "A command line tool to manage configuration maps"
            Write-Host ""
            Write-Host "Usage:"
            Write-Host "qconf -entry <entry> -command <command> -value <value>"
            return
        }
        if ($command -eq "init") {
            if (!$map) { $map = "./.configuration.map.ps1" }
            if ($map -is [string]) {
                if ((Test-Path $map)) {
                    throw "map file '$map' already exists"
                }
            }
            else {
                throw "Map appears to be an object, not a file"
            }

            Initialize-ConfigMap -file $map

            return
        }

        $map = $map -is [System.Collections.IDictionary] ? $map : (Resolve-ConfigMap $map | % { . $_ } | Validate-ConfigMap)

        if (-not $entry -and -not $command) {           
            Write-MapHelp -map $map -invocation $MyInvocation -language "conf"
            return
        }

        if ($command -and -not $entry) {
            
        }

        Write-Verbose "entry=$entry command=$command"


        switch ($command) {
            "set" {
                $subEntry = $map.$entry
                if (!$subEntry) {
                    throw "entry '$entry' not found"
                }
        
                $optionKey = $value
                $options = Get-CompletionList $subEntry -listKey "options" -reservedKeys $languages.conf.reservedKeys -maxDepth 1
                $optionValue = $options.$optionKey

                $bound = $PSBoundParameters
                $bound.key = $optionKey
                $bound.value = $optionValue
                Invoke-Set $subEntry -ordered "", $optionValue, $optionKey -bound $bound
            }
            "get" {
                $entries = $entry
                if (!$entries) {
                    # not passing -listKey "options" here, as we don't want to expand options - we just need top-level keys
                    $entries = (Get-CompletionList $map -reservedKeys $languages.conf.reservedKeys).Keys 
                }

                foreach ($entry in @($entries)) {
                    try {
                        $subEntry = $map.$entry

                        $options = Get-CompletionList $subEntry -listKey "options" -reservedKeys $languages.conf.reservedKeys -maxDepth 1
                
                        $bound = $PSBoundParameters
                        $bound.options = $options
                
                        $value = Invoke-Get $subEntry -bound $bound
                
                        $result = ConvertTo-MapResult $value $entry $subEntry $options
                        $result | Write-Output
                    }
                    catch {
                        Write-Error "Error getting value for entry '$entry': $($_.Exception.Message)"
                    }
                }
            }
            default {
                throw "command '$command' not supported"
            }
        }
        
    }
}

function ConvertTo-MapResult($value, $entryName, $entry, $options, $validate = $true) {
    $result = $null
    if ($value -is [Hashtable]) {
        $hash = @{
            Path = "$entryName/$subPath"
        }
        $hash += $value
        
        $result = $hash
    }
    else {
        $result = @{ 
            Path  = "$entryName/$subPath"
            Value = $value
        }
    }

    if (!$result.Active) {
        $result.Active = $options.keys | where { $options.$_ -eq $value }
    }
    $result.Options = $options.keys

    $isvalid = "?"
    if ($validate -and $entry.validate) {
        if (!$result.Active) {
            Write-Warning "no active option found for $entryName/$subPath"
            $isvalid = $null
        }
        else {
            $optionvalue = $options.$($result.Active)
            $isvalid = Invoke-EntryCommand $entry validate -ordered @($path, $optionvalue, $result.Active)
        }
    }

    $result = [PSCustomObject]@{
        Path    = $result.Path
        Value   = $result.Value
        Active  = $result.Active
        Options = $result.Options
        IsValid = $isvalid
    }

    return $result
}

function Initialize-ConfigMap([Parameter(Mandatory = $true)] $file) {
    if (Test-Path $file) {
        throw "map file '$file' already exists"
    }

    $defaultConfig = Get-Content $PSScriptRoot/samples/_default/.configuration.map.ps1
    Write-Host "Initializing configmap file '$file'"
    $defaultConfig | Out-File $file

    $fullPath = (Get-Item $file).FullName
    $dir = Split-Path $fullPath -Parent
    $defaultUtils = Get-Content $PSScriptRoot/samples/_default/.config-utils.ps1
    $defaultUtils | Out-File (Join-Path $dir ".config-utils.ps1")
}


function Initialize-BuildMap([Parameter(Mandatory = $true)] $file) {
    if (Test-Path $file) {
        throw "map file '$file' already exists"
    }

    $defaultConfig = Get-Content $PSScriptRoot/samples/_default/.build.map.ps1
    Write-Host "Initializing buildmap file '$file'"
    $defaultConfig | Out-File $file
}

#region hierarchy

function Test-IsParentEntry {
    <#
    .SYNOPSIS
        Determines if an entry is a parent container (has nested commands) or a leaf (executable command)
    .PARAMETER Entry
        The map entry to test
    .PARAMETER ListKey
        The key used to identify nested lists (default: "list")
    .PARAMETER ReservedKeys
        Array of reserved keys that should be skipped during processing
    .OUTPUTS
        [PSCustomObject] with IsParent (bool) and HasExplicitList (bool) properties
    #>

    param(
        $Entry,
        $ListKey = "list",
        $ReservedKeys = @("options", "exec", "list")
    )
    
    # If entry is not a hashtable, it's a leaf (scriptblock or other)
    if ($Entry -isnot [System.Collections.IDictionary]) {
        return [PSCustomObject]@{
            IsParent        = $false
            HasExplicitList = $false
        }
    }
    
    # Check for explicit list key (traditional nested structure)
    if ($Entry.$ListKey) {
        return [PSCustomObject]@{
            IsParent        = $true
            HasExplicitList = $true
        }
    }
    
    # Check if entry contains nested commands (hashtables or scriptblocks)
    $hasNestedCommands = $false
    foreach ($subKvp in $Entry.GetEnumerator()) {
        if ($subKvp.Key -in $reservedKeys -or $subKvp.Key -eq $ListKey) {
            continue
        }
        if ($subKvp.Value -is [System.Collections.IDictionary] -or $subKvp.Value -is [scriptblock]) {
            $hasNestedCommands = $true
            break
        }
    }
    
    return [PSCustomObject]@{
        IsParent        = $hasNestedCommands
        HasExplicitList = $false
    }
}

#endregion

#region help
function Write-MapHelp {
    param([System.Collections.IDictionary]$map, $invocation, [ValidateSet("build", "conf")]$language = "build")
    $commandName = $invocation.InvocationName
    $scripts = Get-CompletionList $map -reservedKeys $languages.$language.reservedKeys
    
    # Calculate max command name length for alignment
    $maxNameLength = ($scripts.Keys | Measure-Object -Property Length -Maximum).Maximum
    $maxNameLength = [Math]::Max($maxNameLength, 12) # Minimum width
    
    Write-Host ""
    Write-Host "$($commandName.ToUpper())" -ForegroundColor Cyan
    Write-Host "A command line tool to manage $language scripts" -ForegroundColor Gray
    Write-Host ""
    Write-Host "USAGE:" -ForegroundColor Yellow
    Write-Host " $commandName <COMMAND> [OPTIONS]" -ForegroundColor White
    Write-Host ""
    Write-Host "COMMANDS:" -ForegroundColor Yellow
    
    # Sort scripts alphabetically
    $sortedScripts = $scripts.GetEnumerator() | Sort-Object Name
    
    foreach ($item in $sortedScripts) {
        $name = $item.Name
        $script = $item.Value
        try {
            $entry = Get-EntryCommand $script
        }
        catch {
            $entry = $null
        }
        $args = $entry ? (Get-ScriptArgs $entry) : @{}
        
        # Format command name with proper padding
        $paddedName = $name.PadRight($maxNameLength)
        
        # Get description
        $description = ""
        if ($script -is [System.Collections.IDictionary] -and $script.description) {
            $description = $script.description
        }
        
        $argList = $args.Keys | % { "-$($_)" }
        $paramInfo = ($argList -join " ")
                
        Write-Host " " -NoNewline
        Write-Host "$paddedName" -ForegroundColor Green -NoNewline
        if ($paramInfo) {
            Write-Host " [$paramInfo]" -ForegroundColor DarkGray -NoNewline
        }
        Write-Host " $description" -ForegroundColor White
    }
}

function Write-Help {
    param($invocation, [string]$mapPath)
    $commandName = $invocation.Statement
    
    Write-Host "No build map file found at '$mapPath'"
    Write-Host ""
    Write-Host "To create a new build map file, run:"
    Write-Host " $commandName init"
    Write-Host ""
    Write-Host "This will create a sample $mapPath file with basic build scripts."
}

#endregion


Set-Alias -Name "qbuild" -Value "Invoke-QBuild" -Force
Set-Alias -Name "qconf" -Value "Invoke-QConf" -Force