Private/NDT.Tui.ps1

# NDT TUI - add-computer form. PowerShell 7 only; dot-sourced by ndt.psm1 on PS7.
# Presentation only: all validation lives in Get-NDTCatalog / Test-NDTComputerEntry, the only write path is Add-NDTComputer.
# Handlers are built with [scriptblock]::Create (not GetNewClosure) so they keep module scope and can call private functions.
# Pinned to Terminal.Gui 1.x as shipped by Microsoft.PowerShell.ConsoleGuiTools 0.7.x (API differs in 2.x).

$script:NdtTui = $null

function Import-NDTTerminalGui {
    if (-not ('Terminal.Gui.Application' -as [type])) {
        $mod = Get-Module -ListAvailable -Name Microsoft.PowerShell.ConsoleGuiTools |
            Sort-Object Version -Descending | Select-Object -First 1
        if (-not $mod) {
            throw 'Microsoft.PowerShell.ConsoleGuiTools is required. Install it with: Install-Module Microsoft.PowerShell.ConsoleGuiTools -Scope CurrentUser'
        }
        $dir = Split-Path $mod.Path
        foreach ($dll in 'NStack.dll', 'Terminal.Gui.dll') { Add-Type -Path (Join-Path $dir $dll) }
    }
    $ver = [Terminal.Gui.Application].Assembly.GetName().Version
    if ($ver.Major -ne 1) { throw "Terminal.Gui $ver is loaded; this TUI is built for Terminal.Gui 1.x (ConsoleGuiTools 0.7.x)." }
}

function Update-NDTTuiList {
    param([Parameter(Mandatory)][hashtable]$List)
    for ($i = 0; $i -lt $List.Names.Count; $i++) {
        $n = $List.Names[$i]
        $detail = if ($List.Details[$i]) { ' ' + $List.Details[$i] } else { '' }
        if ($List.Multi) {
            if ($List.Locked.Contains($n))       { $mark = '[x]' }
            elseif ($List.Checked.Contains($n))  { $mark = '[' + ($List.Checked.IndexOf($n) + 1) + ']' }
            else                                 { $mark = '[ ]' }
        } else {
            $mark = if ($List.Checked.Contains($n)) { '(o)' } else { '( )' }
        }
        $List.Rows[$i] = "$mark $n$detail"
    }
    $List.View.SetNeedsDisplay()
}

function New-NDTTuiList {
    # Marker list. Multi: [n] = position in the order checked. Single: (o). Locked rows are shown checked and cannot be toggled.
    param(
        [Parameter(Mandatory)][string]$Id,
        [string[]]$Names,
        [string[]]$Details,
        [bool]$Multi,
        [string[]]$Locked = @(),
        [string[]]$Initial = @(),
        [int]$X, [int]$Y, [int]$Height,
        [Parameter(Mandatory)][Terminal.Gui.View]$Parent
    )
    $rows = [System.Collections.Generic.List[string]]::new()
    foreach ($n in $Names) { $rows.Add($n) }

    $lv = [Terminal.Gui.ListView]::new()
    $lv.X      = [Terminal.Gui.Pos]::At($X)
    $lv.Y      = [Terminal.Gui.Pos]::At($Y)
    $lv.Width  = [Terminal.Gui.Dim]::Fill(1)
    $lv.Height = [Terminal.Gui.Dim]::Sized($Height)
    $lv.SetSource($rows)
    $Parent.Add($lv)

    $checked = [System.Collections.Generic.List[string]]::new()
    foreach ($n in $Initial) { $checked.Add($n) }
    $list = @{
        Id = $Id; View = $lv; Names = $Names; Details = $Details; Multi = $Multi
        Rows = $rows; Checked = $checked
        Locked = [System.Collections.Generic.HashSet[string]]::new([string[]]$Locked)
    }
    $script:NdtTui.Lists[$Id] = $list
    Update-NDTTuiList -List $list

    $handler = [scriptblock]::Create("param(`$a) Invoke-NDTTuiListKey -Id '$Id' -EventArgs `$a")
    $lv.add_KeyPress([System.Action[Terminal.Gui.View+KeyEventEventArgs]]$handler)

    # Hides itself when everything fits; shows arrows and a thumb when the list overflows.
    $sb = [Terminal.Gui.ScrollBarView]::new($lv, $true, $false)
    $list.ScrollBar = $sb
    $sb.add_ChangedPosition([System.Action][scriptblock]::Create("Sync-NDTTuiScroll -Id '$Id' -FromBar"))
    $lv.add_DrawContent([System.Action[Terminal.Gui.Rect]][scriptblock]::Create("param(`$r) Sync-NDTTuiScroll -Id '$Id'"))
    $list
}

