CacheMeIfYouCan.psm1

using namespace System.Collections.Generic
using namespace System.Collections
using namespace System.Management.Automation.Language
using namespace System.Management.Automation
using namespace System.Management

$script:DefaultTemplateTypeMapping = @{}
$script:ModuleConfig = @{
    ExportAggressiveNames = $false
    VerboseLifetimeMessages = $true
    ExportDebugFunctions = $false
    VerboseJson_ArgCompletions = $false
    PrintExtraSummaryOnTabCompletion = $false
    WarnWhen_GetValueCommand_KeyDoesNotExist = $true
    Verbose = @{
        OnCacheWrites = $true
        OnCacheReads = $true
    }
}

[hashtable]$script:Cache = @{}
if($Script:ModuleConfig.VerboseJson_ArgCompletions) {
    (Join-Path (gi 'temp:\') 'CacheMeIfYouCan.ArgCompletions.log')
    | Join-String -op 'CacheMeIfYouCan: VerboseLogging for ArgCompletions is enabled at: '
    | write-warning
}
$script:Color = @{
    MedBlue   = '#a4dcff'
    DimBlue   = '#7aa1b9'
    DimFg     = '#cbc199'
    DarkFg    = '#555759'
    DimGreen  = '#95d1b0'
    DimOrange = '#ce8d70'
    DimPurple = '#c586c0'
    Dim2Purple = '#c1a6c1'
    DimYellow = '#dcdcaa'
    MedGreen  = '#4cd189'
}
function WriteFg {
    # Internal Ansi color wrapper
    param( [object]$Color )
    if( [string]::IsNullOrEmpty( $Color ) ) { return }
    $PSStyle.Foreground.FromRgb( $Color )
}
function WriteBg {
    # Internal Ansi color wrapper
    param( [object]$Color )
    if( [string]::IsNullOrEmpty( $Color ) ) { return }
    $PSStyle.Background.FromRgb( $Color )
}
function WriteColor {
    # Internal Ansi color wrapper
    param(
        [object]$ColorFg,
        [object]$ColorBg
    )
    if( [string]::IsNullOrEmpty( $ColorFg ) -and [string]::IsNullOrEmpty( $ColorBg ) ) { return }
    @(  WriteFg $ColorFg
        WriteBg $ColorBg ) -join ''
}
function Format-CacheMeStatus {
    <#
    .SYNOPSIS
        render the optional color messages
    .example
        CacheMe.RenderStatus -Name 'Gci_1' -Status Fresh
    .example
        $record = Get-CacheMe (Get-CacheMeKeys|Select -first 1) -PassThru
        CacheMe.RenderStatus -Name $record.Name -Status Hit -Time $record.CalculationDuration
    #>

    [Alias(
        'CacheMe.RenderStatus'
    )]
    param(
        [Alias('Name')]
        [string]$Key,
        [ArgumentCompletions(
            'Hit', 'Miss',
            'Stale', 'Fresh',
            'Read', 'Write'
        )]
        [string]$Status,

        [Nullable[timespan]]$Time
    )

    $StateColor = Switch -regex ($Status) {
        'Stale|Write|Miss' { $Color.DimOrange ; break }
        'Hit|Read|Fresh' { $Color.DimGreen  ; break }
        default {
            $Color.DimFg
        }
    }

    $CPre1 = WriteFg $Color.DimOrange
    $CPre1 = WriteFg $Color.DimFg
    $CPre2 = WriteFg $Color.DimPurple
    $CPre2 = WriteFg $Color.DimYellow
    $CPre2 = $PSStyle.Reset

    $CPre1 = $PSStyle.Reset
    $CPre2 = WriteFg $Color.DimYellow
    $CPre1 = WriteFg $Color.DarkFg
    $CPre4 = WriteFg $Color.Dim2Purple
    if( $Time ) {
        $TimeAsMs = $Time.TotalMilliSeconds.ToSTring('n1')
        $TimeSuffix = " ${CPre1}(${CPre4}${TimeAsMs} ms${CPre1})"
    } else {
        $TimeSuffix = ''
    }

    $CPre3 = WriteFg $StateColor
    $Space = ': '
    $Space = ''
    "${CPre1}Cache ${CPre2}${Key}${CPre1}${SPace}${CPre3}${Status}${TimeSuffix}"
}
function Cache._Read {
    <#
    .SYNOPSIS
        Internal helper, that encapsulates reading keys
    .LINK
        Cache._Read
    .LINK
        Cache._Write
    #>

    # [Alias('Cache._Read')]
    [OutputType( [CachedRecord] )]
    [CmdletBinding()]
    param(
        [Parameter(Position=0, Mandatory)]
            [Alias('Name')]
            [ArgumentCompleter( [CachedKeyNameArgumentCompleter] )]
            [string]$KeyName,

            [switch]$ErrorWhenMissing
    )
    [bool]$exists? = $script:Cache.ContainsKey( $KeyName )
    if( $ErrorWhenMissing -and -not $exists? ) {
        if($script:ModuleConfig.WarnWhen_GetValueCommand_KeyDoesNotExist) {
            $PSCmdlet.MyInvocation.ScriptName
                | Join-String -op 'Cmdlet.MyInvo.Script: '
                | Write-Debug
            throw ( $KeyName | Join-String -f 'CacheMe::_Read: Tried reading from a key that does not exist! Key = "{0}"')
        } else {
            $KeyName
                | Join-String -f 'CacheMe::_Read: Tried reading from a key that does not exist! Key = "{0}"'
                | Write-Error
                # | Write-Debug
        }

    }
    $record = $script:Cache[ $KeyName ]
    return $record
}
function Cache._Write {
    <#
    .SYNOPSIS
        Internal helper, that encapsulates writing keys
    .LINK
        Cache._Read
    .LINK
        Cache._Write
    #>

    # [Alias('Cache._Write')]
    [CmdletBinding()]
    param(
        # Name of key
        [ValidateNotNullOrWhiteSpace()]
        [ArgumentCompleter( { [CachedKeyNameArgumentCompleter] } )]
        # [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory, Position=0)]
        [string]$KeyName,

        # maybe this should allow caching in circumstances
        # [ValidateNotNullOrWhiteSpace()]
        # [ValidateNotNullOrEmpty()]
        # [Parameter(Mandatory, ValueFromPipeline )]
        [Parameter(Mandatory, Position = 1 )]
        [CachedRecord]$CachedRecord
    )
    if(-not $PSBoundParameters.ContainsKey('CachedRecord')){
        throw 'MissingArgException' }
    if( $CachedRecord -isnot [CachedRecord] ) {
        throw 'ShouldNeverReachInvalidParameterTypeException' }

    $script:Cache[ $KeyName ] = $Record
}

function WhenContainsSpaces-FormatQuotes {
        param(
            [string]$Text,
            [switch]$DoubleQuote
        )
        $hasSpaces = $Text -match ' '
        $hasSingle = $Text -match "[']+"
        $hasDouble = $Text -match '["]+'

        $splat = @{}
        if($DoubleQuote) {
            $splat.DoubleQuote = $True
        } else {
            $splat.SingleQuote = $True
        }

        $hasSpaces ? (
            Join-String -in $Text @splat
        ) : $Text
}
class CachedKeyNameArgumentCompleter : System.Management.Automation.IArgumentCompleter {
    <#
        it supports names with spaces
    #>

    [IEnumerable[CompletionResult]] CompleteArgument(
        [string] $CommandName,
        [string] $ParameterName,
        [string] $WordToComplete,
        [CommandAst] $CommandAst,
        [IDictionary] $FakeBoundParameters
    ) {
        [List[CompletionResult]]$Completions = @()

        if($Script:ModuleConfig.VerboseJson_ArgCompletions) {
            $Script:Cache
                | ConvertTo-Json -wa 'ignore' -depth 3
                | set-content -path (Join-Path (gi 'temp:\') 'CacheMeIfYouCan.ArgCompletions.log')
        }
        $script:Cache.GetEnumerator() | % {
            $Item = $_.Value
            $toMatch = $Item.Name

            # $hasSpaces = ($Item.Name -match ' ')
            # $quotedName = if( $hasSpaces ) {
            # Join-String -in $Item.Name -SingleQuote
            # } else {
            # $Item.Name
            # }
            # $toCompleteAs = $quotedName
            $toCompleteAs = WhenContainsSpaces-FormatQuotes -Text $Item.Name
            if( $toMatch -notmatch [regex]::escape( $WordToComplete )) {
                return
            }

            $Completions.Add(
                [CompletionResult]::new(
                    $toCompleteAs,
                    $toMatch,
                    'ParameterValue',
                    $Item.Tooltip()
                ) )
        }

        if( $script:ModuleConfig.PrintExtraSummaryOnTabCompletion) {
            "`n" | write-host
            $Completions
                | format-table | out-string
                | write-host  #-bg $Script:Color.DimPurple
            "`n" | write-host
        }
        return $Completions
    }
}


class CachedRecord { # Todo: make me
    [string]$Name
    # created by
    [scriptblock]$ScriptBlock
    [timespan]$CalculationDuration
    [datetime]$EvaluatedAt
    [string]$ValueKind
    [object]$Value
    [hashtable]$Metadata

    [timespan]Age () {
        return [datetime]::Now - $This.EvaluatedAt
    }
    [string] ToString() {
        return '[CacheRecord := Name: {0}, Age: {1} ]' -f @(
            $This.Name
            $This.Age().TotalSeconds | Join-String -f '{0:n2}'
        )
    }
    [string] Tooltip() {
        # used for custom rendering for tooltips
        return $This.ToString()
    }
}
function CacheMe.ColorTest {
    $script:Color.GetEnumerator() | %{
        $_.Key | New-Text -fg $_.Value | Join-String
    }| Join-String -sep "`n"
}
function Clear-CacheMe {
    [Alias('CacheMe.Clear')]
    param()
    'clearing all cached values...' | write-host -fg 'orange'
    $script:Cache.Clear()
}
function Get-CacheMeRecords {
    <#
    .SYNOPSIS
        enumerates the raw [CachedRecord[]] records
    #>

    [OutputType( [CachedRecord] )]
    [Alias()]
    param()
    # ( $script:Cache.Keys ).count
    $script:Cache.GetEnumerator() | %{
        $_.Value
    }

}
function CacheMe.ListKeys {
    <#
    .SYNOPSIS
        Returns key names as untouched strings. Writes optional colors to to Write-Information
    .DESCRIPTION
        You'll
    .EXAMPLE
    Pwsh7 🐒
    > $names = CacheMe.ListKeys
    > $Names -join ', '
 
            All_Modules, gcm_less
 
    .EXAMPLE
    Pwsh7 🐒
        > $names = CacheMe.ListKeys -infa Continue | Join.UL
 
        keys => All_Modules, gcm_less
 
            - All_Modules
            - gcm_less
 
    .EXAMPLE
    Pwsh7 🐒
        > CacheMe.ListKeys | Join-String -sep ', ' -SingleQuote
            'All_Modules', 'gcm_less'
 
    .EXAMPLE
    Pwsh7 🐒>
        foreach($x in (CacheMe.ListKeys)) {
            "DoStuff: $x"
        }
    #>


    [Alias(
        'Get-CacheMe.Keys',
        'Get-CacheMeKeys'
    )]
    [CmdletBinding()]
    [OutputType('String[]')]
    param()

    $PSStyle.OutputRendering = 'ansi'
    Cache._Read -KeyName foo|ft -AutoSize | Out-String | Write-Information

    [string[]]$Keys = @($script:Cache).Keys?.Clone()
        | Sort-Object -Unique

    $Keys
        | join-String -sep ', ' -p {
            $_ | New-Text -fg 'gray70' -bg 'gray40'
        }   | Join-String -op 'keys => '
            | Write-Information

    return $Keys
}

# function # was: CacheMe.Add-CachedValue {

function Set-CacheMeValue {
    <#
    .SYNOPSIS
        # Add a value to the cache, if not already cached, then it's an no-op
    .DESCRIPTION
        minimal, fast, caching of random results
    .EXAMPLE
        Cache.Value -Name 'gmo-noargs' -ScriptBlock { get-module } -Verbose -Force | % Value| Out-Null
    .EXAMPLE
        Cache.Value -Name <tab>'gmo-noargs'
            # returns value
    .EXAMPLE
        $x = Namo.CachedValue -Name 'gmo-list' { get-module -ListAvailable } -Verbose
        # totalTime: 0.02 ms
    #>

    [Alias('Set-CacheMe')]
    [CmdletBinding()]
    param(
        [Alias('Label')]
        [Parameter(Mandatory)]
        [ValidateNotNullOrWhiteSpace()]
        [ArgumentCompleter(  [CachedKeyNameArgumentCompleter] )]
        [string]$Name,

        # echo cached value
        [switch]$PassThru,

        [Parameter(Mandatory)]
        [Alias('Expression', 'SB', 'E')]
        [scriptblock]$ScriptBlock
    )

    $addCacheMeValueSplat = @{
        Name        = $Name
        PassThru    = $PassThru
        ScriptBlock = $ScriptBlock
        Force = $True
    }

    Add-CacheMeValue @addCacheMeValueSplat
}
function Add-CacheMeValue {
     <#
    .SYNOPSIS
        # Add a value to the cache, if not already cached, then it's an no-op
    .DESCRIPTION
        PassThru will always return a value. If it's not expired, returns the cached value rather then the scriptBlock parameter.
        minimal, fast, caching of scriptblocks
    .EXAMPLE
        Cache.Value -Name 'gmo-noargs' -ScriptBlock { get-module } -Verbose -Force | % Value| Out-Null
    .EXAMPLE
        Cache.Value -Name <tab>'gmo-noargs'
            # returns value
    .EXAMPLE
        $x = Namo.CachedValue -Name 'gmo-list' { get-module -ListAvailable } -Verbose
        # totalTime: 0.02 ms
    #>

    [Alias('Add-CacheMe')]
    [CmdletBinding()]
    param(
        [Alias('Label')]
        [Parameter(Mandatory)]
        [ValidateNotNullOrWhiteSpace()]
        [ArgumentCompleter(  [CachedKeyNameArgumentCompleter] )]
        [string]$Name,

        # echo cached value
        [switch]$PassThru,

        [Parameter(Mandatory)]
        [Alias('Expression', 'SB', 'E')]
        [scriptblock]$ScriptBlock,

        # force calculation
        [Alias('Rebuild')]
        [switch]$Force
    )
    [datetime]$Start = [datetime]::Now
    [datetime]$End = $Start

    [bool]$ValueIsStale = $false
    if($Force) { $valueIsStale = $true }
    if( -not $script:Cache.ContainsKey($Name) ) { $valueIsStale = $true }
    'Value for Key: {0} is stale?: {1}' -f @( $Name, $ValueIsStale )
        | write-verbose

    if( -not $ValueIsStale ) {
        # value because it might not be expired with passthru
        $record = Cache._Read -keyName $Name -ErrorWhenMissing:$False
        if( $PassThru ) { return $record.Value }
        return
    }

    try {
        $newValue = & $ScriptBlock
    } catch {
        'CacheMeIfYouCan:Add-CacheMeValue: Exception: Namo.CachedValue( {0} ) invoked by {1}: {2}' -f @(
            $Name
            $PSCommandPath
            $_
        )  | write-verbose
        $_ | write-warning
        $newValue = $Null
        return
    }

    $end = [datetime]::Now
    $delta = $end - $start
    [CachedRecord]$record = @{
        CalculationDuration = $delta
        EvaluatedAt = $Start
        Name = $Name
        ScriptBlock = $ScriptBlock
        Value = $newValue
        ValueKind = @(
            '{0} of {1} тип {2}' -f @(
                ($newValue)?.GetType().Name
                $newValue.Count
                @( $newValue)[0]?.GetType().Name
            )
        )
        Metadata = @{
            SourceCommand = $PSCommandPath
            SourcePSScriptRoot = $PSScriptRoot
            MyCmdName = $MyInvocation.MyCommand.Name
            MyModule = $MyInvocation.MyCommand.ModuleName
            MyScriptLine = $MyInvocation.ScriptLineNumber
            MyScriptName = $MyInvocation.ScriptName
            PathString = $MyInvocation | Join-String -p {
                $_.ScriptName, $_.ScriptLineNumber -join ':' }
        }
    }
    Cache._Write -KeyName $Record.Name -CachedRecord $record
    Format-CacheMeStatus -Key $KeyName -Status Write -Time $Record.CalculationDuration
            | Write-Information -infa 'continue'

    $record = Cache._Read -keyName $Record.Name -ErrorWhenMissing:$False  # return the value because it might not be expired
    if( $PassThru ) { return $record.Value }
 }
function Get-CacheMeValue {
    <#
    .SYNOPSIS
        Read cached values. Key names autocomplete.
    .EXAMPLE
        Get-CacheMe -Name 'key'
        Get-CacheMe -Name 'key' -ErrorOnMissingKey
        Get-CacheMe -Name 'key' -PassThru
    #>

    [Alias('Get-CacheMe')]
    [CmdletBinding()]
    param(
        [Alias('Label')]
        [Parameter(Mandatory)]
        [ValidateNotNullOrWhiteSpace()]
        [ArgumentCompleter(  [CachedKeyNameArgumentCompleter] )]
        [string]$Name,

        # returns the [CacheRecord] instead of the raw value
        [switch]$PassThru,

        # off means missing keys return nothing, silently
        [switch]$ErrorOnMissingKey
    )
    [CachedRecord]$record = Cache._Read -KeyName $Name -ErrorWhenMissing:$ErrorOnMissingKey
    if( $PassThru ) { return $Record }
    return $record.Value
}
function CacheMe.ProxyFunc {
    <#
    .SYNOPSIS
        experimenting with a proxy function that supports multiple commandlets
    .EXAMPLE
        # is: Add-CacheMeValue
        CacheMe -Name 'gcm' -ScriptBlock { ... }
 
        # is: Set-CacheMeValue
        CacheMe -Name 'gcm' -ScriptBlock { ... } -Force
 
        # is: Get-CacheMeValue
        CacheMe -Name 'gcm'
 
        # is: Get-CacheMeValue
        CacheMe -Name 'missing' -ErrorOnMissingKey
 
        # is: Get-CacheMeKeys
        CacheMe
 
    #>

    [Alias('CacheMe')]
    [CmdletBinding()]
    param(
        [Alias('Label')]
        [Parameter(Position=0)]
        [ArgumentCompleter(  [CachedKeyNameArgumentCompleter] )]
        [string]$Name,

        # -PassThru returns the raw [CacheRecord], instead of the value
        [switch]$PassThru,

        [Parameter(
            Position=1,
            Mandatory,
            ParameterSetName = 'FromScriptBlock'
        )]
        [Alias('Expression', 'SB', 'E')]
        [scriptblock]$ScriptBlock,
        # force calculation
        [Alias('Rebuild')]
        [switch]$Force,

        # move to another cmdlet when exporting module
        [switch]$FullReset,
        [switch]$ErrorOnMissingKey,

        # this mode collects inputs from the pipeline to save
        # in comparison to the scriptblock invoke version
        [Parameter(
            # Mandatory,
            ParameterSetName = 'FromPipeline',
            ValueFromPipeline

        )]
        [Alias('Values', 'Item', 'Records', 'Data', 'InObj','PipelineInputObject')]
        [object[]]$InputObject
    )
    begin {
        if( $FullReset ) { Clear-CacheMe }
        [List[Object]]$Items = @()
    }
    process {
        [bool]$Using_SmartAlias_Set = $PSCmdlet.MyInvocation.InvocationName -match '\bSet\b'
        [bool]$Param_SB_IsDefined = -not [string]::IsNullOrWhiteSpace($ScriptBlock)
        [bool]$Param_Pipeline_IsDefined =
            $PSBoundParameters.ContainsKey('InputObject') -or $PSCmdlet.MyInvocation.ExpectingInput

        if( -not $Name ) {
            Get-CacheMeKeys
            return
        }

        if( $PSBoundParameters.ContainsKey('InputObject') -and
            [String]::IsNullOrWhiteSpace( $Name ) ) {
            # -not $PSBoundParameters.ContainsKey('Name') ) {
            throw "Expected Key is blank!"
        }


        if($Param_Pipeline_IsDefined) {
            $Items.AddRange(@($InputObject))
            return
        } else {
            if( -not $Param_SB_IsDefined ) {
                Get-CacheMe -Name $Name -PassThru:$PassThru -ErrorOnMissingKey:$ErrorOnMissingKey
                return
            } else {
                $AddCacheMeSplat = @{
                    Name        = $Name
                    PassThru    = $PassThru
                    ScriptBlock = $ScriptBlock
                    Force       = $Force
                }
                if( $Using_SmartAlias_Set ) { $AddCacheMeSplat.Force = $True }
                Add-CacheMe @AddCacheMeSplat
                return
            }
        }
        throw 'ShouldNeverReachUnlessLogicChanged'
    }
    end {
        if($Param_Pipeline_IsDefined) {
            $AddCacheMeSplat = @{
                Name        = $Name
                PassThru    = $PassThru
                ScriptBlock = { $Items }
                Force       = $Force
            }
            if( $Using_SmartAlias_Set ) { $AddCacheMeSplat.Force = $True }
            Add-CacheMe @AddCacheMeSplat
            return

        } else {
            return
        }
        throw 'ShouldNeverReachUnlessLogicChanged'
    }
}

function Out-CacheMe {
    <#
    .SYNOPSIS
        Pipe values that you'd update the cache
    #>

    [CmdletBinding()]
    param(

        # label/key name
        [Alias('KeyName', 'Id', 'Key')]
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory, Position = 0)]
        [string]$Name,

        # pipe anything
        [Parameter(Mandatory, ValueFromPipeline)]
        [object[]]$InputObject,

        # Normally hide output. Passthru to include it
        [switch]$PassThru
    )
    begin {
        [List[Object]]$Items = @()
    }
    process {
        $Items.AddRange(@( $InputObject ))
    }
    end {
        'CacheMe::Out-CachMe captured output, saving to key: "{0}"' -f @(
            $Name
        ) | Write-Information -infa 'Continue'
        Set-CacheMe -Force -Name $Name -ScriptBlock { $Items }
    }
}

Export-ModuleMember -Function @(
    '*-CacheMe*'
    'CacheMe.*'

    if( $ModuleConfig.ExportDebugFunctions ) {
        # 'WarnIf.NotType'
        'Cache*_*'
        'Format-*'
    }
    if ($ModuleConfig.ExportAggressiveNames) {
        'Cache.*'
        'Cached*'
    }
) -Alias @(
    'CacheMe'
    '*-CacheMe*'
    'CacheMe.*'
    if ($ModuleConfig.ExportAggressiveNames) {
        'Cached*'
        'Cache.*'
    }
    if( $ModuleConfig.ExportDebugFunctions ) {
        # 'WarnIf.NotType'
        'Cache*_*'
    }
) -Variable @(
    # '*CacheMe*'
    # 'CacheMe.*'
    if ($ModuleConfig.ExportAggressiveNames) {
        # 'Cache.*'
        # 'Cached*'
    }
)

if($ModuleConfig.ExportDebugFunctions) {
    'CacheMeIfYouCan::ExportDebugFunctions is enabled' | Write-warning
}
if($ModuleConfig.ExportAggressiveNames) {
    $ModuleConfig.ExportAggressiveNames
        | Join-String -op 'CacheMeIfYouCan::ModuleConfig.ExportAggressiveNames: '
        | write-warning
}