src/Appliers.ps1

# Appliers.ps1 - write theme into the 4 layers.
# Layer 1 Windows Terminal -> settings.json (hot-reloads instantly)
# Layer 2 PSReadLine -> $PROFILE managed block + live session
# Layer 3 $PSStyle output -> $PROFILE managed block + live session
# Layer 4 oh-my-posh prompt -> $PROFILE managed block + live session

$script:BlockStart = '# >>> PoshPalette >>>'
$script:BlockEnd   = '# <<< PoshPalette <<<'

# --- Layer 1: Windows Terminal ------------------------------------------------

function Get-WindowsTerminalSettingsPath {
    if (-not $IsWindows -and $PSVersionTable.PSEdition -ne 'Desktop') { return $null }
    $candidates = @(
        "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json",
        "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json",
        "$env:APPDATA\Microsoft\Windows Terminal\settings.json"  # unpackaged / scoop
    )
    $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
}

function Backup-PoshPaletteFile {
    param([string] $Path)
    if ($Path -and (Test-Path $Path)) {
        $stamp  = (Get-Date -Format 'yyyyMMdd-HHmmss')
        $backup = "$Path.poshpalette-$stamp.bak"
        Copy-Item $Path $backup -Force
        return $backup
    }
}

# Build the hashtable we upsert into settings.schemes / settings.profiles.defaults.
function Get-PoshPaletteTerminalEdits {
    param($Theme)
    $scheme = @{}
    $Theme.terminal.scheme.psobject.Properties | ForEach-Object { $scheme[$_.Name] = $_.Value }
    if (-not $scheme['name']) { $scheme['name'] = $Theme.name }
    @{
        SchemeName = $scheme['name']
        Scheme     = $scheme
        Defaults   = [ordered]@{
            colorScheme = $scheme['name']
            opacity     = $Theme.terminal.opacity
            useAcrylic  = [bool]$Theme.terminal.useAcrylic
            font        = [ordered]@{ face = $Theme.terminal.font; size = $Theme.terminal.fontSize }
        }
    }
}

# Menu (up/down + Enter) asking whether to install a missing font.
# Returns 'Install' or 'Keep'.
function Show-PoshPaletteFontInstallMenu {
    param([Parameter(Mandatory)][string] $Face)
    $options = @(
        [pscustomobject]@{ Action = 'Install'; Title = "Install '$Face' now"
            Desc = 'Download and install it (per-user, no admin), then set it as your font.' }
        [pscustomobject]@{ Action = 'Keep'; Title = 'Keep my current font'
            Desc = 'Apply the theme but leave the terminal font as it is.' }
    )
    $header = {
        Write-Host " This theme uses '$Face', which isn't installed." -ForegroundColor Yellow
        Write-Host ""
    }.GetNewClosure()
    Show-PoshPaletteChoice -Options $options -RenderHeader $header
}

# When a theme's font isn't installed, offer to install it in-session (per-user,
# no admin) and keep it. Returns $true if the font is available afterwards (so the
# caller applies it), $false to fall back to the current terminal font.
function Confirm-PoshPaletteFontInstall {
    param([Parameter(Mandatory)][string] $Face)

    # Non-Windows can't read the font registry, so Test-* returns $true there and
    # we never reach this; on Windows, bail early if it's already present.
    if (Test-PoshPaletteFontInstalled $Face) { return $true }

    # Need the catalog id to know which Nerd Font asset to fetch.
    $fontId = (Get-PoshPaletteFonts | Where-Object { $_.face -eq $Face -or $_.name -eq $Face } | Select-Object -First 1).id

    $interactive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected
    if (-not $interactive -or -not $fontId) {
        Write-Host " Font '$Face' is not installed - keeping your current terminal font." -ForegroundColor Yellow
        if ($fontId) { Write-Host " Install it with: Install-PoshPaletteFont $fontId" -ForegroundColor DarkGray }
        else { Write-Host " Install a matching Nerd Font, then set it as your terminal font." -ForegroundColor DarkGray }
        return $false
    }

    $choice = Show-PoshPaletteFontInstallMenu -Face $Face
    if ($choice -ne 'Install') {
        Write-Host " Keeping your current terminal font. Install later with: Install-PoshPaletteFont $fontId" -ForegroundColor DarkGray
        return $false
    }

    try {
        Install-PoshPaletteFont $fontId
    } catch {
        Write-Host " Font install failed: $_" -ForegroundColor Red
        Write-Host " Keeping your current terminal font." -ForegroundColor DarkGray
        return $false
    }

    if (Test-PoshPaletteFontInstalled $Face) {
        Write-Host " '$Face' installed and set as your terminal font." -ForegroundColor Green
    } else {
        # Files copied but the registry hasn't caught up yet (rare). Apply anyway -
        # Windows Terminal will pick it up, worst case after a restart.
        Write-Host " '$Face' installed and set. Restart the terminal if glyphs look off." -ForegroundColor Yellow
    }
    return $true
}

