lib/background.ps1

# background.ps1 -- resolving a style's background image, and remembering when
# there isn't one.
#
# Dot-sourced by tstyles.ps1, so everything here shares its $script: scope.
#
# Resolution is three tiers plus one inheritance hop: a file bundled beside the
# style, the per-user cache, a tuned style's base, then a lazy fetch from the
# gifs branch. The fetch downloads to a .part and renames, because a file
# sitting at the cache path is treated as a complete entry by every reader and
# nothing revalidates it.
#
# The negative cache is dated, and deliberately so: a 404 means the asset is
# genuinely absent and is worth remembering for a month, while an unreachable
# network means nothing and is remembered for an hour.

function Test-HttpNotFound {
    # Did this web error mean "the server answered, and the file is not there"
    # (404) as opposed to "I could not reach the server" (DNS failure, timeout,
    # offline, proxy, 5xx)?
    #
    # The distinction is the whole point of the negative cache: an absent asset
    # is a stable fact worth remembering, an unreachable network is not.
    # Both engines surface it the same way -- a 404 carries a .Response with a
    # StatusCode, a transport failure has no .Response at all (verified on
    # pwsh 7's HttpResponseException/HttpRequestException and 5.1's WebException,
    # whose .Response is null for NameResolutionFailure).
    param($ErrorRecord)
    try {
        $response = $ErrorRecord.Exception.Response
        if (-not $response) { return $false }
        return ([int]$response.StatusCode -eq 404)
    } catch { return $false }
}

function Test-BackgroundProbeSuppressed {
    # Should we skip the lazy fetch because a previous probe already answered?
    # Pure, so the expiry rules are testable without touching the network.
    #
    # Two lifetimes, because the two answers are worth different amounts:
    # absent -- every extension 404'd. A stable fact, but not permanent:
    # the gifs branch is updated independently of releases, so
    # a style CAN gain an asset later. Re-probe monthly.
    # unreachable -- the network failed. Worth remembering only long enough to
    # stop every apply in the next hour paying four 10-second
    # timeouts; then retry.
    #
    # An empty or unparseable marker is treated as EXPIRED. Releases up to 0.8.5
    # wrote a content-free marker on ANY failure and deleted it never, so one
    # apply while offline cost that style its background permanently. Returning
    # $false here re-probes once and replaces it with a marker that can expire.
    param([string]$MarkerText, [datetime]$Now = [datetime]::UtcNow)

    if ([string]::IsNullOrWhiteSpace($MarkerText)) { return $false }
    try { $marker = $MarkerText | ConvertFrom-Json } catch { return $false }
    if (-not $marker -or -not $marker.kind -or -not $marker.at) { return $false }

    # AdjustToUniversal + AssumeUniversal, NOT RoundtripKind: TryParse with
    # RoundtripKind consumes the trailing Z but hands back Kind=Unspecified, so
    # a later .ToUniversalTime() re-reads a UTC stamp as local and shifts it by
    # the machine's offset. That silently widened or narrowed every TTL by hours
    # depending on where the user lives. The marker is always written as UTC.
    $at = [datetime]::MinValue
    $parseStyles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor
                   [System.Globalization.DateTimeStyles]::AssumeUniversal
    if (-not [datetime]::TryParse($marker.at, [cultureinfo]::InvariantCulture,
            $parseStyles, [ref]$at)) { return $false }

    $ttl = if ($marker.kind -eq 'absent') { [timespan]::FromDays(30) } else { [timespan]::FromHours(1) }
    return (($Now.ToUniversalTime() - $at) -lt $ttl)
}

