tintcd.psm1

# tintcd.psm1 — Directory-aware terminal theming
# https://github.com/ymyke/tintcd

#region Configuration

$script:DefaultConfig = @{
    BackgroundLightness = @(0.08, 0.14)   # L range for dark backgrounds
    AccentLightness     = @(0.45, 0.65)   # L range for bright accents
    Saturation          = @(0.35, 0.55)   # S range
    DefaultBackground   = "1e1e1e"        # Reset color (dark gray)
    Enabled             = $true
}

$script:ConfigCache = $null
$script:ConfigPath = $null

# Prompt hook state
$script:TintcdHookEnabled = $false
$script:LastTintcdPath = $null
$script:OriginalPrompt = $null
$script:SkipTintOnce = $false

function Get-TintcdConfig {
    [CmdletBinding()]
    param()

    $configPath = if ($env:TINTCD_CONFIG) { $env:TINTCD_CONFIG } else { Join-Path $HOME ".tintcd.json" }

    # Return cache if path unchanged
    if ($script:ConfigCache -and $script:ConfigPath -eq $configPath) {
        return $script:ConfigCache
    }

    if (Test-Path $configPath) {
        try {
            $userConfig = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable
            # Merge with defaults (fill missing keys)
            $config = $script:DefaultConfig.Clone()
            foreach ($key in $userConfig.Keys) {
                if ($script:DefaultConfig.ContainsKey($key)) {
                    $config[$key] = $userConfig[$key]
                }
            }
            # Validate and clamp config values
            $config = Assert-TintcdConfig $config
            $script:ConfigCache = $config
            $script:ConfigPath = $configPath
            return $config
        }
        catch {
            Write-Warning "tintcd: Failed to parse config at $configPath, using defaults"
        }
    }

    $script:ConfigCache = $script:DefaultConfig.Clone()
    $script:ConfigPath = $configPath
    return $script:ConfigCache
}

function Assert-TintcdConfig {
    param([hashtable]$Config)

    # Validate DefaultBackground: must be 6-char hex (with or without #)
    $bg = $Config.DefaultBackground -replace '^#', ''
    if ($bg -notmatch '^[0-9a-fA-F]{6}$') {
        Write-Warning "tintcd: Invalid DefaultBackground '$($Config.DefaultBackground)', using default"
        $bg = $script:DefaultConfig.DefaultBackground
    }
    $Config.DefaultBackground = $bg.ToLower()

    # Validate ranges: must be 2-element arrays with numeric values 0-1, min < max
    foreach ($key in @('BackgroundLightness', 'AccentLightness', 'Saturation')) {
        $range = $Config[$key]
        $valid = $false
        if ($range -is [array] -and $range.Count -eq 2) {
            # Accept any numeric type (double, int, decimal, etc.) and cast to double
            try {
                $v0 = [double]$range[0]
                $v1 = [double]$range[1]
                $valid = $v0 -ge 0 -and $v0 -le 1 -and $v1 -ge 0 -and $v1 -le 1 -and $v0 -lt $v1
                if ($valid) {
                    $Config[$key] = @($v0, $v1)  # Normalize to double
                }
            }
            catch {
                $valid = $false
            }
        }
        if (-not $valid) {
            Write-Warning "tintcd: Invalid $key range, using default"
            $Config[$key] = $script:DefaultConfig[$key]
        }
    }

    return $Config
}

#endregion

#region Color Math

function Get-PathHash {
    [CmdletBinding()]
    param([string]$Path = (Get-Location).Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return @(0, 0, 0) }

    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        # Case-insensitive on Windows only; use culture-invariant lowercase
        $hashPath = if ($IsWindows) { $Path.ToLowerInvariant() } else { $Path }
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($hashPath)
        $hash = $sha.ComputeHash($bytes)
        return $hash[0..2]  # First 3 bytes (decorrelated: H, S, L)
    }
    finally {
        $sha.Dispose()
    }
}

