functions/public/Enter-KlippyConsole.ps1

function Enter-KlippyConsole {
    <#
    .SYNOPSIS
        Enters an interactive G-code console for a Klipper printer.

    .DESCRIPTION
        Opens an interactive REPL-style console for sending G-code commands
        to a Klipper printer. Features include:
        - Tab completion for G-code commands and macros
        - Command history navigation with Up/Down arrows
        - Line editing with Left/Right arrows
        - Real-time command responses via WebSocket
        - Built-in commands for status, temperatures, etc.
        - Colored output (errors in red)

    .PARAMETER Id
        The unique identifier of the printer.

    .PARAMETER PrinterName
        The friendly name of the printer.

    .PARAMETER InputObject
        A printer object from pipeline input.

    .EXAMPLE
        Enter-KlippyConsole
        Opens console for the default printer.

    .EXAMPLE
        Enter-KlippyConsole -PrinterName "voronv2"
        Opens console for the specified printer.

    .EXAMPLE
        Get-KlippyPrinter -PrinterName "voronv2" | Enter-KlippyConsole
        Opens console via pipeline.

    .NOTES
        Built-in commands:
          :exit, :quit, :q - Exit the console
          :help, :h - Show help
          :status, :s - Show printer status
          :temps, :t - Show temperatures
          :pos - Show toolhead position
          :macros - List available macros
          :clear - Clear the screen

    .OUTPUTS
        None. Interactive console session.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ById')]
        [ValidateNotNullOrEmpty()]
        [string]$Id,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByName', Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$PrinterName,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByObject', ValueFromPipeline = $true)]
        [PSCustomObject]$InputObject,

        [Parameter()]
        [switch]$ShowMessages
    )

    begin {
        # Check for PSReadLine module
        if (-not (Get-Module -Name PSReadLine)) {
            try {
                Import-Module PSReadLine -ErrorAction Stop
            }
            catch {
                Write-Error "PSReadLine module is required for the interactive console. Please install it with: Install-Module PSReadLine"
                return
            }
        }
    }

    process {
        # Resolve printer
        $resolveParams = @{}
        switch ($PSCmdlet.ParameterSetName) {
            'ById' { $resolveParams['Id'] = $Id }
            'ByName' { $resolveParams['PrinterName'] = $PrinterName }
            'ByObject' { $resolveParams['InputObject'] = $InputObject }
        }

        $printer = Resolve-KlippyPrinterTarget @resolveParams

        # Store original PSReadLine settings to restore later
        $originalTabHandler = $null
        $originalPrompt = $function:prompt

        try {
            # Test connection first
            Write-Host "Connecting to " -NoNewline
            Write-Host $printer.PrinterName -ForegroundColor Cyan -NoNewline
            Write-Host "..."

            $status = Get-KlippyStatus -Id $printer.Id -ErrorAction Stop
            Write-Host "Connected! " -ForegroundColor Green -NoNewline
            Write-Host "Printer state: " -NoNewline
            $stateColor = switch ($status.State) {
                'ready' { 'Green' }
                'printing' { 'Yellow' }
                'paused' { 'Yellow' }
                'error' { 'Red' }
                default { 'White' }
            }
            Write-Host $status.State -ForegroundColor $stateColor

            # Fetch G-code commands for tab completion
            Write-Host "Loading G-code commands..." -ForegroundColor Gray
            $gcodeCommands = Get-KlippyGcodeCommands -Printer $printer
            Write-Host "Loaded $($gcodeCommands.Count) commands." -ForegroundColor Gray

            # Connect WebSocket for real-time responses
            Write-Host "Establishing WebSocket connection..." -ForegroundColor Gray
            $ws = $null
            try {
                $ws = New-KlippyWebSocketClient -Printer $printer
                if ($ShowMessages) { Write-Host "[DEBUG] WebSocket connected to $($ws.Uri)" -ForegroundColor DarkGray }

                # Identify the connection to Moonraker (required to receive notifications)
                $null = $ws.SendRpc("server.connection.identify", @{
                    client_name = "KlippyCLI"
                    version     = "0.1.0"
                    type        = "other"
                    url         = "https://github.com/darkoperator/KlippyCLI"
                })

                # Wait for identify response
                $identifyResponse = $ws.Receive(5000)
                if ($ShowMessages) {
                    Write-Host "[DEBUG] Identify response: $($identifyResponse | ConvertTo-Json -Compress)" -ForegroundColor DarkGray
                }

                # Subscribe to printer objects (like Mainsail does)
                $null = $ws.SendRpc("printer.objects.subscribe", @{
                    objects = @{
                        gcode_move = $null
                        toolhead = $null
                        print_stats = $null
                    }
                })

                # Drain any pending messages from subscription
                $drainCount = 0
                do {
                    $pending = $ws.Receive(500)
                    if ($pending) {
                        $drainCount++
                        if ($ShowMessages) {
                            $method = if ($pending.method) { $pending.method } else { "rpc-response" }
                            Write-Host "[DEBUG] Drained: $method" -ForegroundColor DarkGray
                        }
                    }
                } while ($pending -and $drainCount -lt 10)

                Write-Host "WebSocket connected." -ForegroundColor Gray
                if ($ShowMessages) { Write-Host "[DEBUG] Drained $drainCount initial messages" -ForegroundColor DarkGray }
            }
            catch {
                Write-Host "WebSocket connection failed: $_" -ForegroundColor Yellow
                Write-Host "Using HTTP fallback." -ForegroundColor Yellow
                $ws = $null
            }

            # Display welcome banner
            Write-Host ""
            Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
            Write-Host "║ KlippyCLI Interactive Console ║" -ForegroundColor Cyan
            Write-Host "║ Printer: " -ForegroundColor Cyan -NoNewline
            Write-Host ("{0,-51}" -f $printer.PrinterName) -ForegroundColor White -NoNewline
            Write-Host "║" -ForegroundColor Cyan
            Write-Host "║ Type G-code commands or :help for built-in commands ║" -ForegroundColor Cyan
            Write-Host "║ Tab = autocomplete, Escape = skip wait, :exit = quit ║" -ForegroundColor Cyan
            Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
            Write-Host ""

            # Console prompt - store in module scope for tab completion access
            $consolePrompt = "[$($printer.PrinterName)]> "

            # Setup tab completion - store commands in module scope for access from key handler
            $script:KlippyConsoleCompletions = @{
                Commands = @($gcodeCommands | ForEach-Object { $_.Name })
                Builtins = @(':exit', ':quit', ':q', ':help', ':h', ':status', ':s', ':temps', ':t', ':pos', ':macros', ':clear')
                Prompt   = $consolePrompt
            }

            Set-PSReadLineKeyHandler -Key Tab -ScriptBlock {
                param($key, $arg)

                $line = $null
                $cursor = $null
                [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)

                # Handle empty line
                if ([string]::IsNullOrEmpty($line) -or $cursor -eq 0) {
                    return
                }

                $word = $line.Substring(0, $cursor).Split(' ')[-1].ToUpper()

                # Get matching completions from module-scoped variable
                $completions = $script:KlippyConsoleCompletions
                if ($null -eq $completions) {
                    return
                }

                $allCommands = @($completions.Commands) + @($completions.Builtins)
                $matches = @($allCommands | Where-Object { $_ -and $_.ToUpper().StartsWith($word) } | Sort-Object)

                if ($matches.Count -eq 0) {
                    return
                }
                elseif ($matches.Count -eq 1) {
                    # Single match - complete it
                    $wordStart = $cursor - $word.Length
                    [Microsoft.PowerShell.PSConsoleReadLine]::Replace($wordStart, $word.Length, $matches[0] + " ")
                }
                else {
                    # Multiple matches - find longest common prefix and show list
                    $commonPrefix = $matches[0]
                    foreach ($m in $matches) {
                        while ($commonPrefix.Length -gt 0 -and -not $m.ToUpper().StartsWith($commonPrefix.ToUpper())) {
                            $commonPrefix = $commonPrefix.Substring(0, $commonPrefix.Length - 1)
                        }
                    }

                    # If we can extend the current word, do it
                    if ($commonPrefix.Length -gt $word.Length) {
                        $wordStart = $cursor - $word.Length
                        [Microsoft.PowerShell.PSConsoleReadLine]::Replace($wordStart, $word.Length, $commonPrefix)
                    }
                    else {
                        # Show matches below the current line
                        Write-Host ""
                        $columns = 4
                        $maxLen = ($matches | Measure-Object -Property Length -Maximum).Maximum + 2
                        $i = 0
                        foreach ($m in $matches) {
                            Write-Host ("{0,-$maxLen}" -f $m) -NoNewline -ForegroundColor Cyan
                            $i++
                            if ($i % $columns -eq 0) { Write-Host "" }
                        }
                        if ($i % $columns -ne 0) { Write-Host "" }

                        # Redraw our custom prompt and current input
                        Write-Host $completions.Prompt -NoNewline -ForegroundColor Yellow
                        Write-Host $line -NoNewline
                    }
                }
            }

            # Main REPL loop
            $exitConsole = $false

            while (-not $exitConsole) {
                try {
                    # Display prompt and get input
                    Write-Host $consolePrompt -NoNewline -ForegroundColor Yellow
                    $inputLine = [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $null)

                    # Skip empty lines
                    if ([string]::IsNullOrWhiteSpace($inputLine)) {
                        continue
                    }

                    $inputLine = $inputLine.Trim()

                    # Check for built-in commands
                    if ($inputLine.StartsWith(':')) {
                        $builtinResult = Invoke-KlippyConsoleBuiltin -Command $inputLine -Printer $printer -GcodeCommands $gcodeCommands

                        if ($builtinResult.Exit) {
                            $exitConsole = $true
                            continue
                        }

                        if ($builtinResult.Output) {
                            Write-Host $builtinResult.Output
                        }
                        continue
                    }

                    # Execute G-code command
                    try {
                        $useHttp = $true

                        # Check WebSocket state and attempt reconnection if needed
                        if ($ShowMessages) {
                            $wsState = if ($ws) { $ws.WebSocket.State } else { "null" }
                            $wsConnected = if ($ws) { $ws.IsConnected } else { "null" }
                            Write-Host "[DEBUG] WS check: ws=$($null -ne $ws) IsConnected=$wsConnected State=$wsState" -ForegroundColor DarkGray
                        }

                        # Attempt to reconnect if WebSocket was disconnected or nulled (idle timeout, error, etc.)
                        $needsReconnect = ($null -eq $ws) -or
                                          (-not $ws.IsConnected) -or
                                          ($ws.WebSocket.State -ne [System.Net.WebSockets.WebSocketState]::Open)

                        if ($needsReconnect) {
                            if ($ShowMessages) {
                                Write-Host "[DEBUG] WebSocket needs reconnect (ws=$($null -ne $ws))" -ForegroundColor DarkGray
                            }
                            # Clean up old connection if exists
                            if ($ws) {
                                try {
                                    $ws.WebSocket.Dispose()
                                }
                                catch { }
                                $ws = $null
                            }

                            try {
                                $ws = New-KlippyWebSocketClient -Printer $printer
                                $null = $ws.SendRpc("server.connection.identify", @{
                                    client_name = "KlippyCLI"
                                    version     = "0.1.0"
                                    type        = "other"
                                    url         = "https://github.com/darkoperator/KlippyCLI"
                                })
                                $null = $ws.Receive(5000)  # Wait for identify response

                                # Re-subscribe to printer objects for status updates
                                $null = $ws.SendRpc("printer.objects.subscribe", @{
                                    objects = @{
                                        gcode_move  = $null
                                        toolhead    = $null
                                        print_stats = $null
                                    }
                                })
                                # Drain subscription response and any pending messages
                                $drainCount = 0
                                do {
                                    $pending = $ws.Receive(200)
                                    if ($pending) { $drainCount++ }
                                } while ($pending -and $drainCount -lt 5)

                                if ($ShowMessages) {
                                    Write-Host "[DEBUG] WebSocket reconnected successfully (drained $drainCount msgs)" -ForegroundColor DarkGray
                                }
                            }
                            catch {
                                if ($ShowMessages) {
                                    Write-Host "[DEBUG] WebSocket reconnect failed: $_" -ForegroundColor DarkGray
                                }
                                $ws = $null
                            }
                        }

                        # Debug: show state after potential reconnection
                        if ($ShowMessages) {
                            $wsState2 = if ($ws) { $ws.WebSocket.State } else { "null" }
                            $wsConnected2 = if ($ws) { $ws.IsConnected } else { "null" }
                            Write-Host "[DEBUG] After reconnect check: ws=$($null -ne $ws) IsConnected=$wsConnected2 State=$wsState2" -ForegroundColor DarkGray
                        }

                        # Try WebSocket if available and open
                        if ($ws -and $ws.IsConnected -and $ws.WebSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
                            if ($ShowMessages) {
                                Write-Host "[DEBUG] Sending via WebSocket: $inputLine" -ForegroundColor DarkGray
                            }
                            try {
                                # Fire-and-forget pattern (like Mainsail) - send command without waiting for RPC response
                                $null = $ws.SendRpc("printer.gcode.script", @{ script = $inputLine })

                                # Async message handling - display messages as they arrive
                                # User can press Escape to return to prompt early (like Mainsail's always-available input)
                                $commandComplete = $false
                                $overallTimeout = [datetime]::UtcNow.AddMinutes(10)

                                while (-not $commandComplete -and [datetime]::UtcNow -lt $overallTimeout) {
                                    # Check for user interrupt (Escape key) - allows user to "take control" like Mainsail
                                    if ([Console]::KeyAvailable) {
                                        $key = [Console]::ReadKey($true)
                                        if ($key.Key -eq [ConsoleKey]::Escape) {
                                            Write-Host ""
                                            Write-Host "// Returned to prompt (messages may still arrive)" -ForegroundColor DarkGray
                                            $commandComplete = $true
                                            continue
                                        }
                                    }

                                    # Non-blocking receive with short timeout
                                    $message = $ws.Receive(500)

                                    if ($null -ne $message) {
                                        # Debug: show message method
                                        if ($ShowMessages) {
                                            $method = if ($message.method) { $message.method } elseif ($message.result) { "rpc-result" } elseif ($message.error) { "rpc-error" } else { "unknown" }
                                            # Only show non-status updates in detail to reduce noise
                                            if ($method -eq 'notify_gcode_response') {
                                                Write-Host "[DEBUG] GCODE_RESPONSE: $($message.params[0])" -ForegroundColor Magenta
                                            }
                                            elseif ($method -notin @('notify_status_update', 'notify_proc_stat_update')) {
                                                $msgJson = $message | ConvertTo-Json -Compress -Depth 3
                                                if ($msgJson.Length -gt 200) { $msgJson = $msgJson.Substring(0, 200) + "..." }
                                                Write-Host "[DEBUG] $method : $msgJson" -ForegroundColor DarkGray
                                            }
                                        }

                                        if ($message.error) {
                                            # RPC error (e.g., invalid command syntax)
                                            Write-Host "!! $($message.error.message)" -ForegroundColor Red
                                            $commandComplete = $true
                                        }
                                        elseif ($message.result) {
                                            # RPC result - command completed successfully
                                            Write-Host "ok" -ForegroundColor Green
                                            $commandComplete = $true
                                        }
                                        elseif ($message.method -eq 'notify_gcode_response') {
                                            # G-code response - display immediately (async notification)
                                            $responseText = $message.params[0]

                                            if ($responseText -match '^!!') {
                                                Write-Host $responseText -ForegroundColor Red
                                                $commandComplete = $true
                                            }
                                            elseif ($responseText -match '^//') {
                                                Write-Host $responseText -ForegroundColor Yellow
                                            }
                                            elseif ($responseText -eq 'ok') {
                                                # Some commands send 'ok' via notification too - ignore if we'll get RPC result
                                            }
                                            else {
                                                Write-Host $responseText -ForegroundColor Cyan
                                            }
                                        }
                                        # Silently ignore other notifications (status updates, etc.)
                                    }
                                }

                                if (-not $commandComplete) {
                                    Write-Host "// Command timed out (10 min)" -ForegroundColor Yellow
                                }

                                $useHttp = $false
                            }
                            catch {
                                $wsError = $_.Exception.Message
                                if ([string]::IsNullOrEmpty($wsError)) {
                                    $wsError = $_.Exception.GetType().Name
                                }
                                Write-Host "// WebSocket error: $wsError" -ForegroundColor Yellow
                                if ($ShowMessages) {
                                    Write-Host "[DEBUG] Full exception: $_" -ForegroundColor DarkGray
                                }
                                Write-Host "// Falling back to HTTP for this command" -ForegroundColor Yellow
                                # Mark as disconnected - reconnection logic will handle next command
                                if ($ws) {
                                    $ws.IsConnected = $false
                                }
                            }
                        }

                        # Fallback to HTTP (blocking, but necessary when WebSocket unavailable)
                        if ($useHttp) {
                            if ($ShowMessages) {
                                Write-Host "[DEBUG] Using HTTP fallback for: $inputLine" -ForegroundColor DarkGray
                            }
                            $encodedScript = [System.Uri]::EscapeDataString($inputLine)
                            $endpoint = "printer/gcode/script?script=$encodedScript"

                            try {
                                $null = Invoke-KlippyJsonRpc -Printer $printer -Method $endpoint -Timeout 600
                                Write-Host "ok" -ForegroundColor Green
                            }
                            catch {
                                $httpError = $_.Exception.Message
                                if ($httpError -match 'Klipper.*?:\s*(.+)$') {
                                    $httpError = $Matches[1]
                                }
                                Write-Host "!! $httpError" -ForegroundColor Red
                            }
                        }
                    }
                    catch {
                        $errorMsg = $_.Exception.Message
                        if ($errorMsg -match 'Klipper.*?:\s*(.+)$') {
                            $errorMsg = $Matches[1]
                        }
                        Write-Host "!! $errorMsg" -ForegroundColor Red
                    }
                }
                catch [System.Management.Automation.PipelineStoppedException] {
                    # Ctrl+C pressed
                    Write-Host ""
                    Write-Host "// Use :exit or :q to quit the console" -ForegroundColor Yellow
                }
                catch {
                    Write-Host "!! Error: $_" -ForegroundColor Red
                }
            }

            Write-Host ""
            Write-Host "Goodbye!" -ForegroundColor Cyan
        }
        catch {
            Write-Error "Console error: $_"
        }
        finally {
            # Cleanup
            if ($ws) {
                try {
                    $ws.Close()
                    Write-Verbose "WebSocket closed"
                }
                catch {
                    Write-Verbose "WebSocket close error: $_"
                }
            }

            # Reset PSReadLine Tab handler
            try {
                Set-PSReadLineKeyHandler -Key Tab -Function TabCompleteNext
            }
            catch {
                # Ignore
            }

            # Clean up module-scoped completion data
            $script:KlippyConsoleCompletions = $null
        }
    }
}

