src/functions/custom-process.ps1

#requires -version 7.0

# Handler names are matched ordinal-ignore-case.
# 'exec' is the built-in default process (normal map entry exec). It is never
# handed to an external CLI. Other registered handlers stay in-process.
# Null GetDynamicParam or WriteListEntry keeps the host fallback for that slot.
# Only an unregistered, non-builtin name is handed to an external process command.
$script:ConfigMapDefaultProcessName = 'exec'
$script:ConfigMapProcessHandlers = [System.Collections.Generic.Dictionary[string, hashtable]]::new(
    [StringComparer]::OrdinalIgnoreCase
)

function Register-ConfigMapProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [scriptblock]$Invoke,
        [scriptblock]$GetDynamicParam,
        [scriptblock]$WriteListEntry
    )

    if ([string]::IsNullOrWhiteSpace($Name)) {
        throw 'Process handler name is required.'
    }

    $script:ConfigMapProcessHandlers[$Name] = @{
        Name            = $Name
        Invoke          = $Invoke
        GetDynamicParam = $GetDynamicParam
        WriteListEntry  = $WriteListEntry
    }
}

function Get-ConfigMapProcessHandler {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Name
    )

    if ([string]::IsNullOrWhiteSpace($Name) -or -not $script:ConfigMapProcessHandlers.ContainsKey($Name)) {
        return $null
    }

    return $script:ConfigMapProcessHandlers[$Name]
}

function Unregister-ConfigMapProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    [void]$script:ConfigMapProcessHandlers.Remove($Name)
}

function New-ConfigMapProcessContext {
    <#
    .SYNOPSIS
        Context passed to an in-process handler.
 
        dynamicparam leaves OperationContext empty. process fills every field.
    #>

    param(
        $Map,
        $UserMap,
        $MapArgument,
        $Entry,
        $Command,
        $Invocation,
        $OperationContext,
        $BoundParameters,
        [AllowEmptyString()]
        [string]$ProcessAction,
        [AllowEmptyString()]
        [string]$ProcessValue,
        [AllowNull()]
        [string[]]$RemainingArguments
    )

    $passthrough = @($RemainingArguments | Where-Object { $null -ne $_ })
    return @{
        Map                = $Map
        UserMap            = $UserMap
        MapArgument        = $MapArgument
        Entry              = $Entry
        Command            = $Command
        Invocation         = $Invocation
        OperationContext   = $OperationContext
        BoundParameters    = $BoundParameters
        ProcessAction      = $ProcessAction
        ProcessValue       = $ProcessValue
        RemainingArguments = $passthrough
    }
}

function Get-ConfigMapProcessPassthrough {
    param(
        [AllowEmptyString()]
        [string]$ProcessAction,
        [AllowEmptyString()]
        [string]$ProcessValue,
        [AllowNull()]
        [string[]]$RemainingArguments
    )

    return @(
        $ProcessAction
        $ProcessValue
        $RemainingArguments
    ) | Where-Object { $null -ne $_ -and "$_" -ne '' }
}

function Test-ConfigMapProcessIsBuiltin {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$ProcessName
    )

    if ([string]::IsNullOrWhiteSpace($ProcessName)) {
        return $true
    }

    return $ProcessName -eq $script:ConfigMapDefaultProcessName
}

function Test-ConfigMapProcessResolvesToHost {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName
    )

    $cmd = Get-Command $ProcessName -ErrorAction SilentlyContinue
    if (-not $cmd) {
        return $false
    }
    if ($cmd.CommandType -eq 'Alias') {
        $cmd = $cmd.ResolvedCommand
    }
    return $cmd.Name -eq 'Invoke-QBuild'
}

function Test-ConfigMapProcessCannotDelegate {
    <#
    .SYNOPSIS
        True when a process name must not be handed to an external CLI.
        Covers the builtin 'exec' default and names that resolve to the qbuild host.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$ProcessName
    )

    if (Test-ConfigMapProcessIsBuiltin -ProcessName $ProcessName) {
        return $true
    }

    return Test-ConfigMapProcessResolvesToHost -ProcessName $ProcessName
}