function Convert-HslToRgb {
    param(
        [double]$H,  # 0-360
        [double]$S,  # 0-1
        [double]$L   # 0-1
    )

    $H = $H / 360.0
    if ($S -eq 0) {
        $r = $g = $b = [int]($L * 255)
    }
    else {
        $q = if ($L -lt 0.5) { $L * (1 + $S) } else { $L + $S - $L * $S }
        $p = 2 * $L - $q

        $h2r = {
            param($p, $q, $t)
            if ($t -lt 0) { $t += 1 }
            if ($t -gt 1) { $t -= 1 }
            if ($t -lt 1 / 6) { return $p + ($q - $p) * 6 * $t }
            if ($t -lt 1 / 2) { return $q }
            if ($t -lt 2 / 3) { return $p + ($q - $p) * (2 / 3 - $t) * 6 }
            return $p
        }

        $r = [int]([Math]::Round((&$h2r $p $q ($H + 1 / 3)) * 255))
        $g = [int]([Math]::Round((&$h2r $p $q $H) * 255))
        $b = [int]([Math]::Round((&$h2r $p $q ($H - 1 / 3)) * 255))
    }

    # Clamp to 0-255
    $r = [Math]::Max(0, [Math]::Min(255, $r))
    $g = [Math]::Max(0, [Math]::Min(255, $g))
    $b = [Math]::Max(0, [Math]::Min(255, $b))

    return @{ R = $r; G = $g; B = $b }
}

function Get-DirColors {
    [CmdletBinding()]
    param([string]$Path = (Get-Location).Path)

    $config = Get-TintcdConfig
    $hash = Get-PathHash -Path $Path

    # Decorrelated mapping: each byte controls one HSL dimension
    $hue = ($hash[0] / 255.0) * 360.0
    $satFactor = $hash[1] / 255.0
    $lightFactor = $hash[2] / 255.0

    # lerp(range, t) = min + (max - min) * t
    $sat = $config.Saturation[0] + ($config.Saturation[1] - $config.Saturation[0]) * $satFactor

    # Background: dark
    $bgL = $config.BackgroundLightness[0] +
    ($config.BackgroundLightness[1] - $config.BackgroundLightness[0]) * $lightFactor
    $bgRgb = Convert-HslToRgb -H $hue -S $sat -L $bgL
    $bgHex = "#{0:X2}{1:X2}{2:X2}" -f $bgRgb.R, $bgRgb.G, $bgRgb.B

    # Accent: bright (slightly boosted saturation)
    $accentL = $config.AccentLightness[0] +
    ($config.AccentLightness[1] - $config.AccentLightness[0]) * $lightFactor
    $accentSat = [Math]::Min(1.0, $sat + 0.1)
    $accentRgb = Convert-HslToRgb -H $hue -S $accentSat -L $accentL
    $accentHex = "#{0:X2}{1:X2}{2:X2}" -f $accentRgb.R, $accentRgb.G, $accentRgb.B

    return @{
        Background = $bgHex
        Accent     = $accentHex
        Hue        = [Math]::Round($hue, 1)
    }
}

#endregion

#region Terminal Control

function Test-OscSupported {
    # Windows Terminal sets WT_SESSION, VS Code sets TERM_PROGRAM
    return ($null -ne $env:WT_SESSION) -or ($env:TERM_PROGRAM -like 'vscode*')
}

function Set-TerminalBackground {
    [CmdletBinding()]
    param([string]$Hex6)

    if (-not (Test-OscSupported)) { return }
    if ([Console]::IsOutputRedirected) { return }

    # OSC 11 with #RRGGBB format and BEL terminator
    try { [Console]::Write("$([char]27)]11;$Hex6$([char]7)") } catch { }
}

function Reset-TerminalBackground {
    [CmdletBinding()]
    param()

    if (-not (Test-OscSupported)) { return }
    if ([Console]::IsOutputRedirected) { return }

    $config = Get-TintcdConfig
    try { [Console]::Write("$([char]27)]11;#$($config.DefaultBackground)$([char]7)") } catch { }
}

#endregion

#region Prompt Hook

