src/UI/05-Init.ps1

# 05-Init.ps1 — lazy UI bootstrap: dependency, console, UI state.

# Ensure PwshSpectreConsole (ships the Spectre.Console assembly) is loaded,
# offering to install it on first use. NEVER runs at profile import.
#
# Imported WITHOUT -Global on purpose (gh#16). This is psmm's own UI engine,
# not something the user asked for, and it has no business appearing in their
# `Get-Module`. A private import is enough for psmm's needs: the Spectre
# assembly loads process-wide so the types resolve either way, and the
# PwshSpectreConsole cmdlets psmm calls (Read-SpectreConfirm /
# Read-SpectreSelection) are visible in psmm's own session state - verified.
# Note the asymmetry with Import-PSMMModuleTimed, which MUST use -Global: the
# user's modules belong in the user's session, psmm's belong in psmm's.
function script:Initialize-PSMMUI {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'First-use install prompt is interactive host output by design (mirrors the original block).')]
    [CmdletBinding()] param()
    $dep = Get-PSMMUIDependencyName
    if (-not ('Spectre.Console.AnsiConsole' -as [type])) {
        if (Get-Module -ListAvailable -Name $dep) {
            Register-PSMMPrivateImport -Module (Import-Module $dep -PassThru -ErrorAction Stop)
        } else {
            Write-Host "$dep is required for the interactive manager." -ForegroundColor Yellow
            if ((Read-Host 'Install it now from the PowerShell Gallery? (y/N)') -notmatch '^(y|yes)$') { return $false }
            try {
                Install-PSMMModule -Name $dep
                Register-PSMMPrivateImport -Module (Import-Module $dep -PassThru -ErrorAction Stop)
            } catch {
                Write-Warning "Could not install ${dep}: $($_.Exception.Message)"
                return $false
            }
        }
    }
    # NB: when the type already resolves nothing is imported and nothing is
    # registered - the copy in play is the user's own (or an earlier psmm run's,
    # already registered), and either way the grid should report it as it finds
    # it.
    $null = Get-PSMMConsole
    $true
}