# Comment-preserving write: edit only the spans that change, so the user's
# comments, key order, and formatting survive. Falls back to a parse->reserialize
# round-trip if the file is too irregular to edit surgically.
function Set-PoshPaletteTerminalLayer {
    param($Theme, [string] $SettingsPath, [switch] $DryRun)

    $edits      = Get-PoshPaletteTerminalEdits $Theme
    $schemeName = $edits.SchemeName

    # Don't set a font that isn't installed - that's what makes Windows Terminal
    # pop the "Unable to find the following fonts" warning. Offer to install it
    # in-session; if the user declines (or it's unavailable), keep their font.
    $face = $edits.Defaults.font.face
    if ($face -and -not (Test-PoshPaletteFontInstalled $face)) {
        if ($DryRun) {
            $fontId = (Get-PoshPaletteFonts | Where-Object { $_.face -eq $face -or $_.name -eq $face } | Select-Object -First 1).id
            $hint = if ($fontId) { " (would offer to install '$fontId')" } else { '' }
            Write-Host " [dry-run] Font '$face' is not installed$hint." -ForegroundColor DarkGray
            $edits.Defaults.Remove('font')
        } elseif (-not (Confirm-PoshPaletteFontInstall $face)) {
            $edits.Defaults.Remove('font')
        }
    }

    if ($DryRun) {
        Write-Host " [dry-run] would write Terminal scheme '$($Theme.name)' to $SettingsPath" -ForegroundColor DarkGray
        return
    }

    $text = Get-Content $SettingsPath -Raw
    $wrote = $false
    try {
        $rootOpen = $text.IndexOf('{')
        if ($rootOpen -lt 0) { throw 'no root object' }

        # profiles.defaults.{colorScheme,opacity,useAcrylic,font}
        $prof = Find-JsoncMember $text $rootOpen 'profiles'
        if (-not $prof) {
            $text = Set-JsoncMember $text $rootOpen 'profiles' '{ "defaults": {} }'
            $prof = Find-JsoncMember $text $rootOpen 'profiles'
        }
        $profOpen = $text.IndexOf('{', $prof.ValueStart)
        $defM = Find-JsoncMember $text $profOpen 'defaults'
        if (-not $defM) {
            $text = Set-JsoncMember $text $profOpen 'defaults' '{}'
            $defM = Find-JsoncMember $text $profOpen 'defaults'
        }
        $defOpen = $text.IndexOf('{', $defM.ValueStart)
        $text = Set-JsoncMember $text $defOpen 'colorScheme' ('"' + $schemeName + '"')
        $defOpen = $text.IndexOf('{', (Find-JsoncMember $text $profOpen 'defaults').ValueStart)
        $text = Set-JsoncMember $text $defOpen 'opacity' ([string]$edits.Defaults.opacity)
        $defOpen = $text.IndexOf('{', (Find-JsoncMember $text $profOpen 'defaults').ValueStart)
        $text = Set-JsoncMember $text $defOpen 'useAcrylic' ($edits.Defaults.useAcrylic.ToString().ToLower())
        if ($edits.Defaults.Contains('font')) {
            $defOpen = $text.IndexOf('{', (Find-JsoncMember $text $profOpen 'defaults').ValueStart)
            $fontJson = ($edits.Defaults.font | ConvertTo-Json -Depth 5 -Compress)
            $text = Set-JsoncMember $text $defOpen 'font' $fontJson
        }

        # schemes[] upsert by name
        $rootOpen = $text.IndexOf('{')
        $schM = Find-JsoncMember $text $rootOpen 'schemes'
        if (-not $schM) {
            $text = Set-JsoncMember $text $rootOpen 'schemes' '[]'
            $schM = Find-JsoncMember $text $rootOpen 'schemes'
        }
        $arrOpen = $text.IndexOf('[', $schM.ValueStart)
        $schemeJson = ($edits.Scheme | ConvertTo-Json -Depth 5)
        $text = Set-JsoncArrayItemByName $text $arrOpen $schemeName $schemeJson

        # validate before committing; if it doesn't parse, fall back
        $null = ConvertFrom-Jsonc $text
        Set-Content -Path $SettingsPath -Value $text -Encoding utf8
        $wrote = $true
    } catch {
        Write-Verbose "Surgical JSONC edit failed ($_); falling back to round-trip."
    }

    if (-not $wrote) {
        $settings = ConvertFrom-Jsonc (Get-Content $SettingsPath -Raw) -AsHashtable
        if (-not $settings.schemes) { $settings.schemes = @() }
        $settings.schemes = @($settings.schemes | Where-Object { $_.name -ne $schemeName }) + $edits.Scheme
        if (-not $settings.profiles)          { $settings.profiles = @{} }
        if (-not $settings.profiles.defaults) { $settings.profiles.defaults = @{} }
        $d = $settings.profiles.defaults
        $d.colorScheme = $schemeName
        $d.opacity     = $edits.Defaults.opacity
        $d.useAcrylic  = $edits.Defaults.useAcrylic
        if ($edits.Defaults.Contains('font')) { $d.font = @{ face = $edits.Defaults.font.face; size = $edits.Defaults.font.size } }
        Set-Content -Path $SettingsPath -Value ($settings | ConvertTo-Json -Depth 32) -Encoding utf8
    }
}