function Invoke-TintcdPromptCheck {
    # Internal: Called by prompt hook. Runs in module scope so $script: resolves correctly.
    try {
        $currentPath = (Get-Location).Path
    }
    catch {
        # Path unavailable (network drive dropped, etc.) - silently skip, don't crash prompt
        return
    }

    if ($script:SkipTintOnce) {
        $script:SkipTintOnce = $false
        $script:LastTintcdPath = $currentPath
        return
    }

    # Case-insensitive compare on Windows (path case can vary)
    $comparePath = if ($IsWindows) { $currentPath.ToLowerInvariant() } else { $currentPath }
    $lastPath = if ($IsWindows -and $script:LastTintcdPath) { $script:LastTintcdPath.ToLowerInvariant() } else { $script:LastTintcdPath }

    if ($comparePath -ne $lastPath) {
        Set-Tintcd -Path $currentPath
        $script:LastTintcdPath = $currentPath
    }
}

function Test-TintcdPromptHook {
    # Internal: Check if prompt hook is active via AST (string literal survives better than comments)
    $promptInfo = Get-Item function:prompt -ErrorAction SilentlyContinue
    return $promptInfo -and $promptInfo.ScriptBlock -and ($promptInfo.ScriptBlock.Ast.Extent.Text -match 'TINTCD_PROMPT_HOOK')
}

function Enable-TintcdPromptHook {
    <#
    .SYNOPSIS
        Enable automatic tinting on directory change by hooking into the prompt.
    .DESCRIPTION
        Wraps the current prompt function to check for directory changes.
        Must be called AFTER oh-my-posh init if using oh-my-posh.
        Safe to call multiple times (idempotent).
    .EXAMPLE
        Enable-TintcdPromptHook
    #>

    [CmdletBinding()]
    param()

    # Idempotent: don't double-wrap (prevents recursion)
    if (Test-TintcdPromptHook) {
        Write-Verbose "tintcd: Prompt hook already active"
        return
    }

    # Capture CURRENT prompt *now* (should be oh-my-posh if already initialized)
    # Use function:prompt (not function:global:prompt) for reliable access
    $currentPromptInfo = Get-Item function:prompt -ErrorAction SilentlyContinue
    if ($currentPromptInfo -and $currentPromptInfo.ScriptBlock) {
        $script:OriginalPrompt = $currentPromptInfo.ScriptBlock
    }
    else {
        $script:OriginalPrompt = { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }
    }

    # Store in global scope (no closures needed - avoids oh-my-posh context issues)
    $global:__TintcdModule = $ExecutionContext.SessionState.Module
    $global:__TintcdOriginalPrompt = $script:OriginalPrompt

    $script:TintcdHookEnabled = $true
    $script:SkipTintOnce = $false
    $script:LastTintcdPath = (Get-Location).Path

    # Define new prompt - no closure, uses global vars for reliable resolution
    Set-Item function:prompt -Value {
        # String literal marker for AST-based detection (survives better than comments)
        $null = 'TINTCD_PROMPT_HOOK'

        # Run tintcd check, suppress output, swallow errors (prompt must never throw)
        try {
            & $global:__TintcdModule { Invoke-TintcdPromptCheck } | Out-Null
        }
        catch { }

        # Run original prompt with error handling
        try {
            $p = $global:__TintcdOriginalPrompt.InvokeReturnAsIs()
            if ($null -eq $p) { throw "null" }
            $p
        }
        catch {
            "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) "
        }
    }

    # Apply tint for current directory immediately
    Set-Tintcd
}