function Invoke-ConfigMapExternalProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName,
        [Parameter(Mandatory = $true)]
        [string]$Entry,
        $Map,
        [hashtable]$BoundParameters,
        [AllowEmptyString()]
        [string]$ProcessAction,
        [AllowEmptyString()]
        [string]$ProcessValue,
        [string[]]$RemainingArguments
    )

    $resolvedMapPath = Resolve-MapPath -Map $Map
    if (-not $resolvedMapPath) {
        throw "Cannot delegate process '$ProcessName' for entry '$Entry': the map has no _sourceFile."
    }
    if (Test-ConfigMapProcessCannotDelegate -ProcessName $ProcessName) {
        throw "Cannot delegate process '$ProcessName' for entry '$Entry': no in-process handler is registered."
    }

    $passthrough = Get-ConfigMapProcessPassthrough `
        -ProcessAction $ProcessAction `
        -ProcessValue $ProcessValue `
        -RemainingArguments $RemainingArguments
    $splat = ConvertTo-ConfigMapProcessSplat `
        -MapPath $resolvedMapPath `
        -Entry $Entry `
        -BoundParameters $BoundParameters `
        -RemainingArguments $passthrough

    Invoke-ConfigMapProcessCli -ProcessName $ProcessName -Splat $splat
}

function Get-EntryProcess {
    param(
        $Entry,
        [string]$Default = 'exec'
    )

    if (
        $Entry -is [System.Collections.IDictionary] -and
        $Entry._settings -is [System.Collections.IDictionary] -and
        $Entry._settings.Contains('process') -and
        $Entry._settings.process
    ) {
        return [string]$Entry._settings.process
    }
    return $Default
}

function Resolve-MapPath {
    <#
    .SYNOPSIS
        Get the source file path for process CLI -map handoff.
    #>

    param(
        $Map
    )

    if ($Map -is [System.Collections.IDictionary] -and $Map._sourceFile) {
        return [string]$Map._sourceFile
    }
    return $null
}

function Resolve-ConfigMapProcessCommand {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName
    )

    $cmd = Get-Command $ProcessName -ErrorAction SilentlyContinue
    if (-not $cmd) {
        throw "Process command '$ProcessName' not found on PATH / session. Install or import it so '& $ProcessName' works."
    }
    if ($cmd.CommandType -eq 'Alias') {
        $cmd = $cmd.ResolvedCommand
    }
    return $cmd
}

function Get-QBuildProcessCompletionContext {
    param(
        $CommandAst,
        [hashtable]$FakeBoundParameters
    )

    $elements = @($CommandAst.CommandElements | ForEach-Object { [string]$_.Extent.Text })
    $mapInput = $FakeBoundParameters.map
    $positionals = [System.Collections.Generic.List[string]]::new()

    for ($index = 1; $index -lt $elements.Count; $index++) {
        $token = $elements[$index]
        if ($token -eq '-map' -and $index + 1 -lt $elements.Count) {
            if (-not $mapInput) {
                $mapInput = $elements[++$index].Trim("'`"")
            }
            else {
                $index++
            }
            continue
        }
        if ($token.StartsWith('-')) { continue }
        $positionals.Add($token.Trim("'`""))
    }

    return [pscustomobject]@{
        MapInput      = $mapInput
        Entry         = if ($FakeBoundParameters.entry) { [string]$FakeBoundParameters.entry } elseif ($positionals.Count -gt 0) { $positionals[0] } else { $null }
        ProcessAction = if ($FakeBoundParameters.ProcessAction) { [string]$FakeBoundParameters.ProcessAction } elseif ($positionals.Count -gt 1) { $positionals[1] } else { $null }
        Positionals   = @($positionals)
    }
}

