src/UI/70-Help.ps1

# 70-Help.ps1 — tabbed help (design-system-v2 §7): '?' opens the current
# screen's help as tabs `this screen | keys | config | startup | about`;
# left/right switches, '/' filters within the visible tab, 'c' copies it,
# esc backs out (clearing an active filter first).
# Tab content lines are Spectre MARKUP strings (plain text pre-escaped), so
# the keys tab renders capsules; Get-PSMMHelpText flattens to plain text.

function script:Get-PSMMHelpSection {
    param([Parameter(Mandatory)][string]$Topic)
    switch ($Topic) {
        'grid' { @(
            'MAIN SCREEN (module grid)'
            '-------------------------'
            'Every module your config files declare, one row each.'
            ''
            ' state ' + [char]0x25CF + ' loaded (this session) · ' + [char]0x25D0 + ' installed (on disk) ·'
            ' ' + [char]0x25CB + ' missing · ' + [char]0x25CC + ' unmanaged (in no config file - m shows them)'
            ' startup what happens at shell start: load / install (background) / off'
            ' gallery what the gallery may do: if-missing / check-only / latest'
            ' version loaded (or newest installed). ' + [char]0x21E1 + ' = update available (after'
            ' a k check); the cursor row names the target. pin = pinned.'
            ' scope user (CurrentUser) / all (AllUsers) / mixed. "all ro" means'
            ' the session is not elevated, so AllUsers copies are read-only.'
            ' ' + [char]0x26A0 + ' after the name: the entry has validation issues (g c details)'
            ''
            'The muted sentence under the table explains the cursor row in full'
            'words. The last hint row is the same on every screen: g goto,'
            '/ filter, ? help, ^q quit.'
            ''
            ' space select/deselect row (bulk actions target the selection,'
            ' or just the cursor row when nothing is selected)'
            ' enter open the module action menu (right-arrow does the same;'
            ' left-arrow backs out of any menu)'
            ' ^l load ^u unload (^ means ctrl)'
            ' i install the targeted modules that are missing (background)'
            ' u update the targeted installed modules (background) -'
            ' install and update are always separate keys'
            ' k check the gallery for updates (background)'
            ' a add a new entry r reload everything from disk'
            ' m show/hide installed-but-unmanaged modules'
        ) }
        'module' { @(
            'MODULE MENU'
            '-----------'
            'Facts and actions for one module, grouped by what they touch:'
            'session (this pwsh), gallery (disk/network), entry (the config'
            'file line), connection (Connect-* modules). Only actions that'
            'make sense for the row are offered.'
            ''
            ' ^l / ^u load / unload in this session (^ means ctrl)'
            ' i / u install (when missing) / update (when installed) -'
            ' foreground, with progress; always separate keys'
            ' b browse the module''s commands with full help'
            ' v pin the entry to a version: exact "1.2.3" or a NuGet range'
            ' like "[1.0,2.0)". Pinned modules are never nagged to update.'
            ' x remove all but the newest installed version'
            ' s check connection status (Connect-* modules: Graph, Az, EXO,'
            ' PnP, Teams) o disconnect the active session'
            ' a (unmanaged modules) add to a config file'
            ' e/d/m edit fields / delete entry / move entry to another file'
        ) }
        'commands' { @(
            'COMMAND BROWSER'
            '---------------'
            'All commands the module exports. / filters (same as everywhere);'
            'enter opens tabbed help: Overview | Parameters | Examples,'
            'left/right switches tab, up/down scrolls, c copies the tab you'
            'are viewing to the clipboard.'
            'Tip: help is much richer once the module is imported.'
        ) }
        'files' { @(
            'CONFIG FILES'
            '------------'
            'Every config source psmm found, in load order. space toggles a whole'
            'file on/off (saved immediately - a applies the load/unload changes to'
            'the running session). n creates a new config, blank or from a scenario'
            'template. m moves a file and keeps it discoverable (Includes updated).'
            ''
            'A disabled file''s entries are kept in the file untouched - disabling'
            'is how you park a whole module set ("work", "lab", ...).'
        ) }
        'paths' { @(
            'MODULE LOCATIONS'
            '----------------'
            'Every folder PowerShell searches for modules ($env:PSModulePath), in'
            'search order. The FIRST entry is the CurrentUser location, which'
            'PowerShell derives from your Documents folder - when OneDrive backs'
            'up Documents (a common org policy), your modules silently live in'
            'OneDrive, and "Files On-Demand" can make them cloud-only'
            'placeholders that stall or fail module loading.'
            ''
            ' d scan the highlighted path and download (hydrate) every'
            ' cloud-only file, with progress'
            ' k keep on this device: pin the folder so OneDrive keeps all'
            ' files local from now on (downloads in the background)'
            ' s set the primary (CurrentUser) module location - creates the'
            ' folder if needed (suggests one in your user profile,'
            ' outside OneDrive) and writes the documented PSModulePath'
            ' override to your user powershell.config.json. Takes effect'
            ' in this session immediately and in every new pwsh session.'
            ' Caveat: Install-Module/Install-PSResource still install to'
            ' the default Documents location.'
            ' r remove that override again'
            ''
            'psmm also checks for cloud-only files before loading a module (with'
            'a prompt in the module menu / apply, silently with a status line in'
            'grid bulk loads).'
        ) }
        'gallery' { @(
            'GALLERY SEARCH'
            '--------------'
            'Searches the PowerShell Gallery (read-only). Wildcards work: Az.*,'
            'Microsoft.Graph*. enter adds the highlighted module to one of your'
            'config files - pick install policy and mode, done. / starts a new'
            'search.'
        ) }
        'cleanup' { @(
            'VERSION CLEANUP'
            '---------------'
            'Update-Module and Update-PSResource never delete old versions - they'
            'accumulate on disk forever. This screen lists every module with more'
            'than one installed version; enter prunes one module to its newest'
            'version, ctrl+a prunes all. Without elevation, AllUsers copies are'
            'skipped automatically.'
        ) }
        'tasks' { @(
            'BACKGROUND TASKS'
            '----------------'
            'Everything psmm runs in the background lands here: install batches'
            '(i), update batches (u), update checks (k), the unmanaged-module'
            'scan, and Update-Help (start it with u on this screen). enter shows'
            'a task''s full output. The grid keeps working while tasks run; a'
            'one-line overlay shows progress.'
        ) }
        default { @() }
    }
}

