src/Engine/Discovery.ps1

# Discovery.ps1 — build the module-entry lists from every config source.
#
# Sources, in load order (precedence rules below):
# 1. inline JSON in $PSMM_InlineJson (read-only)
# 2. MAIN config ~/.psmm/psmm-config.json (only file whose Includes count)
# 3. main's Includes (one level deep, no nesting -> no circular references)
# 4. profile-dir <profile dir>/psmm-config.json
# 5. legacy globs in $PSMM_JsonPath (default: psmodules.d/*.json next to $PROFILE)
#
# Conflict rules (same module name in several files):
# - main config wins over anything else (warning)
# - among non-main files, first-loaded wins (error-style warning)
# - a disabled file's entries are parsed but never actioned, and are
# preserved on save (never silently dropped)
#
# Engine state lives in script scope; UI and tests reach it via the accessor
# functions at the bottom, never the variables directly.

function Get-PSMMEntry {
    [CmdletBinding()] param()
    $script:PSMM_Legends  = @{}
    $script:PSMM_FileMeta = [ordered]@{}
    $script:PSMM_Warnings = [System.Collections.Generic.List[string]]::new()
    $all  = [System.Collections.Generic.List[object]]::new()
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    $loadFile = {
        param([string]$Path, [string]$Kind, [bool]$AllowIncludes)
        if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @() }
        if (-not $seen.Add(([IO.Path]::GetFullPath($Path)))) { return @() }
        try { $parsed = ConvertFrom-PSMMJson -Json (Get-Content -LiteralPath $Path -Raw) }
        catch { $script:PSMM_Warnings.Add("failed to parse '$Path': $($_.Exception.Message)"); return @() }
        $writable = Test-PSMMWritable -Path $Path
        $script:PSMM_Legends[$Path] = $parsed.Legend
        $ignored = (-not $AllowIncludes) -and $parsed.Includes.Count -gt 0
        if ($ignored) {
            $script:PSMM_Warnings.Add("Includes in '$(Split-Path $Path -Leaf)' ignored - only the main config ($(Get-PSMMMainConfigPath)) may include files")
        }
        $script:PSMM_FileMeta[$Path] = [pscustomobject]@{
            Path = $Path; Kind = $Kind; Enabled = $parsed.Enabled; HasEnabled = $parsed.HasEnabled
            Includes = @($parsed.Includes); IncludesIgnored = $ignored
            Writable = $writable; ModuleCount = @($parsed.Modules).Count
            RawModules = @($parsed.Modules)
        }
        foreach ($m in $parsed.Modules) {
            $e = Resolve-PSMMEntry -Raw $m -Source $Path -Writable $writable
            $e | Add-Member -NotePropertyName FromMain -NotePropertyValue ($Kind -eq 'main') -Force
            $e | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $parsed.Enabled -Force
            $all.Add($e)
        }
        if ($AllowIncludes) { return @($parsed.Includes) } else { return @() }
    }

    # 1. inline (optional — empty/whitespace means "config lives in files")
    $inlineJson = Get-PSMMSetting -Name 'PSMM_InlineJson'
    if (-not [string]::IsNullOrWhiteSpace($inlineJson)) {
        try {
            $inline = ConvertFrom-PSMMJson -Json $inlineJson
            $script:PSMM_Legends['<profile inline>'] = $inline.Legend
            if ($inline.Includes.Count) {
                $script:PSMM_Warnings.Add('Includes in the inline profile JSON are ignored - only the main config may include files')
            }
            $script:PSMM_FileMeta['<profile inline>'] = [pscustomobject]@{
                Path = '<profile inline>'; Kind = 'inline'; Enabled = $inline.Enabled; HasEnabled = $inline.HasEnabled
                Includes = @(); IncludesIgnored = ($inline.Includes.Count -gt 0)
                Writable = $false; ModuleCount = @($inline.Modules).Count; RawModules = @($inline.Modules)
            }
            if ($inline.Enabled) {
                foreach ($m in $inline.Modules) {
                    $e = Resolve-PSMMEntry -Raw $m -Source '<profile inline>' -Writable $false
                    $e | Add-Member -NotePropertyName FromMain -NotePropertyValue $false -Force
                    $e | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force
                    $all.Add($e)
                }
            }
        } catch { Write-Warning "psmm: inline JSON parse failed: $($_.Exception.Message)" }
    }

    # 2 + 3. main config and its (one-level) includes
    $includes = & $loadFile (Get-PSMMMainConfigPath) 'main' $true
    foreach ($inc in $includes) {
        $p = $inc
        try { $p = [System.Environment]::ExpandEnvironmentVariables($inc) } catch { }
        if ($p -match '^~') { $p = Join-Path $HOME ($p -replace '^~[\\/]?', '') }
        if (Test-Path -LiteralPath $p -PathType Leaf) { $null = & $loadFile $p 'include' $false }
        else { $script:PSMM_Warnings.Add("included config not found: '$inc'") }
    }

    # 4. profile-dir config
    $profileCfg = Get-PSMMProfileConfigPath
    if ($profileCfg) { $null = & $loadFile $profileCfg 'profile' $false }

    # 5. legacy globs
    foreach ($glob in (Get-PSMMLegacyGlobs)) {
        $files = @(); try { $files = @(Get-ChildItem -Path $glob -File -ErrorAction SilentlyContinue) } catch { }
        foreach ($f in $files) { $null = & $loadFile $f.FullName 'legacy' $false }
    }

    $script:PSMM_AllEntries = $all

    # ---- dedupe with precedence: main wins; otherwise first wins ----
    $final  = [System.Collections.Generic.List[object]]::new()
    $byName = @{}
    foreach ($e in $all) {
        if (-not $e.FileEnabled) { continue }                       # disabled file: not active
        if ([string]::IsNullOrWhiteSpace($e.Name)) { $final.Add($e); continue }
        $leafE = if ($e.Source -eq '<profile inline>') { 'profile inline' } else { Split-Path $e.Source -Leaf }
        if (-not $byName.ContainsKey($e.Name)) { $byName[$e.Name] = $e; $final.Add($e); continue }
        $cur = $byName[$e.Name]
        $leafC = if ($cur.Source -eq '<profile inline>') { 'profile inline' } else { Split-Path $cur.Source -Leaf }
        if ($e.FromMain -and -not $cur.FromMain) {
            $idx = $final.IndexOf($cur); $final[$idx] = $e; $byName[$e.Name] = $e
            $script:PSMM_Warnings.Add("conflict: '$($e.Name)' also defined in $leafC - main config wins")
        } elseif ($cur.FromMain) {
            $script:PSMM_Warnings.Add("conflict: '$($e.Name)' in $leafE overridden by main config")
        } else {
            $script:PSMM_Warnings.Add("ERROR conflict: '$($e.Name)' defined in $leafC and $leafE - using $leafC, fix your configs")
        }
    }
    return $final
}

# ---- engine-state accessors (UI + tests use these, never the vars) ----
# Get-PSMMAllEntries returns a plain array (safe to pipe); mutation goes
# through Add-PSMMAllEntry / Set-PSMMAllEntries so the backing list stays
# consistent.

function Get-PSMMAllEntries  { if ($script:PSMM_AllEntries) { @($script:PSMM_AllEntries) } else { @() } }
function Get-PSMMFileMeta    { $script:PSMM_FileMeta }
function Get-PSMMLegend      { param([string]$Path) $script:PSMM_Legends[$Path] }
function Get-PSMMWarning     { if ($script:PSMM_Warnings) { @($script:PSMM_Warnings) } else { @() } }
function Add-PSMMAllEntry    { param([Parameter(Mandatory)] $Entry) $script:PSMM_AllEntries.Add($Entry) }
function Set-PSMMAllEntries  {
    param([Parameter(Mandatory)] $Entries)
    $l = [System.Collections.Generic.List[object]]::new()
    foreach ($e in $Entries) { $l.Add($e) }
    $script:PSMM_AllEntries = $l
}