function Get-QBuildRemainingArgumentPosition {
    param(
        $CommandAst,
        [hashtable]$FakeBoundParameters,
        [AllowEmptyString()]
        [string]$Word = ''
    )

    if (-not $CommandAst) { return $null }

    $context = Get-QBuildProcessCompletionContext -CommandAst $CommandAst -FakeBoundParameters $FakeBoundParameters
    $positionals = @($context.Positionals)
    $slot = $positionals.Count
    $normalizedWord = $Word.Trim("'`"")
    if ($normalizedWord -ne '' -and $slot -gt 0 -and $positionals[-1] -eq $normalizedWord) {
        $slot = $slot - 1
    }

    switch ($slot) {
        1 { return 'Action' }
        2 { return 'Value' }
        default { return $null }
    }
}

function Get-QBuildProcessArgumentCompletion {
    param(
        $CommandAst,
        [hashtable]$FakeBoundParameters,
        [AllowEmptyString()]
        [string]$Word = '',
        [ValidateSet('Action', 'Value')]
        [string]$Position
    )

    $context = Get-QBuildProcessCompletionContext -CommandAst $CommandAst -FakeBoundParameters $FakeBoundParameters
    if (-not $context.Entry) { return @() }
    if ($Position -eq 'Value' -and -not $context.ProcessAction) { return @() }

    $processMap = Import-ConfigMap -Map $context.MapInput -Fallback "./.build.map.ps1" | Assert-ConfigMap
    $settings = Get-ConfigMapSettingsForPath -Map $processMap -Path $context.Entry
    if (Test-ConfigMapProcessIsBuiltin -ProcessName $settings.Process) { return @() }

    $mapPath = Resolve-MapPath -Map $processMap
    if (-not $mapPath) { return @() }

    $complete = @{
        ProcessName = $settings.Process
        MapPath     = $mapPath
        Entry       = $context.Entry
        Word        = $Word
    }
    if ($Position -eq 'Value') {
        $complete.RemainingArguments = @($context.ProcessAction)
    }
    return Get-ConfigMapProcessCompleteWord @complete
}

function Invoke-ConfigMapProcessComplete {
    <#
    .SYNOPSIS
        Run '<process> !complete …'; return trimmed lines, or $null if unsupported/missing.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName,
        [Parameter(Mandatory = $true)]
        [string]$MapPath,
        [Parameter(Mandatory = $true)]
        [hashtable]$CompleteArgs
    )

    if (Test-ConfigMapProcessCannotDelegate -ProcessName $ProcessName) {
        return $null
    }

    try {
        $cmd = Resolve-ConfigMapProcessCommand -ProcessName $ProcessName
    }
    catch {
        return $null
    }

    $global:LASTEXITCODE = 0
    $output = & $cmd -map $MapPath '!complete' @CompleteArgs 2>$null
    if ($global:LASTEXITCODE -eq 2) {
        return $null
    }

    return @(
        @($output) |
            ForEach-Object { "$_".Trim() } |
            Where-Object { $_ }
    )
}

function New-ConfigMapOpaqueDynamicParam {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [type]$ParameterType = [switch],
        [switch]$DontShow
    )

    $attribute = New-Object System.Management.Automation.ParameterAttribute
    if ($DontShow) {
        $attribute.DontShow = $true
    }
    $attributes = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
    $attributes.Add($attribute)
    return New-Object System.Management.Automation.RuntimeDefinedParameter($Name, $ParameterType, $attributes)
}