# keys tab: the grouped two-column capsule layout (mock 2c).
function script:Get-PSMMHelpKeysLines {
    $col1 = @(
        "[$script:PSMM_ColAccent]navigate[/]"
        (Get-PSMMHint -NoLegend -Pairs @('up/dn=move', 'pgup/pgdn=page'))
        (Get-PSMMHint -NoLegend -Pairs @('home/end=top / bottom'))
        (Get-PSMMHint -NoLegend -Pairs @('/=filter (enter keeps, esc clears)'))
        (Get-PSMMHint -NoLegend -Pairs @("$([char]0x2192)=drill in", "$([char]0x2190)=back out"))
        ''
        "[$script:PSMM_ColAccent]go places[/]"
        (Get-PSMMHint -NoLegend -Pairs @('g=goto: h g f p t c x ?'))
        (Get-PSMMHint -NoLegend -Pairs @('esc=back one level'))
        (Get-PSMMHint -NoLegend -Pairs @('^q=quit from anywhere'))
        "[$script:PSMM_ColDim]^ = ctrl[/]"
    )
    $col2 = @(
        "[$script:PSMM_ColAccent]act on modules[/]"
        (Get-PSMMHint -NoLegend -Pairs @('space=select', 'enter=actions'))
        (Get-PSMMHint -NoLegend -Pairs @('i=install missing (background)'))
        (Get-PSMMHint -NoLegend -Pairs @('u=update installed (background)'))
        (Get-PSMMHint -NoLegend -Pairs @('k=check the gallery for updates'))
        (Get-PSMMHint -NoLegend -Pairs @('^l=load', '^u=unload'))
        (Get-PSMMHint -NoLegend -Pairs @('a=add entry', 'r=reload from disk'))
        (Get-PSMMHint -NoLegend -Pairs @('m=show/hide unmanaged'))
        ''
        "[$script:PSMM_ColAccent]everywhere[/]"
        (Get-PSMMHint -NoLegend -Pairs @('?=help', 'c=copy (text screens)'))
        "[$script:PSMM_ColDim]bulk verbs target the selection, or[/]"
        "[$script:PSMM_ColDim]the cursor row when none is selected[/]"
    )
    $w = 0
    foreach ($l in $col1) {
        $len = [Spectre.Console.Markup]::Remove($l).Length
        if ($len -gt $w) { $w = $len }
    }
    $w += 4
    $rows = for ($i = 0; $i -lt [Math]::Max($col1.Count, $col2.Count); $i++) {
        $l = if ($i -lt $col1.Count) { $col1[$i] } else { '' }
        $r = if ($i -lt $col2.Count) { $col2[$i] } else { '' }
        $pad = ' ' * [Math]::Max(0, $w - [Spectre.Console.Markup]::Remove($l).Length)
        "$l$pad$r"
    }
    @($rows)
}