# --- Per-profile overrides ----------------------------------------------------
#
# PoshPalette writes the theme to profiles.defaults. A profile that sets any of
# these keys itself overrides the default, so it keeps its old look (color,
# font, opacity, acrylic) no matter which theme you apply. These helpers find
# such profiles and (on request) clear the overrides so the profile falls back
# to the theme in defaults.
$script:PPManagedProfileKeys = @('colorScheme', 'font', 'opacity', 'useAcrylic')

# Friendlier labels for the keys, shown in the menu so it's clear what changes.
function Format-PoshPaletteOverrideKeys {
    param([string[]] $Keys)
    $map = @{ colorScheme = 'color scheme'; font = 'font'; opacity = 'opacity'; useAcrylic = 'acrylic' }
    (($Keys | ForEach-Object { if ($map.ContainsKey($_)) { $map[$_] } else { $_ } })) -join ', '
}

function Get-PoshPaletteProfileOverrides {
    param([string] $SettingsPath)
    if (-not $SettingsPath -or -not (Test-Path $SettingsPath)) { return @() }
    $settings = try { ConvertFrom-Jsonc (Get-Content $SettingsPath -Raw) } catch { return @() }
    $list = $settings.profiles.list
    if (-not $list) { return @() }
    $default = $settings.defaultProfile
    @($list | ForEach-Object {
        $names = $_.psobject.Properties.Name
        $keys  = @($script:PPManagedProfileKeys | Where-Object { $names -contains $_ })
        if ($keys.Count) {
            [pscustomobject]@{
                Name      = $_.name
                Guid      = $_.guid
                Keys      = $keys
                IsDefault = ($_.guid -eq $default)
            }
        }
    })
}

# Remove every managed key from the profiles whose guids are listed. Re-reads
# offsets after each edit (removal shifts the text) and validates before writing.
function Clear-PoshPaletteProfileOverrides {
    param([string] $SettingsPath, [string[]] $Guids)
    if (-not $Guids -or -not $Guids.Count) { return }
    $text = Get-Content $SettingsPath -Raw
    foreach ($g in $Guids) {
        foreach ($key in $script:PPManagedProfileKeys) {
            $rootOpen = $text.IndexOf('{')
            $prof = Find-JsoncMember $text $rootOpen 'profiles'
            if (-not $prof) { continue }
            $profOpen = $text.IndexOf('{', $prof.ValueStart)
            $listM = Find-JsoncMember $text $profOpen 'list'
            if (-not $listM) { continue }
            $arrOpen = $text.IndexOf('[', $listM.ValueStart)
            $objOpen = Find-JsoncArrayObjectByMember $text $arrOpen 'guid' $g
            if ($objOpen -ge 0) { $text = Remove-JsoncMember $text $objOpen $key }
        }
    }
    try {
        $null = ConvertFrom-Jsonc $text
        Set-Content -Path $SettingsPath -Value $text -Encoding utf8
    } catch {
        Write-Verbose "Clearing profile overrides produced invalid JSON; leaving settings unchanged ($_)."
    }
}