function Show-TintcdStatus {
    [CmdletBinding()]
    param()

    Write-Host ""
    Write-Host " tintcd setup check" -ForegroundColor Cyan
    Write-Host " ══════════════════" -ForegroundColor DarkGray
    Write-Host ""

    # Module loaded (always true if this runs)
    Write-Host " ✓ Module loaded" -ForegroundColor Green

    # Config check
    $configPath = if ($env:TINTCD_CONFIG) { $env:TINTCD_CONFIG } else { Join-Path $HOME ".tintcd.json" }
    if (Test-Path $configPath) {
        try {
            $null = Get-Content $configPath -Raw | ConvertFrom-Json
            Write-Host " ✓ Config valid ($configPath)" -ForegroundColor Green
        }
        catch {
            Write-Host " ✗ Config invalid ($configPath)" -ForegroundColor Red
        }
    }
    else {
        Write-Host " ○ Config not found, using defaults" -ForegroundColor DarkGray
    }

    # Prompt hook check
    if (Test-TintcdPromptHook) {
        Write-Host " ✓ Prompt hook active" -ForegroundColor Green
    }
    else {
        Write-Host " ✗ Prompt hook not active — run 'tintcd -Hook'" -ForegroundColor Red
        Write-Host " (If using oh-my-posh, ensure tintcd inits AFTER it)" -ForegroundColor Yellow
    }

    # Terminal detection
    if ($env:WT_SESSION) {
        Write-Host " ✓ Windows Terminal detected (WT_SESSION)" -ForegroundColor Green
    }
    elseif ($env:TERM_PROGRAM -like 'vscode*') {
        Write-Host " ✓ VS Code terminal detected (TERM_PROGRAM)" -ForegroundColor Green
    }
    else {
        Write-Host " ○ Supported terminal not detected — OSC 11 disabled" -ForegroundColor Yellow
        Write-Host " (TINTCD_ACCENT still works for prompt integration)" -ForegroundColor DarkGray
    }

    # Current state
    if ($env:TINTCD_ACCENT) {
        Write-Host " ✓ TINTCD_ACCENT set ($env:TINTCD_ACCENT)" -ForegroundColor Green
    }
    else {
        Write-Host " ○ TINTCD_ACCENT not set" -ForegroundColor DarkGray
    }

    if ($env:TINTCD_DISABLED) {
        Write-Host " ⚠ TINTCD_DISABLED is set — tinting disabled for session" -ForegroundColor Yellow
    }

    Write-Host ""
}

#endregion

#region Internal Functions

function Set-Tintcd {
    # Apply tintcd colors for current directory. Called by prompt hook and Invoke-Tintcd.
    [CmdletBinding()]
    param(
        [string]$Path = (Get-Location).Path
    )

    if ($env:TINTCD_DISABLED) { return }

    # Reset colors for non-FileSystem providers (HKCU:\, Env:\, etc.)
    if ((Get-Location).Provider.Name -ne 'FileSystem') {
        Reset-TerminalBackground
        $env:TINTCD_ACCENT = $null
        return
    }

    $config = Get-TintcdConfig
    if (-not $config.Enabled) {
        $env:TINTCD_ACCENT = $null
        return
    }

    $colors = Get-DirColors -Path $Path
    Set-TerminalBackground -Hex6 $colors.Background
    $env:TINTCD_ACCENT = $colors.Accent
}

#endregion

#region Public Functions

function Reset-Tintcd {
    [CmdletBinding()]
    param(
        [switch]$DisableSession,
        [switch]$UnhookPrompt
    )

    Reset-TerminalBackground
    $env:TINTCD_ACCENT = $null

    if ($UnhookPrompt -and (Test-TintcdPromptHook)) {
        if ($script:OriginalPrompt) {
            Set-Item function:prompt -Value $script:OriginalPrompt
        }
        # Clean up global variables used by prompt hook
        Remove-Variable __TintcdModule, __TintcdOriginalPrompt -Scope Global -ErrorAction SilentlyContinue
        $script:TintcdHookEnabled = $false
        $script:LastTintcdPath = $null
        $script:SkipTintOnce = $false
        Write-Host "tintcd prompt hook removed." -ForegroundColor Yellow
    }

    if ($DisableSession) {
        $env:TINTCD_DISABLED = "1"
        # Only show message in interactive sessions
        if ([Environment]::UserInteractive -and $Host.Name -eq 'ConsoleHost') {
            Write-Host "tintcd disabled for this session. Run 'Remove-Item env:TINTCD_DISABLED' to re-enable." -ForegroundColor Yellow
        }
    }
}