# config tab: discovery order, file format, rules (plain text, escaped).
function script:Get-PSMMHelpConfigLines {
    $raw = [System.Collections.Generic.List[string]]::new()
    $raw.Add('WHERE PSMM LOOKS (in load order)')
    $raw.Add('--------------------------------')
    $raw.Add(' 1. inline JSON in $PSMM_InlineJson (set in $PROFILE; read-only)')
    $raw.Add(" 2. MAIN config: $(Get-PSMMMainConfigPath)")
    $raw.Add(' 3. files listed in the MAIN config''s "Includes" (one level, main only)')
    $profileCfg = Get-PSMMProfileConfigPath
    if ($profileCfg) { $raw.Add(" 4. profile-dir: $profileCfg") }
    $raw.Add(' 5. legacy globs in $PSMM_JsonPath (default: psmodules.d next to $PROFILE)')
    $raw.Add('')
    $raw.Add('FILE FORMAT (psmm-config.json)')
    $raw.Add('------------------------------')
    $raw.Add(' {')
    $raw.Add(' "Enabled": true, // false = file parsed but nothing actioned')
    $raw.Add(' "Includes": ["C:\\path\\more.json"], // MAIN config only')
    $raw.Add(' "Modules": [')
    $raw.Add(' {')
    $raw.Add(' "Name": "ImportExcel", // required: gallery name')
    $raw.Add(' "FriendlyName": "Import Excel", // optional display name')
    $raw.Add(' "Description": "what/why", // optional')
    $raw.Add(' "Install": "IfMissing", // CheckOnly | IfMissing | Latest')
    $raw.Add(' "Mode": "Load", // Load | InstallOnly | Ignore')
    $raw.Add(' "Version": "1.2.3" // optional pin (or "[1.0,2.0)")')
    $raw.Add(' }')
    $raw.Add(' ]')
    $raw.Add(' }')
    $raw.Add('')
    $raw.Add(' The UI shows Mode as the startup column (load / install / off) and')
    $raw.Add(' Install as the gallery column (if-missing / check-only / latest).')
    $raw.Add('')
    $raw.Add('RULES')
    $raw.Add('-----')
    $raw.Add(' - Only the MAIN config may include other files (one level deep, so')
    $raw.Add(' circular references are impossible). Includes anywhere else are')
    $raw.Add(' ignored with a warning.')
    $raw.Add(' - Same module in several files: the MAIN config wins (warning);')
    $raw.Add(' otherwise the first-loaded file wins (error-style warning).')
    $raw.Add(' - "Enabled": false switches a whole file off without losing entries.')
    @($raw | ForEach-Object { ConvertTo-PSMMSafe $_ })
}

# startup tab: the $PROFILE bootstrap and its knobs (plain text, escaped).
function script:Get-PSMMHelpStartupLines {
    $raw = @(
        '$PROFILE BOOTSTRAP'
        '------------------'
        ' Import-Module psmm; Invoke-PSMMStartup'
        ''
        ' Knobs (set before Import-Module): $PSMM_StartupReport = $false,'
        ' $PSMM_BackgroundStartup = $false, $PSMM_UpdateCheck = $false,'
        ' $PSMM_InlineJson, $PSMM_JsonPath,'
        ' $PSMM_Theme = ''glacier'' (default) | ''ember'' | ''moss''.'
        ''
        ' Install and Mode are independent: Mode decides load / install-only /'
        ' ignore (and foreground vs background at startup); Install decides the'
        ' disk/gallery policy (never install / install when missing / update).'
        ''
        ' Mode = Load imported into this session, in the foreground.'
        ' Mode = InstallOnly disk/gallery work only - deferred to a background'
        ' job so your prompt appears sooner.'
        ' Mode = Ignore parsed but not actioned.'
        ''
        ' Each imported module''s import time is measured and reported, so you'
        ' always know which module is slowing your shell down.'
    )
    @($raw | ForEach-Object { ConvertTo-PSMMSafe $_ })
}

