PSInquirer.psm1

#Requires -Version 5.1

<#
.SYNOPSIS
    PSInquirer – interactive CLI prompts for PowerShell (5.1 and 7+).
 
.DESCRIPTION
    Provides beautiful, interactive CLI prompts that work in both Windows
    PowerShell 5.1 (powershell.exe) and PowerShell Core 7+ (pwsh.exe) on
    Windows, macOS, and Linux. No external dependencies; only
    [System.Console] methods are used for low-level cursor/key handling.
#>


# ── internal helpers (not exported) ───────────────────────────────────────────

function Get-PSMajorVersion {
    # Thin wrapper so tests can mock the PS version check.
    return $PSVersionTable.PSVersion.Major
}

function Get-VTSupport {
    # Returns $true when the current environment supports VT/ANSI escape sequences.
    # PS 7+ always supports VT; PS 5.1 depends on terminal hints.
    if ((Get-PSMajorVersion) -ge 6) { return $true }
    if ($env:ConEmuANSI -eq 'ON')   { return $true }
    if ($env:TERM -match 'xterm|vt100|ansi') { return $true }
    return $false
}

function Read-ConsoleKey {
    # Thin wrapper around [Console]::ReadKey so tests can mock key input.
    return [Console]::ReadKey($true)
}

function Get-ConsoleWidth {
    # Thin wrapper around [Console]::WindowWidth so tests can mock the console width.
    # Returns a safe default when no console is attached (e.g. in CI/headless mode).
    try { return [Console]::WindowWidth } catch { return 80 }
}

function Get-ConsoleCursorTop {
    # Thin wrapper around [Console]::CursorTop so tests can mock the initial row.
    # Returns a safe default when no console is attached (e.g. in CI/headless mode).
    try { return [Console]::CursorTop } catch { return 0 }
}

function Get-ConsoleBufferHeight {
    # Thin wrapper around [Console]::BufferHeight so tests can mock a small buffer.
    # Returns a safe default when no console is attached (e.g. in CI/headless mode).
    try { return [Console]::BufferHeight } catch { return 50 }
}

function Write-MenuRow {
    <#
    .SYNOPSIS
        Renders a single choice row in the interactive menu.
    .PARAMETER Text
        The choice label to display.
    .PARAMETER Selected
        When $true the row is highlighted with a '>' pointer.
    .PARAMETER ConsoleWidth
        Current console window width; used to pad lines and prevent ghost text.
    .PARAMETER SupportsVT
        When $true uses ANSI/VT escape sequences for colour; otherwise falls
        back to Write-Host -ForegroundColor (PS 5.1 legacy consoles).
    #>

    param (
        [string] $Text,
        [bool]   $Selected,
        [int]    $ConsoleWidth,
        [bool]   $SupportsVT
    )

    # Build the full-width line so overwriting old content leaves no ghost text.
    # Pointer prefix: "> " for selected, " " for unselected.
    if ($Selected) { $prefix = '> ' } else { $prefix = ' ' }

    $line = "$prefix$Text"

    # Pad to (ConsoleWidth - 1) to avoid triggering an auto-wrap on the last
    # column, which would push the next row down unexpectedly on some hosts.
    $padWidth = [Math]::Max(0, $ConsoleWidth - 1)
    $line = $line.PadRight($padWidth)

    if ($Selected) {
        if ($SupportsVT) {
            # Bright Cyan via ANSI escape (always works in PS 7+; works in PS 5.1
            # when the terminal supports VT sequences).
            try { [Console]::Write("`e[96m$line`e[0m") } catch { }
        }
        else {
            # Fallback for PS 5.1 on legacy Windows consoles without VT support.
            Write-Host $line -ForegroundColor Cyan -NoNewline
        }
    }
    else {
        try { [Console]::Write($line) } catch { }
    }
}

# ── public function ────────────────────────────────────────────────────────────