# Generic up/down + Enter chooser used by every interactive confirmation, so they
# all look and behave the same (arrow keys, never typed input). $RenderHeader
# prints whatever context belongs above the list (it runs after a Clear-Host, so
# the menu owns the whole screen); $Options is an array of objects with Action and
# Title, plus an optional Desc line. Returns the chosen Action.
function Show-PoshPaletteChoice {
    param(
        [Parameter(Mandatory)] $Options,
        [scriptblock] $RenderHeader,
        [int] $Default = 0
    )
    $idx = [Math]::Max(0, [Math]::Min($Default, $Options.Count - 1))
    [Console]::CursorVisible = $false
    try {
        while ($true) {
            Clear-Host
            Write-Host ""
            if ($RenderHeader) { & $RenderHeader }
            for ($i = 0; $i -lt $Options.Count; $i++) {
                $sel = ($i -eq $idx)
                $marker = if ($sel) { '>' } else { ' ' }
                $color  = if ($sel) { 'Cyan' } else { 'Gray' }
                Write-Host (" {0} {1}" -f $marker, $Options[$i].Title) -ForegroundColor $color
                if ($Options[$i].Desc) { Write-Host (" {0}" -f $Options[$i].Desc) -ForegroundColor DarkGray }
            }
            Write-Host ""
            Write-Host " up/down move · Enter select" -ForegroundColor DarkGray

            $key = [Console]::ReadKey($true)
            switch ($key.Key) {
                'UpArrow'   { $idx = ($idx - 1 + $Options.Count) % $Options.Count }
                'DownArrow' { $idx = ($idx + 1) % $Options.Count }
                'Enter'     { return $Options[$idx].Action }
            }
        }
    } finally { [Console]::CursorVisible = $true }
}

# Menu shown when per-profile overrides would shadow the theme.
# Returns the chosen action: 'ThisProfile' | 'AllProfiles' | 'Keep'.
function Show-PoshPaletteOverrideMenu {
    param($Overrides, [string] $ThemeName)

    $default = $Overrides | Where-Object IsDefault | Select-Object -First 1
    $thisTitle = if ($default) { "This profile only ($($default.Name))" } else { 'This profile only' }
    $options = @(
        [pscustomobject]@{ Action = 'ThisProfile'; Title = $thisTitle
            Desc = "Clear the overrides on your default profile so it follows '$ThemeName'." }
        [pscustomobject]@{ Action = 'AllProfiles'; Title = "All profiles ($($Overrides.Count))"
            Desc = 'Clear the overrides on every profile below so all tabs follow the theme.' }
        [pscustomobject]@{ Action = 'Keep'; Title = 'Leave them alone'
            Desc = 'Apply to defaults only; these profiles keep their look (theme may not fully apply).' }
    )

    $nameWidth = (@($Overrides | ForEach-Object { $_.Name.Length }) + 0 | Measure-Object -Maximum).Maximum
    $header = {
        Write-Host " These Windows Terminal profiles set their own look and will partly" -ForegroundColor Yellow
        Write-Host " ignore '$ThemeName':" -ForegroundColor Yellow
        Write-Host ""
        foreach ($o in $Overrides) {
            $tag = if ($o.IsDefault) { ' (your default)' } else { '' }
            Write-Host (" - {0} -> {1}{2}" -f $o.Name.PadRight($nameWidth), (Format-PoshPaletteOverrideKeys $o.Keys), $tag) -ForegroundColor Gray
        }
        Write-Host ""
        Write-Host " How should PoshPalette handle them?" -ForegroundColor White
        Write-Host ""
    }.GetNewClosure()

    Show-PoshPaletteChoice -Options $options -RenderHeader $header
}

# Decide and carry out the override handling for an apply. $Action 'Prompt' shows
# the menu when a console is attached, else falls back to 'Keep' (never edits
# profiles silently when no one can confirm).
function Resolve-PoshPaletteProfileOverrides {
    param([string] $SettingsPath, $Theme, [string] $Action, [switch] $Quiet)

    $overrides = Get-PoshPaletteProfileOverrides -SettingsPath $SettingsPath
    if (-not $overrides.Count) { return }

    $interactive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected
    if ($Action -eq 'Prompt') {
        $Action = if ($interactive) { Show-PoshPaletteOverrideMenu -Overrides $overrides -ThemeName $Theme.name } else { 'Keep' }
    }

    switch ($Action) {
        'ThisProfile' {
            $guids = @($overrides | Where-Object IsDefault | ForEach-Object Guid)
            if ($guids.Count) {
                Clear-PoshPaletteProfileOverrides -SettingsPath $SettingsPath -Guids $guids
                if (-not $Quiet) { Write-Host " Cleared overrides on your default profile so it follows the theme." -ForegroundColor Green }
            } elseif (-not $Quiet) {
                Write-Host " Your default profile already follows the theme; left the others as they are." -ForegroundColor DarkGray
            }
        }
        'AllProfiles' {
            Clear-PoshPaletteProfileOverrides -SettingsPath $SettingsPath -Guids @($overrides | ForEach-Object Guid)
            if (-not $Quiet) { Write-Host " Cleared overrides on $($overrides.Count) profile(s) so all tabs follow the theme." -ForegroundColor Green }
        }
        default {
            if (-not $Quiet) {
                Write-Host " Left per-profile overrides in place - those profiles keep their own look." -ForegroundColor DarkGray
            }
        }
    }
}