# about tab: version, engine, self-update detail (the header bar shows only
# the compact flag - the exact command lives here).
function script:Get-PSMMHelpAboutLines {
    $ui = $script:PSMM_UI
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add('psmm - PS Session Module Manager')
    $ver = if ($ui -and $ui.Version) { "v$($ui.Version)" } else { '' }
    $eng = if ($ui -and $ui.Engine) { "$($ui.Engine)" } else { '' }
    $lines.Add((ConvertTo-PSMMSafe ("$ver $([char]0x00B7) install engine $eng$(if ($ui -and $ui.Elevated) { " $([char]0x00B7) elevated" })").Trim()))
    $lines.Add((ConvertTo-PSMMSafe 'github.com/PBNZ/psmm'))
    $lines.Add('')
    if ($ui -and $ui.SelfUpdate) {
        $u = $ui.SelfUpdate
        $lines.Add("[$script:PSMM_ColWarn]$([char]0x21E1) psmm v$($u.Latest) is available (you have v$($u.Current))[/]")
        $lines.Add("[$script:PSMM_ColMute]update:[/] [$script:PSMM_ColInfo]$(ConvertTo-PSMMSafe $u.Command)[/][$script:PSMM_ColMute], then restart pwsh[/]")
    } else {
        $lines.Add((ConvertTo-PSMMSafe 'psmm checks the gallery for its own updates once a day (cached,'))
        $lines.Add((ConvertTo-PSMMSafe 'never in the profile hot path); the header bar flags one with ' + [char]0x21E1 + ','))
        $lines.Add((ConvertTo-PSMMSafe 'and this tab then shows the exact update command.'))
    }
    $lines.Add('')
    $lines.Add((ConvertTo-PSMMSafe 'While psmm is in prerelease, beta-to-beta updates need a forced'))
    $lines.Add((ConvertTo-PSMMSafe 'reinstall: Install-PSResource psmm -Prerelease -Reinstall, then'))
    $lines.Add((ConvertTo-PSMMSafe 'restart pwsh. u on psmm''s own grid row handles this correctly.'))
    @($lines)
}

# The five tabs for a topic. Values are arrays of markup lines.
function script:Get-PSMMHelpTabs {
    param([string]$Topic = 'grid')
    [ordered]@{
        'this screen' = @(Get-PSMMHelpSection -Topic $Topic | ForEach-Object { ConvertTo-PSMMSafe $_ })
        'keys'        = @(Get-PSMMHelpKeysLines)
        'config'      = @(Get-PSMMHelpConfigLines)
        'startup'     = @(Get-PSMMHelpStartupLines)
        'about'       = @(Get-PSMMHelpAboutLines)
    }
}

# Flattened plain-text help (all tabs) - used by tests and as a plain doc.
function script:Get-PSMMHelpText {
    param([string]$Topic = 'grid')
    $tabs = Get-PSMMHelpTabs -Topic $Topic
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add('psmm - PS Session Module Manager')
    $lines.Add('================================')
    foreach ($name in $tabs.get_Keys()) {
        $lines.Add('')
        $lines.Add("== $($name.ToUpperInvariant()) ==")
        $lines.Add('')
        foreach ($l in $tabs[$name]) { $lines.Add([Spectre.Console.Markup]::Remove($l)) }
    }
    $lines
}