function Invoke-Tintcd {
    <#
    .SYNOPSIS
        Unified tintcd command - navigate, configure, and control terminal theming.
    .DESCRIPTION
        Main entry point for tintcd. Modes:
        - Navigate: cd to path + apply tint (default)
        - Reload: reload config + re-apply tint
        - Preview: show color preview
        - Hook/Unhook: install/remove prompt hook
        - Enable/Disable: toggle tinting for session
    .PARAMETER Path
        Target directory. If omitted, goes to $HOME (like cd).
    .PARAMETER NoTint
        Skip applying colors for this navigation only.
    .PARAMETER Reload
        Reload config and re-apply tint for current directory.
    .PARAMETER Preview
        Show color preview for sample directories.
    .PARAMETER Paths
        Specific paths to preview (with -Preview).
    .PARAMETER Hook
        Install prompt hook for automatic tinting.
    .PARAMETER Unhook
        Remove prompt hook, restore original prompt.
    .PARAMETER Disable
        Disable tinting for this session.
    .PARAMETER Enable
        Re-enable tinting after -Disable.
    .PARAMETER Status
        Show tintcd status and diagnose setup issues.
    .EXAMPLE
        tintcd C:\projects\myapp
    .EXAMPLE
        tintcd -Reload
    .EXAMPLE
        tintcd -Preview
    .EXAMPLE
        tintcd -Hook
    .EXAMPLE
        tintcd -Status
    #>

    [CmdletBinding(DefaultParameterSetName = 'Navigate')]
    param(
        [Parameter(Position = 0, ParameterSetName = 'Navigate')]
        [string]$Path,
        [Parameter(ParameterSetName = 'Navigate')]
        [switch]$NoTint,

        [Parameter(Mandatory, ParameterSetName = 'Reload')]
        [switch]$Reload,

        [Parameter(Mandatory, ParameterSetName = 'Preview')]
        [switch]$Preview,
        [Parameter(Position = 0, ParameterSetName = 'Preview')]
        [string[]]$Paths,

        [Parameter(Mandatory, ParameterSetName = 'Hook')]
        [switch]$Hook,

        [Parameter(Mandatory, ParameterSetName = 'Unhook')]
        [switch]$Unhook,

        [Parameter(Mandatory, ParameterSetName = 'Disable')]
        [switch]$Disable,

        [Parameter(Mandatory, ParameterSetName = 'Enable')]
        [switch]$Enable,

        [Parameter(Mandatory, ParameterSetName = 'Status')]
        [switch]$Status
    )

    switch ($PSCmdlet.ParameterSetName) {
        'Navigate' {
            # Mirror cd: no path means go home
            if (-not $Path) { $Path = $HOME }

            # Set skip-once flag BEFORE Set-Location
            if ($NoTint) {
                $script:SkipTintOnce = $true
            }

            try {
                Microsoft.PowerShell.Management\Set-Location -Path $Path -ErrorAction Stop
            }
            catch {
                $script:SkipTintOnce = $false
                $PSCmdlet.WriteError($_)
                return
            }

            # Only tint if not -NoTint and not disabled
            if (-not $NoTint -and -not $env:TINTCD_DISABLED) {
                Set-Tintcd
                $script:LastTintcdPath = (Get-Location).Path
            }
            elseif ($NoTint) {
                # -NoTint means reset to default, not keep stale color
                Reset-TerminalBackground
                $env:TINTCD_ACCENT = $null
                $script:LastTintcdPath = (Get-Location).Path
            }
        }

        'Reload' {
            $script:ConfigCache = $null
            $script:LastTintcdPath = $null
            if (-not $env:TINTCD_DISABLED) {
                Set-Tintcd
                $script:LastTintcdPath = (Get-Location).Path
            }
        }

        'Preview' {
            Show-TintcdPreview -Paths $Paths
        }

        'Hook' {
            # Hook implies enable
            $env:TINTCD_DISABLED = $null
            Enable-TintcdPromptHook
        }

        'Unhook' {
            if (Test-TintcdPromptHook) {
                if ($script:OriginalPrompt) {
                    Set-Item function:prompt -Value $script:OriginalPrompt
                }
                Remove-Variable __TintcdModule, __TintcdOriginalPrompt -Scope Global -ErrorAction SilentlyContinue
                $script:TintcdHookEnabled = $false
                $script:LastTintcdPath = $null
            }
            Reset-TerminalBackground
            $env:TINTCD_ACCENT = $null
        }

        'Disable' {
            $env:TINTCD_DISABLED = "1"
            Reset-TerminalBackground
            $env:TINTCD_ACCENT = $null
        }

        'Enable' {
            $env:TINTCD_DISABLED = $null
            if (Test-TintcdPromptHook) {
                Set-Tintcd
                $script:LastTintcdPath = (Get-Location).Path
            }
        }

        'Status' {
            Show-TintcdStatus
        }
    }
}