# --- Layers 2-4: the $PROFILE managed block -----------------------------------

function New-PoshPaletteProfileBlock {
    param($Theme, [switch] $DryRun)
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine($script:BlockStart)
    [void]$sb.AppendLine('# Managed by PoshPalette - edit via the app, not by hand.')

    # Layer 2: PSReadLine input colors
    [void]$sb.AppendLine('if (Get-Module -ListAvailable PSReadLine) {')
    [void]$sb.AppendLine(' Set-PSReadLineOption -Colors @{')
    foreach ($p in $Theme.psReadLine.psobject.Properties) {
        [void]$sb.AppendLine(" $($p.Name) = '$($p.Value)'")
    }
    [void]$sb.AppendLine(' }')
    [void]$sb.AppendLine('}')

    # Layer 3: $PSStyle output colors (PS 7.2+)
    [void]$sb.AppendLine('if ($PSStyle) {')
    if ($Theme.psStyle.Directory)   { [void]$sb.AppendLine(" `$PSStyle.FileInfo.Directory = `$PSStyle.Foreground.FromRgb('$($Theme.psStyle.Directory)')") }
    if ($Theme.psStyle.Error)       { [void]$sb.AppendLine(" `$PSStyle.Formatting.Error = `$PSStyle.Foreground.FromRgb('$($Theme.psStyle.Error)')") }
    if ($Theme.psStyle.TableHeader) {
        [void]$sb.AppendLine(" `$PSStyle.Formatting.TableHeader = `$PSStyle.Foreground.FromRgb('$($Theme.psStyle.TableHeader)')")
        # PS 7.4+ styles calculated column headers (e.g. Length in a dir listing)
        # with a separate green-by-default property; keep the header row uniform.
        [void]$sb.AppendLine(" if (`$PSStyle.Formatting.PSObject.Properties['CustomTableHeaderLabel']) { `$PSStyle.Formatting.CustomTableHeaderLabel = `$PSStyle.Foreground.FromRgb('$($Theme.psStyle.TableHeader)') }")
    }
    [void]$sb.AppendLine('}')

    # Layer 4: oh-my-posh prompt. A generated ('auto') prompt is written to a
    # managed config file and referenced by absolute path; a referenced theme
    # resolves against $env:POSH_THEMES_PATH at load time.
    if ($Theme.prompt.generated) {
        $cfgPath = if ($DryRun) { Join-Path $HOME '.poshpalette/prompts/pp-auto.omp.json' }
                   else { Save-PoshPalettePrompt -Config $Theme.prompt.config -Name $Theme.prompt.name }
        [void]$sb.AppendLine('if (Get-Command oh-my-posh -ErrorAction SilentlyContinue) {')
        [void]$sb.AppendLine(" oh-my-posh init pwsh --config `"$cfgPath`" | Invoke-Expression")
        [void]$sb.AppendLine('}')
    }
    elseif ($Theme.prompt.ohMyPoshTheme) {
        [void]$sb.AppendLine('if (Get-Command oh-my-posh -ErrorAction SilentlyContinue) {')
        [void]$sb.AppendLine(" oh-my-posh init pwsh --config `"`$env:POSH_THEMES_PATH\$($Theme.prompt.ohMyPoshTheme).omp.json`" | Invoke-Expression")
        [void]$sb.AppendLine('}')
    }
    [void]$sb.AppendLine($script:BlockEnd)
    $sb.ToString()
}

function Set-PoshPaletteProfileLayer {
    param($Theme, [string] $ProfilePath = $PROFILE, [switch] $DryRun)

    $block   = New-PoshPaletteProfileBlock $Theme -DryRun:$DryRun
    $existing = if (Test-Path $ProfilePath) { Get-Content $ProfilePath -Raw } else { '' }

    # Replace an existing managed block, otherwise append. Idempotent re-apply.
    $pattern = "(?s)$([regex]::Escape($script:BlockStart)).*?$([regex]::Escape($script:BlockEnd))"
    $updated = if ($existing -match $pattern) {
        [regex]::Replace($existing, $pattern, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $block.TrimEnd() })
    } else {
        ($existing.TrimEnd() + "`n`n" + $block).TrimStart()
    }

    if ($DryRun) {
        Write-Host " [dry-run] would update PoshPalette block in $ProfilePath" -ForegroundColor DarkGray
        return
    }
    $dir = Split-Path $ProfilePath -Parent
    if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
    Set-Content -Path $ProfilePath -Value $updated -Encoding utf8

    # Apply to the *current* session too, so the change is visible immediately.
    $inner = ($block -split "`n" | Where-Object { $_ -notmatch '^\s*#' }) -join "`n"
    try { . ([scriptblock]::Create($inner)) } catch { Write-Verbose "Live apply skipped: $_" }
}