function ConvertTo-ConfigMapProcessSplat {
    <#
    .SYNOPSIS
        Build a hashtable splat for '& process @splat' (path-only -map + opaque bound params).
 
        Hashtable splat is required so -Force / -Follow bind as switches; a string[] argv
        splat passes '-Force' as a literal remaining argument.
 
        Run: -Entry <path> → process -map <file> -entry <path> …
        Verb: -Command list … → process -map <file> -command list …
        Both: -Command !complete -Entry x → process !complete -entry x
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$MapPath,
        [string]$Command,
        [AllowEmptyString()]
        [string]$Entry,
        [hashtable]$BoundParameters,
        [string[]]$RemainingArguments,
        [string[]]$SkipKeys = @(
            'entry', 'command', 'map', 'ProcessAction', 'ProcessValue', 'RemainingArguments',
            'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction',
            'ErrorVariable', 'WarningVariable', 'InformationVariable',
            'OutVariable', 'OutBuffer', 'PipelineVariable', 'ProgressAction'
        )
    )

    $splat = @{
        map = $MapPath
    }

    if ($Command) {
        $splat['command'] = $Command
    }

    if ($PSBoundParameters.ContainsKey('Entry') -and $null -ne $Entry) {
        $splat['entry'] = $Entry
    }

    if ($BoundParameters) {
        foreach ($key in @($BoundParameters.Keys)) {
            if ($key -in $SkipKeys) { continue }
            if ($key -like '_*') { continue }

            $val = $BoundParameters[$key]
            if ($val -is [System.Management.Automation.SwitchParameter]) {
                if ($val.IsPresent) {
                    $splat[$key] = $true
                }
                continue
            }
            if ($val -is [bool]) {
                if ($val) {
                    $splat[$key] = $true
                }
                continue
            }

            $splat[$key] = $val
        }
    }

    $passthrough = @($RemainingArguments | Where-Object { $null -ne $_ })
    if ($passthrough.Count -gt 0) {
        $splat['RemainingArguments'] = $passthrough
    }

    return $splat
}

function Invoke-ConfigMapProcessCli {
    <#
    .SYNOPSIS
        Invoke a process command via the call operator with path-only -map handoff.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName,
        [Parameter(Mandatory = $true)]
        [hashtable]$Splat
    )

    if (Test-ConfigMapProcessCannotDelegate -ProcessName $ProcessName) {
        throw "Cannot delegate process '$ProcessName': no in-process handler is registered."
    }

    $cmd = Resolve-ConfigMapProcessCommand -ProcessName $ProcessName
    $global:LASTEXITCODE = 0
    & $cmd @Splat
    if ($global:LASTEXITCODE -ne 0) {
        throw "Process command '$ProcessName' exited with code $global:LASTEXITCODE."
    }
}

function Get-ConfigMapProcessNamesInMap {
    <#
    .SYNOPSIS
        Distinct process names declared on top-level entries that have no in-process handler.
        Skips the builtin 'exec' default and names that resolve to the qbuild host.
    #>

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

    if (-not $OperationContext) {
        $OperationContext = New-ConfigMapOperationContext
    }

    $names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($key in @($Map.Keys)) {
        if ([string]$key -like '_*') { continue }
        $resolved = Get-MapEntry $Map $key -language 'build' -OperationContext $OperationContext
        $proc = Get-EntryProcess $resolved
        if (
            $proc -and
            -not (Get-ConfigMapProcessHandler -Name $proc) -and
            -not (Test-ConfigMapProcessCannotDelegate -ProcessName $proc)
        ) {
            [void]$names.Add($proc)
        }
    }
    return @($names | Sort-Object)
}

function Get-ConfigMapProcessCompleteParams {
    <#
    .SYNOPSIS
        Discover opaque parameter names via '<process> !complete -params -entry …'.
        Returns $null when unsupported (exit 2 / missing command).
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName,
        [Parameter(Mandatory = $true)]
        [string]$MapPath,
        [Parameter(Mandatory = $true)]
        [string]$Entry
    )

    return Invoke-ConfigMapProcessComplete -ProcessName $ProcessName -MapPath $MapPath -CompleteArgs @{
        Params = $true
        Entry  = $Entry
    }
}

function Get-ConfigMapProcessCompleteWord {
    <#
    .SYNOPSIS
        Entry/token completion via '<process> !complete -word <prefix> [-entry …]'.
        Returns $null when unsupported (exit 2 / missing command).
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName,
        [Parameter(Mandatory = $true)]
        [string]$MapPath,
        [AllowEmptyString()]
        [string]$Word = '',
        [string]$Entry,
        [string[]]$RemainingArguments
    )

    $completeArgs = @{ Word = $Word }
    if ($Entry) {
        $completeArgs['Entry'] = $Entry
    }
    if ($RemainingArguments) {
        $completeArgs['RemainingArguments'] = @($RemainingArguments)
    }

    return Invoke-ConfigMapProcessComplete -ProcessName $ProcessName -MapPath $MapPath -CompleteArgs $completeArgs
}

