src/qrun/qrun.ps1

. "$PSScriptRoot\process-state.ps1"

function Invoke-QRun {
    [CmdletBinding()]
    param(
        [ArgumentCompleter({
                param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                try {
                    $mapPath = if ($fakeBoundParameters.map) { $fakeBoundParameters.map } else { "./.process.map.ps1" }
                    $localMapExists = Test-Path $mapPath

                    $mapNotFound = $false
                    $map = try {
                        Import-ConfigMap -Map $fakeBoundParameters.map -Fallback "./.process.map.ps1" -ErrorAction Stop
                    }
                    catch {
                        if ($_.Exception.Message -match '^map file .* not found$|^No map provided and fallback .* not found$|^map is null and defaultMapFile is not provided$') {
                            $mapNotFound = $true
                            $null
                        }
                        else {
                            throw
                        }
                    }
                    if ($mapNotFound) {
                        return @("!init", "!state", "!complete", "help", "list") | ? { $_.startswith($wordToComplete) }
                    }
                    $map = $map | Assert-ConfigMap

                    $completions = Get-EntryCompletion $map -language "build" @PSBoundParameters
                    if (!$localMapExists) {
                        $completions = @("!init" | ? { $_.startswith($wordToComplete) }) + $completions
                    }
                    return @(@("!state", "!complete", "help", "list") | ? { $_.startswith($wordToComplete) }) + $completions
                }
                catch {
                    return "ERROR [-command]: $($_.Exception.Message) $($_.ScriptStackTrace)"
                }
            })]
        # Positional verb/path (list, help, !complete, bootstrap, …). Named -entry is separate
        # so `qrun !complete -params -entry bootstrap` does not double-bind.
        [Parameter(Position = 0)]
        $command = $null,

        # Process target for run (`qrun -entry bootstrap`) and !complete subject.
        [Parameter()]
        [ArgumentCompleter({
                param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                try {
                    $map = Import-ConfigMap -Map $fakeBoundParameters.map -Fallback "./.process.map.ps1" -ErrorAction Stop | Assert-ConfigMap
                    return @(Get-EntryCompletion $map -language "build" -wordToComplete $wordToComplete)
                }
                catch {
                    return @()
                }
            })]
        [string]$entry = $null,

        [switch]$Follow,
        [switch]$Force,
        [switch]$Status,
        [switch]$Reset,

        # Machine-readable !complete protocol (DontShow — used by qbuild / tooling)
        [Parameter(DontShow)]
        [string]$Word,
        [Parameter(DontShow)]
        [switch]$Params,

        [Parameter(DontShow)]
        $map = "./.process.map.ps1",

        [Parameter(ValueFromRemainingArguments = $true, DontShow = $true)]
        [string[]]$RemainingArguments
    )
    dynamicparam {
        $dynEntry = if ($entry) { $entry } elseif ($command -and $command -notin @('help', 'list', '!init', '!state', '!complete')) { $command } else { $null }
        if (-not $dynEntry) {
            return
        }
        try {
            $dynMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" | Assert-ConfigMap
            return Get-ProcessDynamicParam -Map $dynMap -ProcessKey $dynEntry -Follow:$Follow
        }
        catch {
            return
        }
    }

    process {
        $qrunVerbs = @('help', 'list', '!init', '!state', '!complete')
        $verb = $null
        $processEntry = $null
        if ($command -in $qrunVerbs) {
            $verb = $command
        }
        elseif ($entry) {
            $processEntry = $entry
        }
        elseif ($command) {
            $processEntry = [string]$command
        }

        if ($verb -eq "help") {
            Write-Host ""
            Write-Host "QRUN" -ForegroundColor Cyan
            Write-Host "Run multi-step processes with durable step state" -ForegroundColor Gray
            Write-Host ""
            Write-Host "USAGE:" -ForegroundColor Yellow
            Write-Host " qrun <process> [-Force] [-Status] [-Reset]" -ForegroundColor White
            Write-Host " qrun <process>.<step> [-Follow] [-Force]" -ForegroundColor White
            Write-Host " qrun !state [show|clean] [<process>]" -ForegroundColor White
            Write-Host " qrun list [<process>]" -ForegroundColor White
            Write-Host ""
            Write-Host "OPTIONS:" -ForegroundColor Yellow
            Write-Host " -Follow With a step path, also run all following steps" -ForegroundColor White
            Write-Host " -Force Rerun succeeded steps; also allow starting mid-process when prior steps are incomplete" -ForegroundColor White
            Write-Host " -Status Show persisted step statuses without running" -ForegroundColor White
            Write-Host " -Reset Clear persisted state for the process" -ForegroundColor White
            Write-Host ""
            Write-Host "STATE:" -ForegroundColor Yellow
            Write-Host " qrun Show process state (same as !state)" -ForegroundColor White
            Write-Host " qrun !state Show all process state (alias for !state show)" -ForegroundColor White
            Write-Host " qrun !state show [name] Show state for all processes, or one process" -ForegroundColor White
            Write-Host " qrun !state clean [name] Clean all process state, or one process" -ForegroundColor White
            Write-Host ""
            Write-Host "Dot paths work like qbuild: 'qrun bootstrap.prepare' runs that step; add -Follow to continue." -ForegroundColor Gray
            Write-Host ""
            return
        }

        if ($verb -eq "!complete") {
            $completeArgs = @{
                Mode  = $null
                Word  = $Word
                Entry = $entry
            }
            if ($Params) { $completeArgs.Mode = 'params' }
            elseif ($PSBoundParameters.ContainsKey('Word')) { $completeArgs.Mode = 'word' }

            $tokens = @($RemainingArguments | Where-Object { $null -ne $_ -and "$_" -ne '' })
            for ($i = 0; $i -lt $tokens.Count; $i++) {
                switch -Regex ($tokens[$i]) {
                    '^-params$' {
                        $completeArgs.Mode = 'params'
                    }
                    '^-word$' {
                        if ($i + 1 -lt $tokens.Count) {
                            $completeArgs.Word = $tokens[++$i]
                            if (-not $completeArgs.Mode) { $completeArgs.Mode = 'word' }
                        }
                    }
                    '^-entry$' {
                        if ($i + 1 -lt $tokens.Count) {
                            $completeArgs.Entry = $tokens[++$i]
                        }
                    }
                    default {
                        if (-not $completeArgs.Entry) {
                            $completeArgs.Entry = $tokens[$i]
                        }
                    }
                }
            }

            if (-not $completeArgs.Mode) {
                $global:LASTEXITCODE = 2
                return
            }

            $completeMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" -ErrorAction Ignore
            if (!$completeMap) {
                $global:LASTEXITCODE = 2
                return
            }

            if ($completeArgs.Mode -eq 'params') {
                if (-not $completeArgs.Entry) {
                    $global:LASTEXITCODE = 2
                    return
                }

                $names = [System.Collections.Generic.List[string]]::new()
                foreach ($switchName in @('Follow', 'Force', 'Status', 'Reset')) {
                    $names.Add($switchName) | Out-Null
                }

                $followHint = $false
                $typed = Get-ProcessDynamicParam -Map $completeMap -ProcessKey $completeArgs.Entry -Follow:$followHint
                if ($typed) {
                    foreach ($key in @($typed.Keys)) {
                        if (-not $names.Contains($key)) {
                            $names.Add($key) | Out-Null
                        }
                    }
                }

                $names | ForEach-Object { $_ }
                $global:LASTEXITCODE = 0
                return
            }

            # -word: entry / token completion
            $prefix = if ($null -ne $completeArgs.Word) { [string]$completeArgs.Word } else { '' }
            $completions = @(Get-EntryCompletion -map $completeMap -language build -wordToComplete $prefix)
            $completions | ForEach-Object { $_ }
            $global:LASTEXITCODE = 0
            return
        }

        if ($verb -eq "list") {
            $listMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1"
            if (!$listMap) {
                Write-Help -invocation $MyInvocation -mapPath "./.process.map.ps1"
                return
            }

            $listEntry = @($RemainingArguments | Where-Object { $null -ne $_ -and "$_" -ne '' } | Select-Object -First 1)
            if ($listEntry.Count -eq 1) {
                $operationContext = New-ConfigMapOperationContext
                try {
                    $target = Resolve-ProcessTarget -Map $listMap -Entry $listEntry[0] -OperationContext $operationContext
                }
                catch {
                    throw "Entry '$($listEntry[0])' not found. Run 'qrun list' to see all available commands."
                }

                $processKey = $target.ProcessKey
                $listedEntry = $target.ProcessEntry
                $stepNames = @($target.StepNames)
                $baseDir = if ($listedEntry._baseDir) { $listedEntry._baseDir } elseif ($listMap._baseDir) { $listMap._baseDir } else { (Get-Location).Path }
                $sourceFile = if ($listMap._sourceFile) { [string]$listMap._sourceFile } else { $null }
                $statePath = Get-ProcessStatePath -ProcessKey $processKey -BaseDir $baseDir -MapFile $sourceFile
                $state = Read-ProcessState -StatePath $statePath -ProcessKey $processKey -StepNames $stepNames -MapFile $sourceFile
                $description = if ($listedEntry.description) { [string]$listedEntry.description } else { '' }

                Write-ProcessStatus `
                    -ProcessKey $processKey `
                    -StepNames $stepNames `
                    -State $state `
                    -Description $description
                return
            }

            Write-MapHelp -map $listMap -invocation $MyInvocation -language "build"
            return
        }

        if ($verb -eq "!init") {
            if (Test-Path $map) {
                throw "map file '$map' already exists"
            }
            Initialize-ProcessMap -file $map
            return
        }

        if (-not $processEntry -or $verb -eq "!state") {
            $stateArgs = @($RemainingArguments | Where-Object { $_ })
            $action = 'show'
            $processFilter = $null

            if ($verb -eq "!state" -and $stateArgs.Count -ge 1) {
                if ($stateArgs[0] -in @('show', 'clean')) {
                    $action = $stateArgs[0]
                    if ($stateArgs.Count -ge 2) {
                        $processFilter = $stateArgs[1]
                    }
                    if ($stateArgs.Count -gt 2) {
                        throw "!state $action accepts at most one process name."
                    }
                }
                else {
                    throw "!state expects 'show' or 'clean', got '$($stateArgs[0])'. Try: qrun !state show"
                }
            }

            $stateMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" -ErrorAction Ignore
            if (!$stateMap) {
                Write-Help -invocation $MyInvocation -mapPath "./.process.map.ps1"
                return
            }

            $baseDir = if ($stateMap._baseDir) { $stateMap._baseDir } else { (Get-Location).Path }
            $sourceFile = if ($stateMap._sourceFile) { [string]$stateMap._sourceFile } else { $null }
            $operationContext = New-ConfigMapOperationContext

            Invoke-ProcessStateCommand `
                -Action $action `
                -ProcessKey $processFilter `
                -Map $stateMap `
                -BaseDir $baseDir `
                -MapFile $sourceFile `
                -OperationContext $operationContext
            return
        }

        $mapPath = $map
        $map = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" -ErrorAction Ignore
        if (!$map) {
            Write-Help -invocation $MyInvocation -mapPath "./.process.map.ps1"
            return
        }

        $operationContext = New-ConfigMapOperationContext
        $target = Resolve-ProcessTarget -Map $map -Entry $processEntry -OperationContext $operationContext
        $processKey = $target.ProcessKey
        $processMapEntry = $target.ProcessEntry
        $stepNames = @($target.StepNames)
        $fromStep = $target.ImpliedStep

        if ($Follow -and -not $fromStep) {
            throw "-Follow requires a step path (e.g. qrun bootstrap.restore -Follow)"
        }

        $baseDir = if ($processMapEntry._baseDir) { $processMapEntry._baseDir } elseif ($map._baseDir) { $map._baseDir } else { (Get-Location).Path }
        $sourceFile = if ($map._sourceFile) { [string]$map._sourceFile } else { $null }
        $statePath = Get-ProcessStatePath -ProcessKey $processKey -BaseDir $baseDir -MapFile $sourceFile

        if ($Reset) {
            Reset-ProcessState -StatePath $statePath
            Write-Host "Reset process state for '$processKey'" -ForegroundColor Yellow
            if (-not $Status -and -not $fromStep -and -not $Follow -and -not $Force) {
                return
            }
        }

        $state = Read-ProcessState -StatePath $statePath -ProcessKey $processKey -StepNames $stepNames -MapFile $sourceFile

        if ($Status) {
            Write-ProcessStatus -ProcessKey $processKey -StepNames $stepNames -State $state
            return
        }

        $skippedPrior = @()
        if ($fromStep) {
            $skippedPrior = @(Assert-ProcessPriorSteps -StepNames $stepNames -FromStep $fromStep -State $state -Force:$Force)
        }

        $stepsToConsider = Get-ProcessStepsToRun -StepNames $stepNames -FromStep $fromStep -Follow:$Follow
        $total = $stepNames.Count

        Write-Host ""
        Write-Host "PROCESS " -NoNewline -ForegroundColor Cyan
        Write-Host $processKey -ForegroundColor White
        if ($fromStep) {
            $rangeLabel = if ($Follow) { "$fromStep …" } else { $fromStep }
            Write-Host "Step: $rangeLabel" -ForegroundColor DarkGray
        }
        Write-Host "State: $statePath" -ForegroundColor DarkGray
        Write-Host ""

        foreach ($priorName in $skippedPrior) {
            $priorIndex = [array]::IndexOf($stepNames, $priorName) + 1
            $priorStatus = if ($state.steps[$priorName]) { [string]$state.steps[$priorName].status } else { 'Pending' }
            Write-Host "[$priorIndex/$total] " -NoNewline -ForegroundColor DarkGray
            Write-Host "SKIP" -NoNewline -ForegroundColor DarkYellow
            Write-Host " $priorName" -NoNewline -ForegroundColor White
            Write-Host " (forced; was $priorStatus)" -ForegroundColor DarkGray
        }

        $bound = @{}
        foreach ($key in $PSBoundParameters.Keys) {
            if ($key -in @('command', 'entry', 'Follow', 'Force', 'Status', 'Reset', 'map', 'RemainingArguments', 'Word', 'Params')) {
                continue
            }
            $bound[$key] = $PSBoundParameters[$key]
        }
        $passthrough = @($RemainingArguments) | Where-Object { $null -ne $_ }
        if ($passthrough.Count -gt 0) {
            $bound._context = @{ additionalArgs = @($passthrough) }
        }

        $ran = 0
        $skipped = 0

        foreach ($stepName in $stepsToConsider) {
            $stepIndex = [array]::IndexOf($stepNames, $stepName) + 1
            $prefix = "[$stepIndex/$total]"
            $stepStatus = if ($state.steps[$stepName]) { [string]$state.steps[$stepName].status } else { 'Pending' }

            if ($stepStatus -eq 'Succeeded' -and -not $Force) {
                Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
                Write-Host "SKIP" -NoNewline -ForegroundColor DarkCyan
                Write-Host " $stepName" -NoNewline -ForegroundColor White
                Write-Host " (already succeeded)" -ForegroundColor DarkGray
                $skipped++
                continue
            }

            Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
            Write-Host "RUN " -NoNewline -ForegroundColor Yellow
            Write-Host " $stepName..." -ForegroundColor White

            $state.steps[$stepName] = [ordered]@{
                status     = 'Running'
                startedAt  = (Get-Date).ToString('o')
                finishedAt = $null
                error      = $null
            }
            Write-ProcessState -StatePath $statePath -State $state

            $stepEntry = Get-ProcessStepEntry -ProcessEntry $processMapEntry -StepName $stepName
            try {
                Push-Location $baseDir
                try {
                    $settingsEntry = if ($stepEntry -is [System.Collections.IDictionary]) { $stepEntry } else { $processMapEntry }
                    Invoke-WithEntrySettings -Map $map -EntryKey "$processKey.$stepName" -Entry $settingsEntry -OperationContext $operationContext -ScriptBlock {
                        Invoke-EntryCommand -entry $stepEntry -key "exec" -bound $bound
                    }
                }
                finally {
                    Pop-Location
                }

                $state.steps[$stepName] = [ordered]@{
                    status     = 'Succeeded'
                    startedAt  = $state.steps[$stepName].startedAt
                    finishedAt = (Get-Date).ToString('o')
                    error      = $null
                }
                Write-ProcessState -StatePath $statePath -State $state

                Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
                Write-Host "OK " -NoNewline -ForegroundColor Green
                Write-Host " $stepName" -ForegroundColor White
                $ran++
            }
            catch {
                $errMsg = $_.Exception.Message
                $state.steps[$stepName] = [ordered]@{
                    status     = 'Failed'
                    startedAt  = $state.steps[$stepName].startedAt
                    finishedAt = (Get-Date).ToString('o')
                    error      = $errMsg
                }
                Write-ProcessState -StatePath $statePath -State $state

                Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
                Write-Host "FAIL" -NoNewline -ForegroundColor Red
                Write-Host " $stepName" -ForegroundColor White
                Write-Host " $errMsg" -ForegroundColor DarkRed
                throw
            }
        }

        Write-Host ""
        Write-Host "Process '$processKey' finished" -NoNewline -ForegroundColor Green
        Write-Host " (ran: $ran, skipped: $skipped)" -ForegroundColor DarkGray
        Write-Host ""
    }
}

Set-Alias -Name "qrun" -Value "Invoke-QRun" -Force