# --- oh-my-posh dependency ----------------------------------------------------

# Only the prompt layer needs the oh-my-posh binary, and only when the theme
# actually drives it (a generated 'auto' prompt or a referenced community theme).
function Test-PoshPaletteThemeUsesOhMyPosh {
    param($Theme)
    [bool]($Theme.prompt -and ($Theme.prompt.generated -or $Theme.prompt.ohMyPoshTheme))
}

# If this theme's prompt needs oh-my-posh and it isn't installed, offer to install
# it (per-user via winget - no admin). The other three layers apply regardless, so
# this is always optional: declining just leaves the prompt layer dormant until the
# binary appears (the $PROFILE block is guarded with `if (Get-Command oh-my-posh)`).
function Confirm-PoshPaletteOhMyPosh {
    param($Theme)

    if (-not (Test-PoshPaletteThemeUsesOhMyPosh $Theme)) { return }
    if (Get-Command oh-my-posh -ErrorAction SilentlyContinue) { return }

    $intro = {
        Write-Host " This theme's prompt needs oh-my-posh, which isn't installed yet." -ForegroundColor Yellow
        Write-Host " (Terminal colors and input/output colors still apply without it.)" -ForegroundColor DarkGray
    }

    # No winget (older Windows, non-Windows): point at the official user-scope script.
    if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
        Write-Host ""; & $intro
        Write-Host " Install it (no admin needed), then re-open pwsh:" -ForegroundColor Gray
        Write-Host " Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object Net.WebClient).DownloadString('https://ohmyposh.dev/install.ps1'))" -ForegroundColor Cyan
        return
    }

    # Non-interactive (CI / piped input): never block on a menu - just print it.
    if (-not ([Environment]::UserInteractive -and -not [Console]::IsInputRedirected)) {
        Write-Host ""; & $intro
        Write-Host " Install it (no admin needed) with:" -ForegroundColor Gray
        Write-Host " winget install JanDeDobbeleer.OhMyPosh -s winget" -ForegroundColor Cyan
        return
    }

    $choice = Show-PoshPaletteChoice -RenderHeader { & $intro; Write-Host "" }.GetNewClosure() -Options @(
        [pscustomobject]@{ Action = 'Install'; Title = 'Install oh-my-posh now'
            Desc = 'Install it with winget (per-user, no admin), then activate the prompt.' }
        [pscustomobject]@{ Action = 'Skip'; Title = 'Skip for now'
            Desc = 'Apply the theme now; the prompt activates once oh-my-posh is installed.' }
    )
    if ($choice -ne 'Install') {
        Write-Host " Skipped. Install it later with: winget install JanDeDobbeleer.OhMyPosh -s winget" -ForegroundColor DarkGray
        return
    }

    Write-Host " Installing oh-my-posh (per-user, no admin)..." -ForegroundColor Cyan
    try {
        # Plain per-user install - do NOT elevate; an admin winget puts it under
        # Program Files and breaks the per-user PATH/POSH_THEMES_PATH expectation.
        winget install JanDeDobbeleer.OhMyPosh --source winget --accept-source-agreements --accept-package-agreements
    } catch {
        Write-Host " winget failed: $_" -ForegroundColor Red
        Write-Host " Install manually, then re-open pwsh: winget install JanDeDobbeleer.OhMyPosh -s winget" -ForegroundColor DarkGray
        return
    }

    if (Get-Command oh-my-posh -ErrorAction SilentlyContinue) {
        Write-Host " oh-my-posh installed and on PATH." -ForegroundColor Green
    } else {
        # winget updates the user PATH (and POSH_THEMES_PATH) but the running
        # session won't see them - the guarded $PROFILE block picks it up next launch.
        Write-Host " oh-my-posh installed. Re-open pwsh (or restart your terminal) so it lands on PATH and the prompt activates." -ForegroundColor Yellow
    }
}

# --- Orchestrator -------------------------------------------------------------