function Invoke-PromptList {
    <#
    .SYNOPSIS
        Displays an interactive single-choice menu and returns the selected item.
 
    .DESCRIPTION
        Renders a question followed by a navigable list of choices. The user
        moves the highlighted cursor with UpArrow / DownArrow and confirms with
        Enter. When Enter is pressed the menu is erased and the final answer is
        printed inline next to the original question; the chosen string is also
        written to the pipeline.
 
    .PARAMETER Message
        The question / prompt text shown above the choices.
 
    .PARAMETER Choices
        An array of strings representing the available options.
 
    .OUTPUTS
        System.String – the item that was selected by the user.
 
    .EXAMPLE
        $color = Invoke-PromptList -Message "What is your favorite color?" -Choices @("Red","Green","Blue","Cyan")
        Write-Host "You chose: $color"
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [string] $Message,

        [Parameter(Mandatory)]
        [string[]] $Choices
    )

    # Determine colour strategy once up front so every redraw is consistent.
    $supportsVT    = Get-VTSupport
    $selectedIndex = 0

    # Snapshot console geometry before drawing; used for cursor math on redraws.
    $consoleWidth = Get-ConsoleWidth

    # Total lines the menu occupies: 1 question line + one row per choice.
    $totalLines = 1 + $Choices.Count

    # Remember the row where the prompt begins so we can return to it when
    # redrawing or clearing the menu.
    $menuStartRow = Get-ConsoleCursorTop

    # Ensure there is enough room in the scroll buffer below the current cursor.
    # If the menu would overflow the bottom of the buffer, pre-scroll by emitting
    # blank lines so that every menu row has a valid, addressable position.
    $bufferHeight = Get-ConsoleBufferHeight
    $neededBottom = $menuStartRow + $totalLines
    if ($neededBottom -gt $bufferHeight) {
        $scrollBy = $neededBottom - $bufferHeight
        for ($lineIndex = 0; $lineIndex -lt $scrollBy; $lineIndex++) { try { [Console]::WriteLine() } catch { } }
        $menuStartRow = [Math]::Max(0, $bufferHeight - $totalLines)
    }

    try {
        # Hide the cursor to prevent flickering while we redraw.
        try { [Console]::CursorVisible = $false } catch { }

        # ── initial draw ───────────────────────────────────────────────────────

        # Question line "? <message>"
        try { [Console]::SetCursorPosition(0, $menuStartRow) } catch { }
        Write-Host "? $Message" -NoNewline
        try { [Console]::WriteLine() } catch { }

        # Render each choice; the currently selected item is highlighted.
        for ($i = 0; $i -lt $Choices.Count; $i++) {
            Write-MenuRow -Text $Choices[$i] -Selected ($i -eq $selectedIndex) `
                          -ConsoleWidth $consoleWidth -SupportsVT $supportsVT
            try { [Console]::WriteLine() } catch { }
        }

        # ── interaction loop ───────────────────────────────────────────────────

        $done = $false
        while (-not $done) {
            $key = Read-ConsoleKey   # blocks until the user presses a key

            switch ($key.Key) {
                'UpArrow' {
                    # Move selection up, wrapping from the first item to the last.
                    if ($selectedIndex -gt 0) {
                        $selectedIndex--
                    }
                    else {
                        $selectedIndex = $Choices.Count - 1
                    }
                }
                'DownArrow' {
                    # Move selection down, wrapping from the last item to the first.
                    if ($selectedIndex -lt ($Choices.Count - 1)) {
                        $selectedIndex++
                    }
                    else {
                        $selectedIndex = 0
                    }
                }
                'Enter' {
                    $done = $true
                    continue   # skip the redraw below
                }
            }

            if (-not $done) {
                # Redraw only the choice rows so the question line does not flicker.
                # Row offset: question occupies $menuStartRow; choices start one row below.
                for ($i = 0; $i -lt $Choices.Count; $i++) {
                    # Reposition cursor to the start of choice row $i.
                    try { [Console]::SetCursorPosition(0, $menuStartRow + 1 + $i) } catch { }
                    Write-MenuRow -Text $Choices[$i] -Selected ($i -eq $selectedIndex) `
                                  -ConsoleWidth $consoleWidth -SupportsVT $supportsVT
                }
            }
        }

        # ── clear the menu and print the final answer ──────────────────────────

        # Total lines occupied: 1 (question) + Choices.Count (choice rows).
        # Erase every rendered line by overwriting with spaces.
        for ($row = $menuStartRow; $row -lt ($menuStartRow + $totalLines); $row++) {
            try { [Console]::SetCursorPosition(0, $row) } catch { }
            # Full-width blank prevents ghost characters from the previous draw.
            try { [Console]::Write(' ' * ([Math]::Max(0, $consoleWidth - 1))) } catch { }
        }

        # Return the cursor to the very start of the prompt line.
        try { [Console]::SetCursorPosition(0, $menuStartRow) } catch { }

        # Print the answered prompt: "? <message> <answer>" with the answer in green.
        Write-Host "? $Message " -NoNewline
        if ($supportsVT) {
            try { [Console]::WriteLine("`e[32m$($Choices[$selectedIndex])`e[0m") } catch { }
        }
        else {
            Write-Host $Choices[$selectedIndex] -ForegroundColor Green
        }
    }
    finally {
        # Always restore the cursor – even if the user presses Ctrl+C.
        try { [Console]::CursorVisible = $true } catch { }
    }

    # Output the selected value to the pipeline.
    return $Choices[$selectedIndex]
}