function Test-ConfigMapProcessPositionalOptionKey {
    <#
    .SYNOPSIS
        True when the option key was already supplied positionally (set <option> …).
    #>

    param(
        [string]$ProcessAction,
        [string]$ProcessValue,
        [string[]]$RemainingArguments
    )

    if (-not [string]::IsNullOrWhiteSpace($ProcessValue)) {
        return $true
    }

    $tokens = @(
        @($ProcessAction) + @($RemainingArguments) |
            Where-Object { $null -ne $_ -and "$_" -ne '' }
    )
    return ($tokens.Count -ge 2 -and $tokens[0] -eq 'set')
}

function New-ConfigMapProcessDynamicParam {
    <#
    .SYNOPSIS
        Build dynparams from !complete -params names; type step params from the map when available.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ProcessName,
        [Parameter(Mandatory = $true)]
        [string]$MapPath,
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Map,
        [Parameter(Mandatory = $true)]
        [string]$Entry,
        [switch]$Follow,
        [string]$ProcessAction,
        [string]$ProcessValue,
        [string[]]$RemainingArguments
    )

    $names = Get-ConfigMapProcessCompleteParams -ProcessName $ProcessName -MapPath $MapPath -Entry $Entry
    $typed = Get-ProcessDynamicParam -Map $Map -ProcessKey $Entry -Follow:$Follow
    if (-not $typed) {
        $typed = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
    }

    # Conf-style set scripts (process=qconf) expose opaque params after ctx/key/value.
    $setParams = Get-EntryDynamicParam $Map "$Entry.set" -skip 3 -bound @{}
    if ($setParams) {
        foreach ($name in @($setParams.Keys)) {
            if (-not $typed.ContainsKey($name)) {
                $typed.Add($name, $setParams[$name])
            }
        }
    }

    # Positional `set <option>` already supplied the option key — drop -value entirely
    # when we can see it (runtime). Tab completion often cannot see RemainingArguments in
    # dynamicparam, so -value is also DontShow below and stays typeable but hidden.
    $hideValue = Test-ConfigMapProcessPositionalOptionKey `
        -ProcessAction $ProcessAction `
        -ProcessValue $ProcessValue `
        -RemainingArguments $RemainingArguments

    if ($null -eq $names) {
        if ($hideValue -and $typed.ContainsKey('value')) {
            $typed.Remove('value') | Out-Null
        }
        return $typed
    }

    $merged = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
    foreach ($name in $names) {
        if ($merged.ContainsKey($name)) { continue }
        if ($hideValue -and $name -eq 'value') { continue }

        $parameterType = [switch]
        $source = $null
        if ($typed -and $typed.ContainsKey($name)) {
            $source = $typed[$name]
            $parameterType = $source.ParameterType
        }
        else {
            try {
                $processCommand = Resolve-ConfigMapProcessCommand -ProcessName $ProcessName
                $processParameter = $processCommand.Parameters[$name]
                if ($processParameter -and $processParameter.ParameterType) {
                    $parameterType = $processParameter.ParameterType
                }
            }
            catch {
                # Keep the protocol's backwards-compatible switch fallback.
            }
        }

        # Prefer set <option>; keep -value bindable but out of -<tab> suggestions.
        if ($name -eq 'value') {
            $merged.Add($name, (New-ConfigMapOpaqueDynamicParam -Name $name -ParameterType $parameterType -DontShow))
        }
        elseif ($source) {
            $merged.Add($name, $source)
        }
        else {
            $merged.Add($name, (New-ConfigMapOpaqueDynamicParam -Name $name -ParameterType $parameterType))
        }
    }
    return $merged
}