function Show-TintcdPreview {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string[]]$Paths
    )

    if (-not $Paths) {
        $Paths = @(
            $HOME,
            (Join-Path $HOME "Documents"),
            (Join-Path $HOME "projects"),
            (Join-Path $HOME "projects" "alpha"),
            (Join-Path $HOME "projects" "beta")
        )
        if ($IsWindows) {
            $Paths += @("C:\Windows", "C:\temp")
        }
    }

    Write-Host ""
    Write-Host " tintcd color preview" -ForegroundColor Cyan
    Write-Host " ════════════════════" -ForegroundColor DarkGray
    Write-Host ""

    foreach ($p in $Paths) {
        $colors = Get-DirColors -Path $p
        $bg = $colors.Background
        $accent = $colors.Accent

        # Show ANSI color swatches only if output supports it
        if (-not [Console]::IsOutputRedirected) {
            $bgR = [Convert]::ToInt32($bg.Substring(1, 2), 16)
            $bgG = [Convert]::ToInt32($bg.Substring(3, 2), 16)
            $bgB = [Convert]::ToInt32($bg.Substring(5, 2), 16)
            $bgSwatch = "$([char]27)[48;2;$bgR;$bgG;${bgB}m $([char]27)[0m"

            $acR = [Convert]::ToInt32($accent.Substring(1, 2), 16)
            $acG = [Convert]::ToInt32($accent.Substring(3, 2), 16)
            $acB = [Convert]::ToInt32($accent.Substring(5, 2), 16)
            $accentSwatch = "$([char]27)[48;2;$acR;$acG;${acB}m $([char]27)[0m"

            Write-Host " $bgSwatch $accentSwatch " -NoNewline
        }
        else {
            Write-Host " " -NoNewline
        }
        Write-Host "bg:$bg accent:$accent " -NoNewline
        Write-Host $p
    }

    Write-Host ""
}

#endregion

#region Aliases

Set-Alias -Name tintcd -Value Invoke-Tintcd

#endregion

#region Module Export

Export-ModuleMember -Function @(
    'Invoke-Tintcd',
    'Enable-TintcdPromptHook',
    'Get-TintcdConfig'
) -Alias @(
    'tintcd'
)

# Cleanup on module unload
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    try {
        # Only restore if our hook is still the active prompt (don't clobber later changes)
        $promptInfo = Get-Item function:prompt -ErrorAction SilentlyContinue
        if ($promptInfo -and $promptInfo.ScriptBlock -and $promptInfo.ScriptBlock.Ast.Extent.Text -match 'TINTCD_PROMPT_HOOK' -and $script:OriginalPrompt) {
            Set-Item function:prompt -Value $script:OriginalPrompt
        }

        # Clean up global variables used by prompt hook
        Remove-Variable __TintcdModule, __TintcdOriginalPrompt -Scope Global -ErrorAction SilentlyContinue

        # Clean up env var and reset background (best effort)
        $env:TINTCD_ACCENT = $null
        if (-not [Console]::IsOutputRedirected -and (($null -ne $env:WT_SESSION) -or ($env:TERM_PROGRAM -like 'vscode*'))) {
            [Console]::Write("$([char]27)]11;#$($script:DefaultConfig.DefaultBackground)$([char]7)")
        }
    }
    catch {
        # Silently ignore - module unload must not fail
    }
}

#endregion