# First manager run with no config file anywhere: create the main config,
# seeded with psmm's own UI dependency as a managed entry (2026-07-05
# feedback). Mode InstallOnly keeps profile startup import-free - psmm loads
# Spectre lazily itself. Only the TUI does this, never Invoke-PSMMStartup:
# profile import must not write files.
function script:Initialize-PSMMMainConfig {
    [CmdletBinding()] param()
    $null = Get-PSMMEntry   # (re)build file metadata before inspecting it
    if (@((Get-PSMMFileMeta).Values | Where-Object { $_.Kind -ne 'inline' }).Count) { return }
    $main = Get-PSMMMainConfigPath
    if (Test-Path -LiteralPath $main) { return }
    try {
        $dir = Split-Path -Parent $main
        if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
        ([ordered]@{
            Includes = @()
            Modules  = @([ordered]@{
                Name        = 'PwshSpectreConsole'
                Description = 'TUI engine used by psmm itself'
                Install     = 'IfMissing'
                Mode        = 'InstallOnly'
            })
        } | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $main -Encoding utf8
        $script:PSMM_UI.Status = "[$script:PSMM_ColOk]created $(ConvertTo-PSMMSafe $main) - psmm's UI dependency is managed there[/]"
    } catch { }   # best-effort: the add flows still offer creation
}

# Fresh UI state for a manager session.
function script:Initialize-PSMMUIState {
    [CmdletBinding()] param()
    $script:PSMM_UI = @{
        Entries       = [System.Collections.Generic.List[object]]::new()
        Cursor        = 0
        Top           = 0
        Sel           = [System.Collections.Generic.HashSet[int]]::new()
        Filter        = ''
        FilterMode    = $false
        View          = @()
        Status        = ''
        Dirty         = $false
        HardQuit      = $false
        Goto          = $null      # set by the g goto overlay: unwind to the
                                   # manager loop, which routes to the target
        Unmanaged     = $null      # results of the background scan (#26)
        ShowUnmanaged = $false     # 'm' toggles unmanaged rows in the grid
        Elevated      = Test-PSMMElevated
        Engine        = Get-PSMMInstallEngine
        Version       = Get-PSMMVersionString
        # OneDrive-backed primary module location? (cached: grid renders a
        # standing notice, and per-frame path checks would be too slow)
        OneDrivePrimary = [bool](@(Get-PSMMModulePathInfo) | Where-Object { $_.First -and $_.OneDrive })
        # newer psmm on the gallery, per the daily cached background check
        SelfUpdate    = Test-PSMMUpdateAvailable
    }
    $null = Start-PSMMSelfUpdateCheck   # throttled to once a day
    Initialize-PSMMMainConfig
    Sync-PSMMUIEntries -FullScan
    # unknown $PSMM_Theme: glacier took over silently at source time - say so
    # (last, so a first-run "created config" status cannot overwrite it)
    if (Test-PSMMThemeFallback) {
        $script:PSMM_UI.Status = "[$script:PSMM_ColWarn]unknown `$PSMM_Theme '$(ConvertTo-PSMMSafe (Get-PSMMSetting -Name 'PSMM_Theme'))' - using glacier (glacier|ember|moss)[/]"
    }
}

# (Re)build the entry list from config + disk. -FullScan does the one
# expensive Get-Module -ListAvailable sweep (open + explicit reload only).
function script:Sync-PSMMUIEntries {
    [CmdletBinding()] param([switch]$FullScan)
    # Disk facts have to SURVIVE the rebuild below.
    #
    # Get-PSMMEntry mints brand-new entry objects on every call, blank-slate:
    # Installed = $false, InstalledVersion = $null, InstallScope = $null
    # (Entry.ps1). The only thing that ever fills them is Update-PSMMAvailable,
    # which is gated on -FullScan because it is a whole-disk sweep. So a bare
    # Sync used to hand back a list where every managed module read as
    # "missing" - and pressing 'm' (show/hide unmanaged) is a bare Sync, which
    # is how psmm came to report its own running UI engine as not installed.
    # Update-PSMMLoaded was called unconditionally, so Loaded stayed honest
    # while everything disk-derived went blank - hence a row that was both
    # "missing" and able to list its 51 commands.
    #
    # Re-reading the disk on every toggle would be correct but costs a full
    # Get-Module -ListAvailable sweep per keypress. The facts have not changed,
    # so carry them across instead. Keyed by source+name: the same module name
    # can appear in several files.
    $carry = @{}
    if (-not $FullScan) {
        foreach ($e in @(Get-PSMMAllEntries)) {
            if ($e.Name -and $e.Installed) { $carry["$($e.Source)|$($e.Name)"] = $e }
        }
    }
    $active = Get-PSMMEntry
    $list = [System.Collections.Generic.List[object]]::new()
    foreach ($e in $active) { $list.Add($e) }
    # unmanaged rows appended when the toggle is on and the scan has results
    if ($script:PSMM_UI.ShowUnmanaged -and $script:PSMM_UI.Unmanaged) {
        $known = [System.Collections.Generic.HashSet[string]]::new(
            [string[]]@($active.Name), [System.StringComparer]::OrdinalIgnoreCase)
        foreach ($u in $script:PSMM_UI.Unmanaged) {
            if ($known.Contains($u.Name)) { continue }
            $x = Resolve-PSMMEntry -Raw ([pscustomobject]@{ Name = $u.Name; Description = $u.Description }) -Source '<unmanaged>' -Writable $false
            $x | Add-Member -NotePropertyName FromMain -NotePropertyValue $false -Force
            $x | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force
            $x | Add-Member -NotePropertyName Unmanaged -NotePropertyValue $true -Force
            $x.Installed = $true
            $x.InstalledVersion = $u.Version
            $x.InstallScope = $u.Scope
            $x.IsSystem = [bool]$u.IsSystem
            $x.Mode = '-'
            $x.Install = '-'
            $list.Add($x)
        }
    }
    $script:PSMM_UI.Entries = $list
    # @() is mandatory: a single-entry result (fresh install: the one seeded
    # PwshSpectreConsole module) unrolls to a scalar PSObject on return, and
    # scalar + array below throws op_Addition (gh#1)
    $all = @(Get-PSMMAllEntries)
    if ($FullScan) {
        Update-PSMMAvailable -Entries ($all + @($list | Where-Object { $_.PSObject.Properties['Unmanaged'] }))
    } else {
        # restore what the previous generation knew about disk and the gallery
        foreach ($e in $all) {
            $p = $carry["$($e.Source)|$($e.Name)"]
            if (-not $p) { continue }
            foreach ($f in 'Installed', 'InstalledVersion', 'InstalledPrerelease', 'InstalledVersions',
                           'InstallScope', 'IsSystem', 'LatestVersion', 'LatestPrerelease', 'UpdateAvailable') {
                $e.$f = $p.$f
            }
        }
    }
    Update-PSMMLoaded -Entries $list
    Update-PSMMLoaded -Entries $all
    $script:PSMM_UI.Sel.Clear()
    $script:PSMM_UI.Dirty = $false
}

# Kick off the background unmanaged-module scan (#26). Results surface as an
# unobtrusive overlay line; 'm' shows them in the grid.
function script:Start-PSMMUnmanagedScan {
    [CmdletBinding()] param()
    # psmm's own modules are excluded like managed ones: they are infrastructure,
    # not something to adopt into a config (gh#16)
    $managed = @(@((Get-PSMMAllEntries).Name | Where-Object { $_ }) + @(Get-PSMMOwnModuleName))
    # The job runs the ENGINE's Get-PSMMUnmanagedModule, dot-sourced in as
    # source text. It used to re-implement the scan with its own inlined scope
    # classifier, so the tested code was not the running code (gh#27).
    $payload = [pscustomobject]@{
        Managed = $managed
        Prelude = (Get-PSMMUnmanagedScanPrelude)
    }
    $null = Start-PSMMTask -Label 'scan: unmanaged modules' -Kind 'unmanagedscan' -ArgumentList (, $payload) -ScriptBlock {
        param($payload)
        . ([scriptblock]::Create($payload.Prelude))
        Get-PSMMUnmanagedModule -ManagedNames $payload.Managed
    }
}