# One frame of the tabbed help. $State: @{ Tab; Scroll; Filter; FilterMode;
# Status }.
function script:Build-PSMMHelpView {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $Tabs
    )
    $names = @($Tabs.get_Keys())
    $State.Tab = [Math]::Max(0, [Math]::Min($State.Tab, $names.Count - 1))
    $lines = @($Tabs[$names[$State.Tab]])
    if ($State.Filter) {
        $needle = $State.Filter
        $lines = @($lines | Where-Object { [Spectre.Console.Markup]::Remove($_) -like "*$needle*" })
    }
    $win = Get-PSMMWinSize
    $page = [Math]::Max(5, $win.Height - 9)
    $State.Scroll = [Math]::Max(0, [Math]::Min($State.Scroll, [Math]::Max(0, $lines.Count - $page)))
    $visible = @($lines | Select-Object -Skip $State.Scroll -First $page)
    $tabBar = (0..($names.Count - 1) | ForEach-Object {
        if ($_ -eq $State.Tab) { "[$script:PSMM_ColAccent underline]$($names[$_])[/]" } else { "[$script:PSMM_ColDim]$($names[$_])[/]" }
    }) -join ' '
    $pos = if ($lines.Count -gt $page) {
        " [$script:PSMM_ColMute]lines $($State.Scroll + 1)-$([Math]::Min($lines.Count, $State.Scroll + $page))/$($lines.Count)[/]"
    } else { '' }
    $flt = Get-PSMMFilterMarkup -State $State
    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHeaderBar -Breadcrumb @('home', 'help') -CountsMarkup "$pos$flt")))
    $items.Add([Spectre.Console.Markup]::new($tabBar))
    $panel = [Spectre.Console.Panel]::new([Spectre.Console.Markup]::new(($visible -join "`n")))
    $panel.Border = [Spectre.Console.BoxBorder]::Rounded
    $panel.BorderStyle = Get-PSMMBorderStyle
    $items.Add($panel)
    if ($State.FilterMode) {
        $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('type=filter', 'enter=apply', 'esc=clear & exit filter'))))
    } else {
        $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('left/right=switch tab', 'up/dn=scroll', 'c=copy tab') -NoLegend)))
        $items.Add([Spectre.Console.Markup]::new((Get-PSMMPersistentHint -Pairs @("g=goto$([char]0x2026)", '/=filter', 'esc=back', '^q=quit'))))
    }
    if ($State.Status) { $items.Add([Spectre.Console.Markup]::new($State.Status)) }
    [Spectre.Console.Rows]::new($items)
}

function script:Show-PSMMHelpScreen {
    param(
        [string]$Topic = 'grid',
        [string]$InitialTab = 'this screen'
    )
    $tabs = Get-PSMMHelpTabs -Topic $Topic
    $names = @($tabs.get_Keys())
    $start = [Array]::IndexOf($names, $InitialTab)
    $st = @{ Tab = [Math]::Max(0, $start); Scroll = 0; Filter = ''; FilterMode = $false; Status = '' }
    Clear-PSMMScreen
    Invoke-PSMMLive -Body {
        param($ctx)
        while ($true) {
            if ($script:PSMM_UI.HardQuit) { return }
            $ctx.UpdateTarget((Build-PSMMHelpView -State $st -Tabs $tabs))
            $ctx.Refresh()
            $k = Read-PSMMKeyResize
            if ($null -eq $k) { continue }
            if (Test-PSMMHardQuitKey $k) { $script:PSMM_UI.HardQuit = $true; return }
            if ($st.FilterMode) {
                $r = Invoke-PSMMFilterKey -State $st -KeyInfo $k
                if ($r) { $st.Scroll = 0; continue }
                $null = Invoke-PSMMPagerNav -State $st -KeyInfo $k
                continue
            }
            if ($k.KeyChar -eq 'g') {
                $dest = Read-PSMMGotoKey -BaseRenderable (Build-PSMMHelpView -State $st -Tabs $tabs)
                if ($dest) { $script:PSMM_UI.Goto = $dest; return }
                continue
            }
            if (Test-PSMMHomeKey $k) { $script:PSMM_UI.Goto = 'home'; return }
            $st.Status = ''
            switch ($k.Key) {
                ([ConsoleKey]::LeftArrow)  { $st.Tab = ($st.Tab + $names.Count - 1) % $names.Count; $st.Scroll = 0; continue }
                ([ConsoleKey]::RightArrow) { $st.Tab = ($st.Tab + 1) % $names.Count; $st.Scroll = 0; continue }
                ([ConsoleKey]::Escape)     {
                    if ($st.Filter) { $st.Filter = ''; $st.Scroll = 0; continue }
                    return
                }
                default {
                    if ($k.KeyChar -eq '/') { $st.FilterMode = $true; continue }
                    if ($k.KeyChar -eq 'c') {
                        $plain = @($tabs[$names[$st.Tab]] | ForEach-Object { [Spectre.Console.Markup]::Remove($_) })
                        $st.Status = Copy-PSMMText -Text ($plain -join [Environment]::NewLine)
                        continue
                    }
                    $null = Invoke-PSMMPagerNav -State $st -KeyInfo $k
                }
            }
        }
    }
    Clear-PSMMScreen
}