function Invoke-KlippyConsoleBuiltin {
    <#
    .SYNOPSIS
        Handles built-in console commands.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Command,

        [Parameter(Mandatory)]
        [PSCustomObject]$Printer,

        [Parameter()]
        [array]$GcodeCommands
    )

    $result = @{
        Exit   = $false
        Output = $null
    }

    $cmd = $Command.ToLower().Trim()

    switch -Regex ($cmd) {
        '^:(exit|quit|q)$' {
            $result.Exit = $true
        }

        '^:(help|h)$' {
            $helpText = @"

  KlippyCLI Console Commands
  ══════════════════════════
  :exit, :quit, :q Exit the console
  :help, :h Show this help
  :status, :s Show printer status
  :temps, :t Show temperatures
  :pos Show toolhead position
  :macros List available macros
  :clear Clear the screen

  G-code commands are sent directly to Klipper.
  Press Tab for auto-completion.
  Press Escape during a command to return to prompt early.

"@

            $result.Output = $helpText
        }

        '^:(status|s)$' {
            try {
                $status = Get-KlippyStatus -Id $Printer.Id
                $output = [System.Text.StringBuilder]::new()
                $output.AppendLine("") | Out-Null
                $output.AppendLine(" Printer Status") | Out-Null
                $output.AppendLine(" ══════════════") | Out-Null
                $output.AppendLine(" State: $($status.State)") | Out-Null
                $output.AppendLine(" Filename: $($status.Filename ?? 'None')") | Out-Null
                $output.AppendLine(" Progress: $($status.Progress)%") | Out-Null
                $output.AppendLine("") | Out-Null
                $result.Output = $output.ToString()
            }
            catch {
                $result.Output = "!! Failed to get status: $_"
            }
        }

        '^:(temps|t)$' {
            try {
                $temps = Get-KlippyObject -Id $Printer.Id -ObjectName "heater_bed", "extruder"
                $output = [System.Text.StringBuilder]::new()
                $output.AppendLine("") | Out-Null
                $output.AppendLine(" Temperatures") | Out-Null
                $output.AppendLine(" ════════════") | Out-Null
                if ($temps.Extruder) {
                    $output.AppendLine(" Extruder: $([Math]::Round($temps.Extruder.Temperature, 1))°C / $($temps.Extruder.Target)°C") | Out-Null
                }
                if ($temps.HeaterBed) {
                    $output.AppendLine(" Bed: $([Math]::Round($temps.HeaterBed.Temperature, 1))°C / $($temps.HeaterBed.Target)°C") | Out-Null
                }
                $output.AppendLine("") | Out-Null
                $result.Output = $output.ToString()
            }
            catch {
                $result.Output = "!! Failed to get temperatures: $_"
            }
        }

        '^:pos$' {
            try {
                $toolhead = Get-KlippyObject -Id $Printer.Id -ObjectName "toolhead"
                $pos = $toolhead.Position
                $output = [System.Text.StringBuilder]::new()
                $output.AppendLine("") | Out-Null
                $output.AppendLine(" Toolhead Position") | Out-Null
                $output.AppendLine(" ═════════════════") | Out-Null
                if ($pos) {
                    $output.AppendLine(" X: $([Math]::Round($pos[0], 3)) mm") | Out-Null
                    $output.AppendLine(" Y: $([Math]::Round($pos[1], 3)) mm") | Out-Null
                    $output.AppendLine(" Z: $([Math]::Round($pos[2], 3)) mm") | Out-Null
                    $output.AppendLine(" E: $([Math]::Round($pos[3], 3)) mm") | Out-Null
                }
                $output.AppendLine(" Homed: $($toolhead.HomedAxes ?? 'unknown')") | Out-Null
                $output.AppendLine("") | Out-Null
                $result.Output = $output.ToString()
            }
            catch {
                $result.Output = "!! Failed to get position: $_"
            }
        }

        '^:macros$' {
            $macros = $GcodeCommands | Where-Object { $_.CommandType -eq 'Macro' } | Sort-Object Name
            $output = [System.Text.StringBuilder]::new()
            $output.AppendLine("") | Out-Null
            $output.AppendLine(" Available Macros ($($macros.Count))") | Out-Null
            $output.AppendLine(" ═══════════════════════════") | Out-Null
            foreach ($macro in $macros) {
                $output.AppendLine(" $($macro.Name)") | Out-Null
            }
            $output.AppendLine("") | Out-Null
            $result.Output = $output.ToString()
        }

        '^:clear$' {
            Clear-Host
        }

        default {
            $result.Output = "!! Unknown command: $Command. Type :help for available commands."
        }
    }

    return $result
}