src/Theme.ps1

# Theme.ps1 - composition model.
#
# A *theme* is a composition: a set of slot references into per-layer catalogs.
# themes/*.json -> { scheme, palette, prompt, font, opacity, acrylic, fontSize }
# schemes/*.json -> Windows Terminal color scheme (16 ANSI + bg/fg/cursor)
# palettes/*.json -> PSReadLine input colors + $PSStyle output colors
# prompts/*.json -> oh-my-posh theme reference
# fonts.json -> list of installed (nerd) fonts
#
# Resolve-PoshPaletteTheme expands a composition into the flat shape the
# appliers consume (terminal/psReadLine/psStyle/prompt), so Simple mode (pick a
# preset), Detail mode (override one slot) and headless install all share one path.

function Get-PoshPaletteDataRoot { Split-Path $PSScriptRoot -Parent }
function Get-PoshPaletteThemeRoot { Join-Path (Get-PoshPaletteDataRoot) 'themes' }

# Where auto-fetched community entries are cached. The catalog loaders read this
# in addition to the bundled module dir, so new themes pulled from GitHub show up
# without reinstalling. Kept under the user's home so it's always writable and
# survives module updates.
function Get-PoshPaletteUserRoot  { Join-Path $HOME '.poshpalette' }
function Get-PoshPaletteCacheRoot { Join-Path (Get-PoshPaletteUserRoot) 'catalog' }

# --- Catalog loaders ----------------------------------------------------------

# Enumerate the *.json entries for a kind across both roots (bundled first, then
# the user cache), de-duped by id. Bundled wins on a clash, so the cache only
# *adds* new community entries and can never shadow a shipped one.
function Get-PoshPaletteCatalogFiles {
    param([Parameter(Mandatory)][string] $Kind)
    $seen = @{}
    foreach ($root in @((Get-PoshPaletteDataRoot), (Get-PoshPaletteCacheRoot))) {
        $dir = Join-Path $root $Kind
        if (-not (Test-Path $dir)) { continue }
        foreach ($file in (Get-ChildItem -Path $dir -Filter '*.json' -File | Sort-Object Name)) {
            $data = try { Get-Content $file.FullName -Raw | ConvertFrom-Json } catch { $null }
            if (-not $data -or -not $data.id -or $seen.ContainsKey($data.id)) { continue }
            $seen[$data.id] = $true
            [pscustomobject]@{ File = $file; Data = $data }
        }
    }
}

function Get-PoshPaletteCatalog {
    param([Parameter(Mandatory)][ValidateSet('schemes', 'palettes', 'prompts')] [string] $Kind)
    Get-PoshPaletteCatalogFiles -Kind $Kind | ForEach-Object {
        [pscustomobject]@{ Id = $_.Data.id; Name = $_.Data.name; Data = $_.Data }
    }
}

function Get-PoshPaletteCatalogItem {
    param([string] $Kind, [string] $Id)
    $item = Get-PoshPaletteCatalog -Kind $Kind | Where-Object { $_.Id -eq $Id } | Select-Object -First 1
    if (-not $item) { throw "No '$Kind' entry with id '$Id'." }
    $item.Data
}

function Get-PoshPaletteFonts {
    Get-Content (Join-Path (Get-PoshPaletteDataRoot) 'fonts.json') -Raw | ConvertFrom-Json
}

# --- Compositions (presets) ---------------------------------------------------

function Get-PoshPaletteThemes {
    # Merges bundled themes with auto-fetched community ones (see
    # Get-PoshPaletteCatalogFiles). Sorted by the theme's 'order' field (the
    # curated sequence the web gallery also uses); entries without an order fall
    # to the end (where new community themes land), then by name as a tiebreak.
    Get-PoshPaletteCatalogFiles -Kind 'themes' | ForEach-Object {
        $data = $_.Data
        [pscustomobject]@{
            Id          = $data.id
            Name        = $data.name
            Description = $data.description
            Order       = if ($null -ne $data.order) { [int]$data.order } else { [int]::MaxValue }
            Path        = $_.File.FullName
            Data        = $data   # the composition
        }
    } | Sort-Object Order, Name
}

