src/functions/discovery-cache.ps1

#requires -version 7.0

$script:discoveryCacheMemory = @{}
$script:discoveryCacheVersion = 1

function Get-ConfigMapDiscoveryCachePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$SourceFile,
        [ValidateSet('build', 'conf')]
        [string]$Language
    )

    $fullPath = [System.IO.Path]::GetFullPath($SourceFile)
    $dir = Split-Path -Parent $fullPath
    $leaf = Split-Path -Leaf $fullPath
    $name = if ($leaf -match '^\.(.+)\.map\.ps1$') {
        $Matches[1]
    }
    else {
        [System.IO.Path]::GetFileNameWithoutExtension($leaf)
    }

    $isCanonical = ($Language -eq 'build' -and $name -eq 'build') -or
                   ($Language -eq 'conf' -and $name -eq 'configuration')
    $fileName = if ($isCanonical) {
        "discovery.$name.cache.json"
    }
    else {
        "discovery.$name.$Language.cache.json"
    }

    return Join-Path (Join-Path $dir '.configmap') $fileName
}

function Get-ConfigMapDiscoveryMemoryKey {
    param(
        [string]$SourceFile,
        [string]$Language
    )
    return "$([System.IO.Path]::GetFullPath($SourceFile))|$Language"
}

function Add-ConfigMapDependsOnDependencies {
    param(
        [System.Collections.IDictionary]$Node,
        [hashtable]$OperationContext,
        [System.Collections.IDictionary]$Map = $null
    )

    if (!$Node -or !$OperationContext) {
        return
    }
    if (-not $Node.Contains('_dependsOn')) {
        return
    }

    $raw = $Node['_dependsOn']
    if ($null -eq $raw) {
        return
    }

    $paths = if ($raw -is [System.Collections.IEnumerable] -and $raw -isnot [string]) {
        @($raw)
    }
    else {
        @($raw)
    }

    $baseDir = $null
    if ($Node._baseDir) {
        $baseDir = [string]$Node._baseDir
    }
    elseif ($Map -and $Map._baseDir) {
        $baseDir = [string]$Map._baseDir
    }
    elseif ($Map -and $Map._sourceFile) {
        $baseDir = Split-Path -Parent $Map._sourceFile
    }
    elseif ($Node._sourceFile) {
        $baseDir = Split-Path -Parent $Node._sourceFile
    }

    foreach ($item in $paths) {
        if ($item -isnot [string] -or [string]::IsNullOrWhiteSpace($item)) {
            continue
        }

        $path = $item

        if ([System.IO.Path]::IsPathRooted($path)) {
            $fullPath = [System.IO.Path]::GetFullPath($path)
        }
        else {
            $resolved = if ($baseDir) { Join-Path $baseDir $path } else { $path }
            $fullPath = [System.IO.Path]::GetFullPath($resolved)
        }

        if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
            $OperationContext.Dependencies[$fullPath] = (Get-Item -LiteralPath $fullPath).LastWriteTimeUtc.Ticks
        }
        else {
            $OperationContext.Dependencies[$fullPath] = $null
        }
    }
}

function ConvertTo-ConfigMapDiscoveryDependencyList {
    param([hashtable]$Dependencies)

    $list = @()
    foreach ($path in ($Dependencies.Keys | Sort-Object)) {
        $list += @{
            path  = $path
            ticks = $Dependencies[$path]
        }
    }
    return $list
}