function Set-PoshPaletteTheme {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Theme,
        [string] $SettingsPath = (Get-WindowsTerminalSettingsPath),
        [string] $ProfilePath  = $PROFILE,
        [switch] $DryRun,
        [switch] $Quiet,  # the TUI shows its own confirmation panel instead
        # How to handle profiles that pin their own colorScheme (and so ignore the
        # theme). 'Prompt' asks via an up/down menu when a console is attached, and
        # falls back to 'Keep' when it can't (CI, piped input).
        [ValidateSet('Prompt', 'ThisProfile', 'AllProfiles', 'Keep')]
        [string] $ProfileOverride = 'Prompt'
    )

    if (-not $Quiet) { Write-Host "Applying '$($Theme.name)'..." -ForegroundColor Cyan }

    if ($SettingsPath) {
        if (-not $DryRun) { Backup-PoshPaletteFile $SettingsPath | Out-Null }
        Set-PoshPaletteTerminalLayer -Theme $Theme -SettingsPath $SettingsPath -DryRun:$DryRun
        if (-not $Quiet) { Write-Host " Terminal scheme applied (hot-reloads instantly)" -ForegroundColor Green }
        # A per-profile colorScheme would shadow what we just wrote to defaults.
        if (-not $DryRun) {
            Resolve-PoshPaletteProfileOverrides -SettingsPath $SettingsPath -Theme $Theme -Action $ProfileOverride -Quiet:$Quiet
        }
    } elseif (-not $Quiet) {
        Write-Host " Windows Terminal settings.json not found - skipping Terminal layer." -ForegroundColor Yellow
        Write-Host " (This is expected when not on Windows / Windows Terminal.)" -ForegroundColor DarkGray
    }

    # Offer to install oh-my-posh if this theme's prompt needs it (skip on dry-run).
    if (-not $DryRun) { Confirm-PoshPaletteOhMyPosh -Theme $Theme }

    if (-not $DryRun) { Backup-PoshPaletteFile $ProfilePath | Out-Null }
    Set-PoshPaletteProfileLayer -Theme $Theme -ProfilePath $ProfilePath -DryRun:$DryRun
    if (-not $Quiet) {
        Write-Host " Prompt + input/output colors applied" -ForegroundColor Green
        Write-Host "Done. Open a new tab if the prompt didn't refresh." -ForegroundColor Cyan
    }
}

# --- Revert -------------------------------------------------------------------

# Backups are named "<file>.poshpalette-<timestamp>.bak" next to the original.
function Get-PoshPaletteBackups {
    param([string] $Path)
    if (-not $Path) { return @() }
    $dir  = Split-Path $Path -Parent
    $leaf = Split-Path $Path -Leaf
    Get-ChildItem -Path $dir -Filter "$leaf.poshpalette-*.bak" -ErrorAction SilentlyContinue |
        Sort-Object LastWriteTime -Descending
}

function Restore-PoshPalette {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string] $SettingsPath = (Get-WindowsTerminalSettingsPath),
        [string] $ProfilePath  = $PROFILE,
        [switch] $KeepProfileBlock   # only revert Terminal settings, leave the profile block
    )

    Write-Host "Reverting PoshPalette..." -ForegroundColor Cyan

    # 1. Restore Windows Terminal settings.json from the newest backup.
    if ($SettingsPath) {
        $bak = Get-PoshPaletteBackups -Path $SettingsPath | Select-Object -First 1
        if ($bak -and $PSCmdlet.ShouldProcess($SettingsPath, "Restore from $($bak.Name)")) {
            Copy-Item $bak.FullName $SettingsPath -Force
            Write-Host " Restored Terminal settings from $($bak.Name)" -ForegroundColor Green
        } elseif (-not $bak) {
            Write-Host " No Terminal settings backup found - skipping." -ForegroundColor Yellow
        }
    }

    # 2. Remove the managed profile block (clean revert of layers 2-4).
    if (-not $KeepProfileBlock -and (Test-Path $ProfilePath)) {
        $content = Get-Content $ProfilePath -Raw
        $pattern = "(?s)\r?\n?$([regex]::Escape($script:BlockStart)).*?$([regex]::Escape($script:BlockEnd))\r?\n?"
        if ($content -match $pattern) {
            if ($PSCmdlet.ShouldProcess($ProfilePath, 'Remove PoshPalette block')) {
                $new = ([regex]::Replace($content, $pattern, "`n")).TrimEnd() + "`n"
                Set-Content -Path $ProfilePath -Value $new -Encoding utf8
                Write-Host " Removed PoshPalette block from profile." -ForegroundColor Green
            }
        } else {
            Write-Host " No PoshPalette block in profile." -ForegroundColor Yellow
        }
    }

    Write-Host "Restart PowerShell for the prompt/input/output to fully revert." -ForegroundColor Cyan
}