# --- Resolver -----------------------------------------------------------------

function ConvertTo-PoshPaletteHashtable {
    param($Object)
    $h = @{}
    if ($Object) { $Object.psobject.Properties | ForEach-Object { $h[$_.Name] = $_.Value } }
    $h
}

# Expand a composition into the flat shape the appliers expect. Returns a
# PSCustomObject identical in shape to a hand-written full theme.
function Resolve-PoshPaletteTheme {
    param([Parameter(Mandatory)] $Composition)

    $scheme  = Get-PoshPaletteCatalogItem -Kind 'schemes'  -Id $Composition.scheme
    $palette = Get-PoshPaletteCatalogItem -Kind 'palettes' -Id $Composition.palette
    # A prompt may be a catalog id, or a bare oh-my-posh theme name typed directly
    # (same as you'd pass to a command); an unknown id is treated as that name.
    $prompt  = Get-PoshPaletteCatalog -Kind 'prompts' | Where-Object { $_.Id -eq $Composition.prompt } | Select-Object -First 1 | ForEach-Object Data
    # A font may be a fonts.json id or a literal font face name typed directly.
    $font    = Get-PoshPaletteFonts | Where-Object { $_.id -eq $Composition.font } | Select-Object -First 1
    if (-not $font) { $font = [pscustomobject]@{ id = $Composition.font; name = $Composition.font; face = $Composition.font; nerd = $Composition.font } }

    $schemeBlock = ConvertTo-PoshPaletteHashtable $scheme.colors
    $schemeBlock['name'] = $scheme.name   # WT scheme is named after the scheme, not the composition

    # A prompt is either a reference to a fixed-color oh-my-posh theme, or 'auto',
    # which generates a config from this scheme's colors so the prompt matches.
    $promptBlock = if ($prompt -and $prompt.generate) {
        $style = if ($prompt.style) { $prompt.style } else { 'classic' }
        @{ generated = $true; name = "pp-$($prompt.id)"; config = (New-PoshPaletteOmpConfig $scheme.colors -Style $style) }
    } elseif ($prompt) {
        @{ ohMyPoshTheme = $prompt.ohMyPoshTheme }
    } else {
        @{ ohMyPoshTheme = $Composition.prompt }   # typed-in oh-my-posh theme name
    }

    $resolved = @{
        name     = $Composition.name
        terminal = @{
            font       = $font.face
            fontSize   = ($Composition.fontSize ?? 11)
            opacity    = ($Composition.opacity ?? 100)
            useAcrylic = [bool]($Composition.acrylic ?? $false)
            scheme     = $schemeBlock
        }
        psReadLine = (ConvertTo-PoshPaletteHashtable $palette.psReadLine)
        psStyle    = (ConvertTo-PoshPaletteHashtable $palette.psStyle)
        prompt     = $promptBlock
    }
    # Round-trip to PSCustomObjects so appliers see the same shape as file themes.
    $resolved | ConvertTo-Json -Depth 32 | ConvertFrom-Json
}

# Resolve a composition by id/name/path into the applier-ready shape.
function Import-PoshPaletteTheme {
    param([Parameter(Mandatory)][string] $NameOrPath)

    $composition = if (Test-Path $NameOrPath) {
        Get-Content $NameOrPath -Raw | ConvertFrom-Json
    } else {
        $match = Get-PoshPaletteThemes | Where-Object { $_.Id -eq $NameOrPath -or $_.Name -eq $NameOrPath } | Select-Object -First 1
        if (-not $match) {
            $available = (Get-PoshPaletteThemes | ForEach-Object Id) -join ', '
            throw "Theme '$NameOrPath' not found. Available: $available"
        }
        $match.Data
    }
    Resolve-PoshPaletteTheme -Composition $composition
}