function Get-EntryParameterMeta {
    <#
    .SYNOPSIS
        Extracts static parameter metadata from a scriptblock AST without resolving @sibling ValidateSet.
    #>

    param([scriptblock]$Func)

    $result = @()
    if (!$Func -or !$Func.Ast.ParamBlock -or !$Func.Ast.ParamBlock.Parameters) {
        return $result
    }

    $exclude = @('$_context', '$_self')
    foreach ($ast in $Func.Ast.ParamBlock.Parameters) {
        $name = $ast.Name.ToString().Trim('`$')
        if ("`$$name" -in $exclude -or $name -in @('_context', '_self')) {
            continue
        }

        $paramType = $ast.StaticType
        $isSwitch = $false
        $validateSet = $null
        $hasValidateSet = $false
        $staticValues = @()

        foreach ($attr in $ast.Attributes) {
            if ($attr -is [System.Management.Automation.Language.TypeConstraintAst]) {
                if ($attr.TypeName.ToString() -eq 'switch') {
                    $paramType = [switch]
                    $isSwitch = $true
                }
            }
            elseif ($attr -is [System.Management.Automation.Language.AttributeAst]) {
                $typeName = $attr.TypeName.ToString() -replace '^.*\.', ''
                if ($typeName -eq 'ValidateSet') {
                    $hasValidateSet = $true
                    foreach ($arg in $attr.PositionalArguments) {
                        if ($arg -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                            $staticValues += $arg.Value
                        }
                    }
                }
            }
        }

        if ($paramType -eq [switch]) {
            $isSwitch = $true
        }

        if ($hasValidateSet) {
            if ($staticValues.Count -eq 1 -and $staticValues[0] -match '^@') {
                $validateSet = $null
            }
            elseif ($staticValues.Count -gt 0) {
                $validateSet = @($staticValues)
            }
        }

        $typeName = if ($isSwitch) {
            'switch'
        }
        elseif ($paramType -eq [string]) {
            'string'
        }
        else {
            $paramType.Name
        }

        $result += @{
            name        = $name
            type        = $typeName
            isSwitch    = [bool]$isSwitch
            validateSet = $validateSet
        }
    }

    return $result
}

function New-ConfigMapDiscoveryEntryDescriptor {
    param(
        [string]$Key,
        $Entry,
        [ValidateSet('build', 'conf')]
        [string]$Language
    )

    $reservedKeys = (Get-MapLanguage $Language).reservedKeys
    $isParent = Test-IsParentEntry $Entry -ReservedKeys $reservedKeys
    $description = ''
    if ($Entry -is [System.Collections.IDictionary] -and $Entry.description) {
        $description = [string]$Entry.description
    }

    $parameters = @()
    if (-not $isParent -and -not (Test-BuildAllEntry $Entry)) {
        try {
            $command = Get-EntryCommand $Entry 'exec'
            if ($command -is [scriptblock]) {
                $parameters = @(Get-EntryParameterMeta $command)
            }
        }
        catch {
            $parameters = @()
        }
    }

    return @{
        key         = $Key
        isParent    = [bool]$isParent
        description = $description
        parameters  = $parameters
    }
}

function ConvertTo-ConfigMapDiscoveryEntryDescriptors {
    param(
        [System.Collections.IDictionary]$EntryList,
        [ValidateSet('build', 'conf')]
        [string]$Language
    )

    $descriptors = @()
    foreach ($kvp in $EntryList.GetEnumerator()) {
        $descriptors += New-ConfigMapDiscoveryEntryDescriptor -Key $kvp.Key -Entry $kvp.Value -Language $Language
    }
    return $descriptors
}

function Test-ConfigMapDiscoveryCacheValid {
    param(
        $Cache,
        [string]$SourceFile,
        [ValidateSet('build', 'conf')]
        [string]$Language
    )

    if (!$Cache) { return $false }
    if ($Cache.version -ne $script:discoveryCacheVersion) { return $false }

    $rootMap = [System.IO.Path]::GetFullPath($SourceFile)
    if ("$($Cache.rootMap)" -ne $rootMap) { return $false }
    if ("$($Cache.language)" -ne $Language) { return $false }
    if (!$Cache.dependencies) { return $false }

    foreach ($dep in @($Cache.dependencies)) {
        $path = [string]$dep.path
        if ([string]::IsNullOrEmpty($path)) { return $false }

        $exists = Test-Path -LiteralPath $path -PathType Leaf
        $storedTicks = $dep.ticks

        if ($null -eq $storedTicks) {
            if ($exists) { return $false }
            continue
        }

        if (-not $exists) { return $false }

        $currentTicks = (Get-Item -LiteralPath $path).LastWriteTimeUtc.Ticks
        if ([int64]$currentTicks -ne [int64]$storedTicks) { return $false }
    }

    return $true
}