function Sync-NDTTuiScroll {
    param([string]$Id, [switch]$FromBar)
    $list = $script:NdtTui.Lists[$Id]
    $lv = $list.View; $sb = $list.ScrollBar
    if ($FromBar) {
        $lv.TopItem = $sb.Position
        if ($lv.TopItem -ne $sb.Position) { $sb.Position = $lv.TopItem }
        $lv.SetNeedsDisplay()
    } else {
        $sb.Size = $list.Names.Count
        $sb.Position = $lv.TopItem
        $sb.Refresh()
    }
}

function Invoke-NDTTuiListKey {
    param([string]$Id, $EventArgs)
    try {
        $key = $EventArgs.KeyEvent.Key
        if ($key -ne [Terminal.Gui.Key]::Space -and $key -ne [Terminal.Gui.Key]::Enter) { return }
        $EventArgs.Handled = $true
        $list = $script:NdtTui.Lists[$Id]
        $idx  = $list.View.SelectedItem
        if ($idx -lt 0 -or $idx -ge $list.Names.Count) { return }
        $name = $list.Names[$idx]
        if ($list.Multi) {
            if ($list.Locked.Contains($name)) { return }
            if ($list.Checked.Contains($name)) { [void]$list.Checked.Remove($name) } else { $list.Checked.Add($name) }
        } else {
            $list.Checked.Clear(); $list.Checked.Add($name)
        }
        Update-NDTTuiList -List $list
        Update-NDTTuiStatus
    } catch { Show-NDTTuiError -Message $_.Exception.Message }
}

function Show-NDTTuiError {
    param([string]$Message, [string]$Title = 'Error')
    $lines = @($Message -split "`n").Count
    $w = [Math]::Min(80, [Terminal.Gui.Application]::Driver.Cols - 4)
    $h = [Math]::Min(8 + $lines, [Terminal.Gui.Application]::Driver.Rows - 2)
    [void][Terminal.Gui.MessageBox]::ErrorQuery($w, $h, $Title, $Message, [NStack.ustring[]]@('OK'))
}

function Get-NDTTuiEntry {
    # Machine entry exactly as the form currently describes it (MAC kept separately).
    $s = $script:NdtTui
    $osSel = @($s.Lists.OS.Checked)
    $fin   = @($s.Lists.Finish.Checked)

    $e = [ordered]@{}
    if ($osSel.Count)                       { $e.OS = $osSel[0] } else { $e.OS = '' }
    $e.Computername = $s.Fields.Computername.Text.ToString().Trim()
    $ip = $s.Fields.IPAddress.Text.ToString().Trim()
    if ($ip)                                { $e.IPAddress = $ip }
    $e.AdminPassword = $s.Fields.AdminPassword.Text.ToString()

    $secs = @($s.Lists.Sections.Checked)
    if ($secs.Count) {
        $o = [ordered]@{}
        foreach ($n in $secs) { $o[$n] = $n }
        $e.Sections = $o
    }
    $grps = @($s.Lists.Groups.Checked)
    if ($grps.Count)                        { $e.DeploymentGroups = $grps }
    if ($fin.Count -and $fin[0] -ne '(inherit)') { $e.FinishAction = $fin[0] }
    if ($s.Fields.InstallNo.Checked)        { $e.Install = 'NO' }
    $e
}

function Update-NDTTuiStatus {
    $s = $script:NdtTui
    if (-not $s.Status) { return }
    $entry = Get-NDTTuiEntry
    $overlaps = @(Get-NDTSectionOverlap -Entry $entry -SectionKeys $s.SectionKeys)
    if (-not $overlaps.Count) {
        $s.Status.Text = 'No overlapping keys between machine fields and checked sections.'
        return
    }
    $lines = foreach ($o in ($overlaps | Select-Object -First 3)) {
        $winner = if ($o.Winner -eq '(machine)') { 'machine' } else { $o.Winner }
        '! {0} set by: {1} (wins), {2}' -f $o.Key, $winner, ($o.Losers -join ', ')
    }
    if ($overlaps.Count -gt 3) { $lines += " (+$($overlaps.Count - 3) more)" }
    $s.Status.Text = ($lines -join "`n")
}