function Get-StyleBundledBackground {
    # Three-tier resolution:
    # 1. Bundled file under $StyleDir (module root, read-only-ish on PSGallery)
    # 2. Cached file under $DataRoot\cache\<name>\ (writable, persistent)
    # 3. Lazy-fetch from gifs branch -> write to $DataRoot\cache\<name>\
    #
    # The negative-cache marker (.no-background) lives in the cache dir, never
    # in the bundled dir, so we can write it on PSGallery installs.
    param([Parameter(Mandatory)][string]$StyleDir, [switch]$NoInherit)

    # 1. Bundled (under module root)
    foreach ($ext in 'gif','png','jpg','jpeg') {
        $bundled = Join-Path $StyleDir "background.$ext"
        if (Test-Path -LiteralPath $bundled) { return $bundled }
    }

    $styleName = Split-Path -Leaf $StyleDir
    $cacheDir  = Get-StyleCacheDir -StyleName $styleName

    # 2. Cached (under data root)
    foreach ($ext in 'gif','png','jpg','jpeg') {
        $cached = Join-Path $cacheDir "background.$ext"
        if (Test-Path -LiteralPath $cached) { return $cached }
    }
    # 2b. Inheritance: a tuned style inherits its base's background. For a
    # non-tuned style this returns $null instantly (no tune.json), so the
    # normal path is unaffected. -NoInherit suppresses this (used when
    # resolving a base, so inheritance is strictly one hop -- no cycles).
    if (-not $NoInherit) {
        $inherited = Get-TunedBaseBackground -StyleDir $StyleDir
        if ($inherited) { return $inherited }
    }

    $markerPath = Join-Path $cacheDir '.no-background'
    if (Test-Path -LiteralPath $markerPath) {
        $markerText = ''
        try {
            $markerText = [System.IO.File]::ReadAllText($markerPath, [System.Text.UTF8Encoding]::new($false))
        } catch { $markerText = '' }
        if (Test-BackgroundProbeSuppressed -MarkerText $markerText) { return $null }
    }

    # 3. Lazy-fetch into cache
    if (-not (Test-Path -LiteralPath $cacheDir)) {
        New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
    }
    $remoteBase = "https://raw.githubusercontent.com/fcreme/TerminalStyles/gifs/$styleName"
    $prevProgress = $ProgressPreference
    $ProgressPreference = 'SilentlyContinue'
    # Did the server actually answer "not there" every time? One unreachable
    # attempt is enough to make the whole result inconclusive.
    $definitelyAbsent = $true
    try {
        foreach ($ext in 'gif','png','jpg','jpeg') {
            $url = "$remoteBase.$ext"
            $local = Join-Path $cacheDir "background.$ext"
            # Same .part-then-rename as the picker's prefetch job, for the same
            # reason: a file at $local is treated as a complete cache entry by
            # every reader and nothing revalidates it. The catch below cleans up
            # after a network error, but a Ctrl+C or a killed process never
            # reaches it -- and that would strand a truncated image as this
            # style's background for good.
            $part = "$local.part"
            try {
                Invoke-WebRequest -Uri $url -OutFile $part -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
                if ((Get-Item -LiteralPath $part -ErrorAction SilentlyContinue).Length -gt 0) {
                    Move-Item -LiteralPath $part -Destination $local -Force
                    return $local
                } else {
                    Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue
                }
            } catch {
                if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue }
                if (-not (Test-HttpNotFound -ErrorRecord $_)) { $definitelyAbsent = $false }
            }
        }
    } finally {
        $ProgressPreference = $prevProgress
    }

    # Nothing fetched. Record WHY, so the marker can expire on the right clock:
    # a real 404 is worth remembering for a month, an unreachable network for an
    # hour. Writing an undated marker on any failure -- what releases up to 0.8.5
    # did -- meant a single apply while offline cost that style its background
    # for good, and the only documented way to clear it was `tstyles uninstall
    # -DeleteData`, which could not be invoked at all.
    try {
        $marker = [pscustomobject]@{
            schemaVersion = 1
            kind          = if ($definitelyAbsent) { 'absent' } else { 'unreachable' }
            at            = [datetime]::UtcNow.ToString('o')
        }
        [System.IO.File]::WriteAllText((Join-Path $cacheDir '.no-background'),
            ($marker | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false))
    } catch { }
    return $null
}