# --- Reset to default ---------------------------------------------------------

# Force the stock default look (Windows Terminal's Campbell scheme + Cascadia
# Mono, no opacity/acrylic, the default PowerShell prompt) - a clean, repeatable
# baseline, e.g. to demo "before -> after". Unlike Restore-PoshPalette (which
# restores your backup), this sets known defaults regardless of prior state.
function Reset-PoshPaletteTerminalDefaults {
    param([string] $SettingsPath, [switch] $DryRun)
    if (-not $SettingsPath) { return }
    if ($DryRun) { Write-Host " [dry-run] would reset Terminal defaults in $SettingsPath" -ForegroundColor DarkGray; return }
    $text = Get-Content $SettingsPath -Raw
    try {
        $rootOpen = $text.IndexOf('{')
        $prof = Find-JsoncMember $text $rootOpen 'profiles'
        if (-not $prof) { return }
        $profOpen = $text.IndexOf('{', $prof.ValueStart)
        if (-not (Find-JsoncMember $text $profOpen 'defaults')) { return }
        $get = { $text.IndexOf('{', (Find-JsoncMember $text $profOpen 'defaults').ValueStart) }
        $text = Set-JsoncMember $text (& $get) 'colorScheme' '"Campbell"'
        $text = Set-JsoncMember $text (& $get) 'opacity'     '100'
        $text = Set-JsoncMember $text (& $get) 'useAcrylic'  'false'
        $text = Set-JsoncMember $text (& $get) 'font'        '{ "face": "Cascadia Mono" }'
        $null = ConvertFrom-Jsonc $text
        Set-Content -Path $SettingsPath -Value $text -Encoding utf8
    } catch { Write-Verbose "Reset terminal defaults failed: $_" }
}

function Reset-PoshPalette {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string] $SettingsPath = (Get-WindowsTerminalSettingsPath),
        [string] $ProfilePath  = $PROFILE,
        [switch] $DryRun,
        [switch] $Quiet
    )

    if (-not $Quiet) { Write-Host "Resetting to the default look..." -ForegroundColor Cyan }

    # Layer 1: stock Windows Terminal defaults.
    if ($SettingsPath) {
        if (-not $DryRun) { Backup-PoshPaletteFile $SettingsPath | Out-Null }
        Reset-PoshPaletteTerminalDefaults -SettingsPath $SettingsPath -DryRun:$DryRun
        if (-not $Quiet) { Write-Host " Terminal reset to Campbell + Cascadia Mono" -ForegroundColor Green }
    }

    # Layers 2-4: drop the managed block so the prompt/colors fall back to default.
    if (Test-Path $ProfilePath) {
        if (-not $DryRun) { Backup-PoshPaletteFile $ProfilePath | Out-Null }
        $content = Get-Content $ProfilePath -Raw
        $pattern = "(?s)\r?\n?$([regex]::Escape($script:BlockStart)).*?$([regex]::Escape($script:BlockEnd))\r?\n?"
        if ($content -match $pattern) {
            if (-not $DryRun) {
                $new = ([regex]::Replace($content, $pattern, "`n")).TrimEnd() + "`n"
                Set-Content -Path $ProfilePath -Value $new -Encoding utf8
            }
            if (-not $Quiet) { Write-Host " Prompt + input/output colors reset" -ForegroundColor Green }
        }
    }

    if (-not $DryRun) {
        # Forget the active composition so tweaks start fresh.
        $cur = Join-Path $HOME '.poshpalette/current.json'
        if (Test-Path $cur) { Remove-Item $cur -Force }
        # Live session: oh-my-posh installs a prompt function (and POSH_* env). A
        # bare Remove-Item Function:\prompt only drops the copy in the current
        # scope, so a second reset can leave the visible (global) prompt in place.
        # Set the global prompt straight back to PowerShell's default instead, and
        # clear oh-my-posh's env so nothing re-applies it this session.
        try {
            $default = { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }
            Set-Item -Path Function:global:prompt -Value $default -Force -ErrorAction SilentlyContinue
        } catch { }
        Get-ChildItem Env: -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'POSH_*' } |
            ForEach-Object { Remove-Item "Env:\$($_.Name)" -ErrorAction SilentlyContinue }
    }

    if (-not $Quiet) { Write-Host "Done. Terminal colors update now; open a new tab for the default prompt." -ForegroundColor Cyan }
}