function Read-ConfigMapDiscoveryCache {
    param([string]$CachePath)

    if (!(Test-Path -LiteralPath $CachePath -PathType Leaf)) {
        return $null
    }

    try {
        return Get-Content -LiteralPath $CachePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        return $null
    }
}

function Write-ConfigMapDiscoveryCache {
    param(
        [string]$CachePath,
        $Cache
    )

    try {
        $dir = Split-Path -Parent $CachePath
        if (!(Test-Path -LiteralPath $dir -PathType Container)) {
            New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null
        }

        $json = $Cache | ConvertTo-Json -Depth 8
        Set-Content -LiteralPath $CachePath -Value $json -Encoding utf8 -ErrorAction Stop
    }
    catch {
    }
}

function Build-ConfigMapDiscoveryCache {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Map,
        [ValidateSet('build', 'conf')]
        [string]$Language
    )

    $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile)
    $operationContext = New-ConfigMapOperationContext
    $operationContext.Dependencies[$sourceFile] = (Get-Item -LiteralPath $sourceFile).LastWriteTimeUtc.Ticks

    $pair = Get-MapEntryListPair -map $Map -language $Language -OperationContext $operationContext

    return @{
        version      = $script:discoveryCacheVersion
        rootMap      = $sourceFile
        language     = $Language
        dependencies = @(ConvertTo-ConfigMapDiscoveryDependencyList $operationContext.Dependencies)
        entries      = @{
            hierarchical = @(ConvertTo-ConfigMapDiscoveryEntryDescriptors $pair.hierarchical $Language)
            flatten      = @(ConvertTo-ConfigMapDiscoveryEntryDescriptors $pair.flatten $Language)
        }
    }
}

function Test-ConfigMapDiscoveryCacheEnabled {
    param([System.Collections.IDictionary]$Map)

    $overrides = $null
    if ($Map._settings -is [System.Collections.IDictionary] -and $Map._settings.Contains('DiscoveryCache')) {
        $overrides = @{ DiscoveryCache = $Map._settings['DiscoveryCache'] }
    }

    $previous = Enter-ConfigMapSettingsScope -Settings $overrides
    try {
        return Test-ConfigMapFeatureEnabled -Name DiscoveryCache
    }
    finally {
        Exit-ConfigMapSettingsScope -PreviousSettings $previous
    }
}

function Get-ConfigMapDiscoveryCache {
    <#
    .SYNOPSIS
        Returns a fresh or rebuilt discovery cache for a file-backed map.
    .DESCRIPTION
        Skips disk cache when the map has no _sourceFile (in-memory maps).
        Uses a process memory layer keyed by source file and language.
        Disabled when DiscoveryCache is opted out via _settings or QCONF_DiscoveryCache.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Map,
        [ValidateSet('build', 'conf')]
        [string]$Language
    )

    if (!$Map._sourceFile) {
        return $null
    }

    if (-not (Test-ConfigMapDiscoveryCacheEnabled -Map $Map)) {
        return $null
    }

    $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile)
    $memoryKey = Get-ConfigMapDiscoveryMemoryKey -SourceFile $sourceFile -Language $Language

    $memoryCache = $script:discoveryCacheMemory[$memoryKey]
    if (Test-ConfigMapDiscoveryCacheValid -Cache $memoryCache -SourceFile $sourceFile -Language $Language) {
        return $memoryCache
    }

    $cachePath = Get-ConfigMapDiscoveryCachePath -SourceFile $sourceFile -Language $Language
    $diskCache = Read-ConfigMapDiscoveryCache -CachePath $cachePath
    if (Test-ConfigMapDiscoveryCacheValid -Cache $diskCache -SourceFile $sourceFile -Language $Language) {
        $script:discoveryCacheMemory[$memoryKey] = $diskCache
        return $diskCache
    }

    $built = Build-ConfigMapDiscoveryCache -Map $Map -Language $Language
    Write-ConfigMapDiscoveryCache -CachePath $cachePath -Cache $built
    $script:discoveryCacheMemory[$memoryKey] = $built
    return $built
}

function Clear-ConfigMapDiscoveryCacheMemory {
    $script:discoveryCacheMemory = @{}
}