function Show-NDTTuiConfirm {
    # Returns $true if the user chose Save.
    param([string]$Text)
    $s   = $script:NdtTui
    $w   = [Math]::Min(80, $s.Driver.Cols - 4)
    $h   = [Math]::Min(26, $s.Driver.Rows - 2)
    $dlg = [Terminal.Gui.Dialog]::new('Confirm - add computer', $w, $h)

    $tv = [Terminal.Gui.TextView]::new()
    $tv.X = [Terminal.Gui.Pos]::At(0); $tv.Y = [Terminal.Gui.Pos]::At(0)
    $tv.Width = [Terminal.Gui.Dim]::Fill(0); $tv.Height = [Terminal.Gui.Dim]::Fill(2)
    $tv.ReadOnly = $true
    $tv.Text = $Text
    $dlg.Add($tv)

    $s.Confirmed = $false
    $save = [Terminal.Gui.Button]::new('Save', $true)
    $back = [Terminal.Gui.Button]::new('Back')
    $save.add_Clicked([System.Action][scriptblock]::Create('$script:NdtTui.Confirmed = $true; [Terminal.Gui.Application]::RequestStop()'))
    $back.add_Clicked([System.Action][scriptblock]::Create('[Terminal.Gui.Application]::RequestStop()'))
    $dlg.AddButton($save)
    $dlg.AddButton($back)

    [Terminal.Gui.Application]::Run($dlg)
    $s.Confirmed
}

function Invoke-NDTTuiSave {
    $s = $script:NdtTui
    try {
        try {
            $rawMac = $s.Fields.MAC.Text.ToString()
            if (-not $rawMac.Trim()) { throw 'MAC address is required.' }
            $mac = ConvertTo-NDTMac $rawMac
        }
        catch { Show-NDTTuiError -Message $_.Exception.Message -Title 'MAC address'; return }

        $entry = Get-NDTTuiEntry
        $check = Test-NDTComputerEntry -Entry $entry -MAC $mac -LocalPath $s.LocalPath
        if (-not $check.Valid) {
            Show-NDTTuiError -Message ($check.Errors -join "`n") -Title 'Fix these before saving'
            return
        }

        $shown = [ordered]@{}
        foreach ($k in $entry.Keys) { $shown[$k] = $entry[$k] }
        $shown.AdminPassword = '********'
        $text = "$mac`n" + ($shown | ConvertTo-Json -Depth 5)
        foreach ($o in $check.Overlaps) { $text += "`n! $($o.Key): $($o.Winner) wins over $($o.Losers -join ', ')" }
        foreach ($w in $check.Warnings) { $text += "`n! $w" }

        if (-not (Show-NDTTuiConfirm -Text $text)) { return }

        $splat = @{
            LocalPath    = $s.LocalPath
            MAC          = $mac
            Computername = $entry.Computername
            OS           = $entry.OS
            LocalAdmin   = $entry.AdminPassword
        }
        if ($entry.Contains('IPAddress'))        { $splat.IPAddress        = $entry.IPAddress }
        if ($entry.Contains('Sections'))         { $splat.Sections         = $entry.Sections }
        if ($entry.Contains('DeploymentGroups')) { $splat.DeploymentGroups = $entry.DeploymentGroups }
        if ($entry.Contains('FinishAction'))     { $splat.FinishAction     = $entry.FinishAction }
        if ($entry.Contains('Install'))          { $splat.Install          = $entry.Install }

        $s.Result = Add-NDTComputer @splat
        [Terminal.Gui.Application]::RequestStop()
    } catch { Show-NDTTuiError -Message $_.Exception.Message -Title 'Save failed' }
}

function Show-NDTComputerForm {
    param([Parameter(Mandatory)][string]$LocalPath)

    Import-NDTTerminalGui
    $catalog = Get-NDTCatalog -LocalPath $LocalPath

    $script:NdtTui = @{
        LocalPath = $LocalPath; Lists = @{}; Fields = @{}; Status = $null
        Result = $null; Confirmed = $false; Driver = $null
        SectionKeys = @{}
    }
    $s = $script:NdtTui
    foreach ($sec in $catalog.Sections) { if (-not $sec.System) { $s.SectionKeys[$sec.Name] = $sec.Keys } }

    [Terminal.Gui.Application]::Init()
    try {
        $s.Driver = [Terminal.Gui.Application]::Driver
        if ($s.Driver.Cols -lt 100 -or $s.Driver.Rows -lt 28) {
            throw "Terminal is $($s.Driver.Cols)x$($s.Driver.Rows); the form needs at least 100x28. Enlarge or maximise the terminal."
        }

        $win = [Terminal.Gui.Window]::new('NDT - Add computer (Tab: next Space: toggle Ctrl+Q: cancel)')
        $win.X = [Terminal.Gui.Pos]::At(0); $win.Y = [Terminal.Gui.Pos]::At(0)
        $win.Width = [Terminal.Gui.Dim]::Fill(0); $win.Height = [Terminal.Gui.Dim]::Fill(0)

        $label = {
            param([string]$Text, [int]$X, [int]$Y)
            $l = [Terminal.Gui.Label]::new($Text)
            $l.X = [Terminal.Gui.Pos]::At($X); $l.Y = [Terminal.Gui.Pos]::At($Y)
            $win.Add($l)
        }
        $field = {
            param([string]$Name, [string]$Text, [int]$Y, [bool]$Secret, [string]$Initial)
            & $label $Text 1 $Y
            $f = [Terminal.Gui.TextField]::new($Initial)
            $f.X = [Terminal.Gui.Pos]::At(17); $f.Y = [Terminal.Gui.Pos]::At($Y)
            $f.Width = [Terminal.Gui.Dim]::Sized(27)
            $f.Secret = $Secret
            $win.Add($f)
            $s.Fields[$Name] = $f
        }

        & $field 'MAC'           'MAC address:'   0 $false ''
        & $field 'Computername'  'Computername:'  1 $false ''
        & $field 'IPAddress'     'IP / DHCP:'     2 $false 'DHCP'
        & $field 'AdminPassword' 'Admin password:' 3 $true ''

        # Left column: OS, FinishAction, Install:NO
        & $label 'OS (Space = select):' 1 5
        $osList = New-NDTTuiList -Id OS -Names @($catalog.OS.Name) -Details @($catalog.OS | ForEach-Object { '' }) -Multi $false -X 1 -Y 6 -Height 4 -Parent $win
        $osList.View.Width = [Terminal.Gui.Dim]::Sized(43)

        & $label 'FinishAction:' 1 11
        $finNames = @('(inherit)') + @($catalog.FinishActions)
        $finList = New-NDTTuiList -Id Finish -Names $finNames -Details @($finNames | ForEach-Object { '' }) -Multi $false -Initial @('(inherit)') -X 1 -Y 12 -Height 6 -Parent $win
        $finList.View.Width = [Terminal.Gui.Dim]::Sized(43)

        $inst = [Terminal.Gui.CheckBox]::new('Install: NO (never wipe this machine)')
        $inst.X = [Terminal.Gui.Pos]::At(1); $inst.Y = [Terminal.Gui.Pos]::At(19)
        $inst.add_Toggled([System.Action[bool]][scriptblock]::Create('param($prev) Update-NDTTuiStatus'))
        $win.Add($inst)
        $s.Fields.InstallNo = $inst

        # Right column: Sections, DeploymentGroups
        & $label 'Sections (order checked = precedence; [x] = system, always applied):' 47 0
        $secNames   = @($catalog.Sections.Name)
        $secDetails = @($catalog.Sections | ForEach-Object { $_.Preview })
        $secLocked  = @($catalog.Sections | Where-Object System | ForEach-Object { $_.Name })
        $secList = New-NDTTuiList -Id Sections -Names $secNames -Details $secDetails -Multi $true -Locked $secLocked -X 47 -Y 1 -Height 9 -Parent $win

        & $label 'DeploymentGroups (order checked = run order):' 47 11
        $grpList = New-NDTTuiList -Id Groups -Names @($catalog.Groups.Name) -Details @($catalog.Groups | ForEach-Object { "$($_.Steps) steps" }) -Multi $true -X 47 -Y 12 -Height 6 -Parent $win

        # Bottom: overlap warnings and buttons
        $status = [Terminal.Gui.Label]::new('')
        $status.X = [Terminal.Gui.Pos]::At(1); $status.Y = [Terminal.Gui.Pos]::At(21)
        $status.Width = [Terminal.Gui.Dim]::Fill(1); $status.Height = [Terminal.Gui.Dim]::Sized(3)
        $win.Add($status)
        $s.Status = $status
        Update-NDTTuiStatus

        $btnSave = [Terminal.Gui.Button]::new('Review & save', $true)
        $btnSave.X = [Terminal.Gui.Pos]::At(1); $btnSave.Y = [Terminal.Gui.Pos]::At(25)
        $btnSave.add_Clicked([System.Action][scriptblock]::Create('Invoke-NDTTuiSave'))
        $btnCancel = [Terminal.Gui.Button]::new('Cancel')
        $btnCancel.X = [Terminal.Gui.Pos]::At(20); $btnCancel.Y = [Terminal.Gui.Pos]::At(25)
        $btnCancel.add_Clicked([System.Action][scriptblock]::Create('[Terminal.Gui.Application]::RequestStop()'))
        $win.Add($btnSave); $win.Add($btnCancel)

        [Terminal.Gui.Application]::Top.Add($win)
        $s.Fields.MAC.SetFocus()
        [Terminal.Gui.Application]::Run()
    }
    finally {
        [Terminal.Gui.Application]::Shutdown()
    }
    $s.Result
}