Private.ps1

function Add-KrAspNetCoreType {
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter()]
        [ValidateSet('net8.0', 'net9.0', 'net10.0')]
        [string]$Version = 'net8.0'
    )
    $versionNumber = $Version -replace 'net(\d+).*', '$1'
    $dotnetPath = (Get-Command -Name 'dotnet' -ErrorAction Stop).Source
    $realDotnetPath = (Get-Item $dotnetPath).Target
    if (-not $realDotnetPath) { $realDotnetPath = $dotnetPath }elseif ($realDotnetPath -notmatch '^/') {
        # If the target is a relative path, resolve it from the parent of $dotnetPath (e.g. symlink in same folder)
        $realDotnetPath = Join-Path -Path (Split-Path -Path $dotnetPath -Parent) -ChildPath $realDotnetPath
        $realDotnetPath = [System.IO.Path]::GetFullPath($realDotnetPath)
    }
    $dotnetDir = Split-Path -Path $realDotnetPath -Parent
    if (-not $dotnetDir) {
        throw 'Could not determine the path to the dotnet executable.'
    }
    $baseDir = Join-Path -Path $dotnetDir -ChildPath 'shared\Microsoft.AspNetCore.App'
    if (-not (Test-Path -Path $baseDir -PathType Container)) {
        throw "ASP.NET Core shared framework not found at $baseDir."
    }
    $versionDirs = Get-ChildItem -Path $baseDir -Directory | Where-Object { $_.Name -like "$($versionNumber).*" } | Sort-Object Name -Descending

    # Try each version directory until we find one with all required assemblies
    $verDir = $versionDirs | Select-Object -First 1
    if (-not $verDir) {
        Write-Error "Could not find ASP.NET Core assemblies for version $Version in $baseDir."
        Write-Warning "Please download the Runtime $Version from https://dotnet.microsoft.com/en-us/download/dotnet/$versionNumber.0"

        throw "Microsoft.AspNetCore.App version $Version not found in $baseDir."
    }

    # Collect required assemblies
    $assemblies = @()

    Get-ChildItem -Path $verDir.FullName -Filter '*.dll' |
        Where-Object {
            $_.Name -like 'Microsoft.*.dll' -or
            $_.Name -eq 'System.IO.Pipelines.dll'
        } | ForEach-Object {
            if ($assemblies -notcontains $_.Name) {
                $assemblies += $_.Name
            }
        }

    Write-Verbose "Collected assemblies: $($assemblies -join ', ')"

    # Check if all required assemblies are present
    $allFound = $true
    foreach ($asm in $assemblies) {
        $asmPath = Join-Path -Path $verDir.FullName -ChildPath $asm
        if (-not (Test-Path -Path $asmPath)) {
            Write-Verbose "Assembly $asm not found in $($verDir.FullName)"
            $allFound = $false
            break
        }
    }

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

    # Load all assemblies
    $result = $true
    foreach ($asm in $assemblies) {
        $asmPath = Join-Path -Path $verDir.FullName -ChildPath $asm
        $result = $result -and (Assert-KrAssemblyLoaded -AssemblyPath $asmPath)
    }

    Write-Verbose "Loaded ASP.NET Core assemblies from $($verDir.FullName)"
    return $result
}
function Add-KrCodeAnalysisType {
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$ModuleRootPath,
        [Parameter(Mandatory = $true)]
        [string]$Version
    )
    $codeAnalysisassemblyLoadPath = Join-Path -Path $ModuleRootPath -ChildPath 'lib' -AdditionalChildPath 'Microsoft.CodeAnalysis', $Version
    return(
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.dll')) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.Workspaces.dll')) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.dll')) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.Scripting.dll')) -and
        #Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath "Microsoft.CodeAnalysis.Razor.dll")
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.VisualBasic.dll')) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.VisualBasic.Workspaces.dll')) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.Workspaces.dll')) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.Scripting.dll'))
    )
}
function Assert-KrAssemblyLoaded {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$AssemblyPath,

        # Default = require it to be loadable from the Default ALC (fixes VSCode/PSES split-load issue)
        [System.Runtime.Loader.AssemblyLoadContext]$LoadContext = [System.Runtime.Loader.AssemblyLoadContext]::Default
    )

    if (-not (Test-Path -LiteralPath $AssemblyPath -PathType Leaf)) {
        throw "Assembly not found at path: $AssemblyPath"
    }

    $full = (Resolve-Path -LiteralPath $AssemblyPath).Path
    $asmName = [System.Reflection.AssemblyName]::GetAssemblyName($full)

    # Find an already-loaded assembly with same simple name *in the desired ALC*
    $loadedInContext = [AppDomain]::CurrentDomain.GetAssemblies() |
        Where-Object {
            $_.GetName().Name -eq $asmName.Name -and
            ([System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) -eq $LoadContext)
        } |
        Select-Object -First 1

    if ($loadedInContext) {
        Write-Verbose ('Assembly already loaded in {0} ALC: {1} {2} from {3}' -f `
            ($LoadContext.Name ?? 'Default'), $loadedInContext.GetName().Name, $loadedInContext.GetName().Version, $loadedInContext.Location)
        return $true
    }

    Write-Verbose ('Loading assembly into {0} ALC: {1}' -f ($LoadContext.Name ?? 'Default'), $full)

    try {
        # Load into the requested context (Default by default)
        [void]$LoadContext.LoadFromAssemblyPath($full)
        return $true
    } catch {
        Write-Error "Failed to load assembly: $full"
        Write-Error $_
        return $false
    }
}
function Get-EntryScriptPath {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    $self = $PSCommandPath ? (Resolve-Path -LiteralPath $PSCommandPath).ProviderPath : $null

    # Take the last real script caller in the stack
    $stack = @(Get-PSCallStack)
    [System.Array]::Reverse($stack)
    foreach ($f in $stack) {
        $p = $f.InvocationInfo.ScriptName
        if (-not $p) { continue }

        try {
            $resolved = (Resolve-Path -LiteralPath $p -ErrorAction Stop).ProviderPath
        } catch {
            Write-Debug "Failed to resolve path '$p': $_"
            continue
        }

        if ($resolved -and $resolved -ne $self) {
            return $resolved
        }
    }

    return $null
}
function Get-KrCommandsByContext {
    [CmdletBinding(DefaultParameterSetName = 'AnyOf')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'AnyOf')]
        [ValidateSet('Definition', 'Route', 'Schedule', 'ScheduleAndDefinition', 'Runtime', 'Everywhere')]
        [string[]]$AnyOf,

        [Parameter(Mandatory, ParameterSetName = 'AllOf')]
        [ValidateSet('Definition', 'Route', 'Schedule')]
        [string[]]$AllOf,

        [ValidateSet('Definition', 'Route', 'Schedule', 'ScheduleAndDefinition', 'Runtime', 'Everywhere')]
        [string[]]$Not,

        [string]$Module = 'Kestrun',
        [switch]$IncludeNonExported,
        [switch]$Exact,

        [object[]]$Functions
    )
    function _KrNameToMask {
        
        param([Parameter(Mandatory)][string]$Name)
        switch ($Name) {
            'Definition' { 1 }
            'Route' { 2 }
            'Schedule' { 4 }
            'ScheduleAndDefinition' { 5 }
            'Runtime' { 6 }
            'Everywhere' { 7 }
            default { throw "Unknown context '$Name'." }
        }
    }

    $cmds = if ($Functions) {
        $Functions
    } else {
        if ($IncludeNonExported) { Get-Command -Module $Module -All } else { Get-Command -Module $Module }
    }

    $target = 0
    if ($PSCmdlet.ParameterSetName -eq 'AnyOf') { foreach ($n in $AnyOf) { $target = $target -bor (_KrNameToMask $n) } }
    else { foreach ($n in $AllOf) { $target = $target -bor (_KrNameToMask $n) } }

    $notMask = 0
    foreach ($n in ($Not | ForEach-Object { $_ })) { $notMask = $notMask -bor (_KrNameToMask $n) }

    $match = if ($PSCmdlet.ParameterSetName -eq 'AnyOf') {
        if ($Exact) { { param($m) $m -eq $target } } else { { param($m) ($m -band $target) -ne 0 } }
    } else {
        if ($Exact) { { param($m) $m -eq $target } } else { { param($m) ($m -band $target) -eq $target } }
    }

    foreach ($c in $cmds) {
        $m = 0
        if ($c.CommandType -eq 'Function') {
            $m = Get-KrFunctionContextMask -Function $c
        } elseif ($c.CommandType -eq 'Cmdlet' -and $c.ImplementingType) {
            $a = $c.ImplementingType.GetCustomAttributes($true) |
                Where-Object { $_.GetType().Name -eq 'KestrunRuntimeApiAttribute' } |
                Select-Object -First 1
            if ($a) { $m = [int]([KestrunApiContext]$a.Contexts) }
        }

        if ($m -eq 0) { continue }
        if ($notMask -ne 0 -and ($m -band $notMask) -ne 0) { continue }  # exclude forbidden bits
        if (& $match $m) { $c }
    }
}
function Get-KrDocSet {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet(
            'DefinitionOnly',
            'RouteOnly',
            'ScheduleOnly',
            'RouteAndSchedule',
            'ScheduleAndDefinition',
            'Runtime',   # Route|Schedule
            'Everywhere',
            'ConfigOnly' # Definition but not Route/Schedule
        )]
        [string]$Name,

        [string]$Module = 'Kestrun',
        [switch]$IncludeNonExported,
        [object[]]$Functions
    )

    switch ($Name) {
        'DefinitionOnly' {
            Get-KrCommandsByContext -AnyOf Definition -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'RouteOnly' {
            Get-KrCommandsByContext -AnyOf Route -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'ScheduleOnly' {
            Get-KrCommandsByContext -AnyOf Schedule -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'RouteAndSchedule' {
            Get-KrCommandsByContext -AllOf Route, Schedule -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'ScheduleAndDefinition' {
            Get-KrCommandsByContext -AnyOf ScheduleAndDefinition -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'Runtime' {
            Get-KrCommandsByContext -AnyOf Runtime -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'Everywhere' {
            Get-KrCommandsByContext -AnyOf Everywhere -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
        'ConfigOnly' {
            # Definition but NOT Route or Schedule
            Get-KrCommandsByContext -AnyOf Definition -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions
        }
    }
}
function Get-KrFunctionContextMask {
    param([System.Management.Automation.FunctionInfo]$Function)

    if (-not $Function.ScriptBlock) { return 0 }

    $fnAst = $Function.ScriptBlock.Ast.
    Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $Function.Name }, $true)
    if (-not $fnAst) { return 0 }

    $attrs = @()
    if ($fnAst.Attributes) { $attrs += $fnAst.Attributes }
    if ($fnAst.Body -and $fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Attributes) {
        $attrs += $fnAst.Body.ParamBlock.Attributes
    }

    $kr = $attrs | Where-Object { $_.TypeName.Name -eq 'KestrunRuntimeApi' } | Select-Object -First 1
    if (-not $kr) { return 0 }

    $txt = (($kr.PositionalArguments + $kr.NamedArguments.Expression) | Where-Object { $_ }).Extent.Text
    #| ForEach-Object { $_.Extent.Text } -join ' '
    $mask = switch ($txt) {
        "'Everywhere'" { 7 }
        "'Runtime'" { 6 }
        "'ScheduleAndDefinition'" { 5 }
        "'Definition'" { 1 }
        "'Route'" { 2 }
        "'Schedule'" { 4 }
    }

    return $mask
}
function Resolve-KrSchemaTypeLiteral {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Schema
    )

    if ($Schema -is [type]) {
        return $Schema
    } elseif ($Schema -is [string]) {
        $s = $Schema.Trim()

        # Require PowerShell type-literal form: [TypeName] or [Namespace.TypeName]
        # Disallow generics, arrays, scripts, whitespace, operators, etc.
        if ($s -notmatch '^\[[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*\]$') {
            throw "Invalid -Schema '$Schema'. Only type literals like '[OpenApiDate]' are allowed."
        }

        # Optional: reject some known-dangerous tokens defensively (belt + suspenders)
        if ($s -match '[\s;|&`$(){}<>]') {
            throw "Invalid -Schema '$Schema'. Disallowed characters detected."
        }

        $Schema = Invoke-Expression $s

        if ($Schema -isnot [type]) {
            throw "Invalid -Schema '$Schema'. Evaluation did not produce a [Type]."
        }
        return $Schema
    } else {
        throw "Invalid -Schema type '$($Schema.GetType().FullName)'. Use ([string]) or 'System.String'."
    }
}
function Test-KrFunctionHasAttribute {
    [CmdletBinding()]
    [outputType([bool])]
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.CommandInfo]$Command,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$AttributeNameRegex
    )
    try {
        $sb = $Command.ScriptBlock
        if (-not $sb) {
            return $false
        }

        # Prefer runtime attributes: this is what PowerShell actually binds
        foreach ($a in @($sb.Attributes)) {
            $t = $a.GetType()
            if ($t.Name -match $AttributeNameRegex -or $t.FullName -match $AttributeNameRegex) {
                return $true
            }
        }

        # Fallback: parse the definition and scan AttributeAst nodes
        $def = $Command.Definition
        if (-not $def) {
            return $false
        }

        $tokens = $null
        $parseErrors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseInput($def, [ref]$tokens, [ref]$parseErrors)

        if ($parseErrors -and $parseErrors.Count -gt 0) {
            return $false
        }

        $found = $ast.FindAll({
                param($n)
                $n -is [System.Management.Automation.Language.AttributeAst] -and
                ($n.TypeName?.Name -match $AttributeNameRegex)
            }, $true)

        return ($found.Count -gt 0)
    } catch {
        return $false
    }
    return $false
}
function ConvertTo-DateTimeOffset {
    [CmdletBinding()]
    [OutputType('System.DateTimeOffset')]
    param(
        [Parameter(Mandatory)]
        [object]$InputObject
    )

    # Already DateTimeOffset
    if ($InputObject -is [DateTimeOffset]) { return $InputObject }

    # DateTime -> DateTimeOffset (local)
    if ($InputObject -is [DateTime]) {
        return [DateTimeOffset]::new([DateTime]$InputObject)
    }

    # String → try absolute date/time first
    if ($InputObject -is [string]) {
        $s = $InputObject.Trim()
        $dto = $null
        if ([DateTimeOffset]::TryParse($s, [ref]$dto)) { return $dto }

        # If not a date, try treating the string as a duration and add to now
        try {
            $ts = ConvertTo-TimeSpan -InputObject $s
            return [DateTimeOffset]::Now.Add($ts)
        } catch {
            throw "Invalid Expires value '$s'. Provide an absolute date (e.g. '2025-09-10T23:00Z') or a duration (e.g. '2h', '1d')."
        }
    }

    # TimeSpan => relative expiry from now
    if ($InputObject -is [TimeSpan]) {
        return [DateTimeOffset]::Now.Add([TimeSpan]$InputObject)
    }

    # Numeric seconds => relative expiry
    if ($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [double] -or $InputObject -is [decimal]) {
        return [DateTimeOffset]::Now.AddSeconds([double]$InputObject)
    }

    throw "Cannot convert value of type [$($InputObject.GetType().FullName)] to [DateTimeOffset]."
}
function ConvertTo-KrThreadSafeValue {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Value
    )

    if ($null -eq $Value) {
        return $null
    }

    # --- Hashtable (@{}) ---
    if ($Value -is [hashtable]) {
        return [hashtable]::Synchronized($Value)
    }

    # --- OrderedDictionary ([ordered]@{}) ---
    if ($Value -is [System.Collections.Specialized.OrderedDictionary]) {
        # Copy into a normal hashtable and wrap
        $ht = @{}
        foreach ($entry in $Value.GetEnumerator()) {
            $ht[$entry.Key] = $entry.Value
        }
        return [hashtable]::Synchronized($ht)
    }

    # --- ArrayList ---
    if ($Value -is [System.Collections.ArrayList]) {
        return [System.Collections.ArrayList]::Synchronized($Value)
    }

    # --- Any other IDictionary (generic or not, but not handled above) ---
    if ($Value -is [System.Collections.IDictionary]) {
        $dict = [System.Collections.Concurrent.ConcurrentDictionary[object, object]]::new()
        foreach ($entry in $Value.GetEnumerator()) {
            $null = $dict.TryAdd($entry.Key, $entry.Value)
        }
        return $dict
    }

    # --- Arrays: treat as immutable snapshots ---
    if ($Value -is [Array]) {
        return $Value
    }

    # --- PSCustomObject, scalars, etc.: just return as-is ---
    return $Value
}
function ConvertTo-TimeSpan {
    [CmdletBinding()]
    [OutputType('System.TimeSpan')]
    param(
        [Parameter(Mandatory)]
        [object]$InputObject
    )

    # 1) Already a TimeSpan
    if ($InputObject -is [TimeSpan]) { return $InputObject }

    # 2) Numeric => seconds
    if ($InputObject -is [int] -or
        $InputObject -is [long] -or
        $InputObject -is [double] -or
        $InputObject -is [decimal]) {
        return [TimeSpan]::FromSeconds([double]$InputObject)
    }

    # 3) String parsing
    if ($InputObject -is [string]) {
        $s = $InputObject.Trim()

        # Try .NET built-in formats first: "c", "g", "G" (e.g., "00:30:00", "1.02:03:04")
        $ts = [TimeSpan]::Zero
        if ([TimeSpan]::TryParse($s, [ref]$ts)) { return $ts }

        # Compact tokens: 1d2h30m15s250ms (order-insensitive, any subset)
        if ($s -match '^(?i)(?:\s*(?<d>\d+)\s*d)?(?:\s*(?<h>\d+)\s*h)?(?:\s*(?<m>\d+)\s*m)?(?:\s*(?<s>\d+)\s*s)?(?:\s*(?<ms>\d+)\s*ms)?\s*$') {
            $days = [int]::Parse(('0' + $Matches['d']))
            $hrs = [int]::Parse(('0' + $Matches['h']))
            $min = [int]::Parse(('0' + $Matches['m']))
            $sec = [int]::Parse(('0' + $Matches['s']))
            $msec = [int]::Parse(('0' + $Matches['ms']))
            return [TimeSpan]::new($days, $hrs, $min, $sec, $msec)
        }

        throw "Invalid TimeSpan format: '$s'. Try '00:30:00', '1.02:03:04', or tokens like '1d2h30m15s'."
    }

    throw "Cannot convert value of type [$($InputObject.GetType().FullName)] to [TimeSpan]."
}
function Get-KrFormattedMessage {
    param(
        [Parameter(Mandatory = $true)]
        [Serilog.ILogger]$Logger,

        [Parameter(Mandatory = $true)]
        [Serilog.Events.LogEventLevel]$Level,

        [parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Message,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$Values,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [System.Exception]$Exception
    )

    $parsedTemplate = $null
    $boundProperties = $null
    if ($Logger.BindMessage($Message, $Values, [ref]$parsedTemplate, [ref]$boundProperties)) {
        $logEvent = [Serilog.Events.LogEvent]::new([System.DateTimeOffset]::Now, $Level, $Exception, $parsedTemplate, $boundProperties)
        $strWriter = [System.IO.StringWriter]::new()
        # Use the global TextFormatter if available, otherwise use the default formatter from Kestrun.Logging
        [Kestrun.Logging]::TextFormatter.Format($logEvent, $strWriter)
        $message = $strWriter.ToString()
        $strWriter.Dispose()
        $message
    }
}
function Set-KrLogLevelToPreference {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true)]
        [Serilog.Events.LogEventLevel]$Level
    )

    if ($PSCmdlet.ShouldProcess('Set log level preferences')) {
        if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Verbose)) {
            Set-Variable VerbosePreference -Value 'Continue' -Scope Global
        } else {
            Set-Variable VerbosePreference -Value 'SilentlyContinue' -Scope Global
        }

        if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Debug)) {
            Set-Variable DebugPreference -Value 'Continue' -Scope Global
        } else {
            Set-Variable DebugPreference -Value 'SilentlyContinue' -Scope Global
        }

        if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Information)) {
            Set-Variable InformationPreference -Value 'Continue' -Scope Global
        } else {
            Set-Variable InformationPreference -Value 'SilentlyContinue' -Scope Global
        }

        if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Warning)) {
            Set-Variable WarningPreference -Value 'Continue' -Scope Global
        } else {
            Set-Variable WarningPreference -Value 'SilentlyContinue' -Scope Global
        }
    }
}
function Write-KrOutsideRouteWarning {
    param (
        [string]$FunctionName = $PSCmdlet.MyInvocation.InvocationName
    )
    if (Test-KrLogger) {
        Write-KrLog -Level Warning -Message '{function} must be called inside a route script block where $Context is available.' -Values $FunctionName
    } else {
        Write-Warning -Message "$FunctionName must be called inside a route script block where `$Context is available."
    }
}
function Write-KrSinkPowerShell {
    param(
        [Parameter(Mandatory = $true)]
        [Serilog.Events.LogEvent]$LogEvent,
        [Parameter(Mandatory = $true)]
        [string]$RenderedMessage
    )

    switch ($LogEvent.Level) {
        Verbose {
            Write-Verbose -Message $RenderedMessage
        }
        Debug {
            Write-Debug -Message $RenderedMessage
        }
        Information {
            Write-Information -MessageData $RenderedMessage
        }
        Warning {
            Write-Warning -Message $RenderedMessage
        }
        default {
            Write-Information -MessageData $RenderedMessage -InformationAction 'Continue'
        }
    }
}
function Get-KrAnnotatedFunctionsLoaded {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true)]
        [Kestrun.Hosting.KestrunHost]$Server,
        [Parameter()]
        [string]$DocId = [Kestrun.OpenApi.OpenApiDocDescriptor]::DefaultDocumentationId
    )
    begin {
        # Ensure the server instance is resolved
        $Server = Resolve-KestrunServer -Server $Server

        # All loaded functions now in the runspace
        $funcs = @(Get-Command -CommandType Function | Where-Object {
                $null -eq $_.Module -and $null -eq $_.PsDrive
            })
    }
    process {
        if ( -not $Server.OpenApiDocumentDescriptor.ContainsKey($DocId)) {
            throw "OpenAPI document with ID '$DocId' does not exist on the server."
        }
        $doc = $Server.OpenApiDocumentDescriptor[$DocId]
        $doc.LoadAnnotatedFunctions( $funcs )
    }
}
function _KrJoin-Route {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')]
    param([string]$base, [string]$child)
    $b = ($base ?? '').TrimEnd('/')
    $c = ($child ?? '').TrimStart('/')
    if ([string]::IsNullOrWhiteSpace($b)) { "/$c".TrimEnd('/') -replace '^$', '/' }
    elseif ([string]::IsNullOrWhiteSpace($c)) { if ($b) { $b } else { '/' } }
    else { "$b/$c" }
}
function _KrMerge-Args {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param([hashtable]$a, [hashtable]$b)

    if (-not $a) { return $b }
    if (-not $b) { return $a }
    $m = @{}
    foreach ($k in $a.Keys) { $m[$k] = $a[$k] }
    foreach ($k in $b.Keys) { $m[$k] = $b[$k] } # child overrides
    $m
}
function _KrMerge-MRO {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')]
    param(
        [Parameter(Mandatory)][Kestrun.Hosting.Options.MapRouteOptions]$Parent,
        [Parameter(Mandatory)][Kestrun.Hosting.Options.MapRouteOptions]$Child
    )
    $pattern = if ($Child.Pattern) {
        if ($Parent.Pattern) { "$($Parent.Pattern)/$($Child.Pattern)" } else { $Child.Pattern }
    } else { $Parent.Pattern }

    $extraRefs = if ($null -ne $Child.ScriptCode.ExtraRefs) {
        if ($Parent.ScriptCode.ExtraRefs) {
            $Parent.ScriptCode.ExtraRefs + $Child.ScriptCode.ExtraRefs
        } else {
            $Child.ScriptCode.ExtraRefs
        }
    } else { $Parent.ScriptCode.ExtraRefs }

    $merged = @{
        Pattern = $pattern.Replace('//', '/')
        HttpVerbs = if ($null -ne $Child.HttpVerbs -and ($Child.HttpVerbs.Count -gt 0)) { $Child.HttpVerbs } else { $Parent.HttpVerbs }
        RequireSchemes = _KrMerge-Unique $Parent.RequireSchemes $Child.RequireSchemes
        RequirePolicies = _KrMerge-Unique $Parent.RequirePolicies $Child.RequirePolicies
        CorsPolicy = if ($Child.CorsPolicy) { $Child.CorsPolicy } else { $Parent.CorsPolicy }
        OpenAPI = if ($Child.OpenAPI) { $Child.OpenAPI } else { $Parent.OpenAPI }
        ThrowOnDuplicate = $Child.ThrowOnDuplicate -or $Parent.ThrowOnDuplicate
        ScriptCode = @{
            Code = if ($Child.ScriptCode.Code) { $Child.ScriptCode.Code } else { $Parent.ScriptCode.Code }
            Language = if ($null -ne $Child.ScriptCode.Language) { $Child.ScriptCode.Language } else { $Parent.ScriptCode.Language }
            ExtraImports = _KrMerge-Unique $Parent.ScriptCode.ExtraImports $Child.ScriptCode.ExtraImports
            ExtraRefs = $extraRefs
            Arguments = _KrMerge-Args $Parent.ScriptCode.Arguments $Child.ScriptCode.Arguments
        }
    }
    return New-KrMapRouteOption -Property $merged
}
function _KrMerge-Unique {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')]
    param([string[]]$a, [string[]]$b)
    @(($a + $b | Where-Object { $_ -ne $null } | Select-Object -Unique))
}
function _KrWith-MRO {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')]
    param(
        [Parameter(Mandatory)][Kestrun.Hosting.Options.MapRouteOptions]$Base,
        [Parameter()][hashtable]$Override = @{}
    )
    $h = @{
        Pattern = $Base.Pattern
        HttpVerbs = $Base.HttpVerbs
        Code = $Base.Code
        Language = $Base.Language
        ExtraImports = $Base.ExtraImports
        ExtraRefs = $Base.ExtraRefs
        RequireSchemes = $Base.RequireSchemes
        RequirePolicies = $Base.RequirePolicies
        CorsPolicy = $Base.CorsPolicy
        Arguments = $Base.Arguments
        OpenAPI = $Base.OpenAPI
        ThrowOnDuplicate = $Base.ThrowOnDuplicate
    }
    foreach ($k in $Override.Keys) { $h[$k] = $Override[$k] }
    return New-KrMapRouteOption -Property $h
}
function Get-KrUserImportedModule {
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSModuleInfo])]
    param()

    # ----- constants ----------------------------------------------------------
    $inboxRoot = [IO.Path]::GetFullPath( (Join-Path $PSHOME 'Modules') )

    # regex fragment that matches “…\.vscode\extensions\ms-vscode.powershell…”
    $vsCodeRegex = [Regex]::Escape(
        [IO.Path]::Combine('.vscode', 'extensions', 'ms-vscode.powershell')
    ) -replace '\\\\', '[\\/]'   # make path-separator agnostic
    # -------------------------------------------------------------------------

    Get-Module | Where-Object {
        $path = [IO.Path]::GetFullPath($_.ModuleBase)

        $isInbox = $path.StartsWith($inboxRoot,
            $IsWindows ? 'OrdinalIgnoreCase' : 'Ordinal')
        $isVSCode = $path -match $vsCodeRegex
        $isMSPSSpace = $_.Name -like 'Microsoft.PowerShell.*'

        -not ($isInbox -or $isVSCode -or $isMSPSSpace)
    }
}
function Resolve-KestrunServer {
    param (
        [Kestrun.Hosting.KestrunHost]$Server
    )
    if ($null -eq $Server) {
        if ($null -ne $KrServer) {
            Write-KrLog -Level Verbose -Message "No server specified, using global `$KrServer variable."
            # If no server is specified, use the global $KrServer variable
            # This is useful for scripts that run in the context of a Kestrun server
            $Server = $KrServer
        } else {
            # Try to get the default Kestrun server instance
            $Server = [Kestrun.KestrunHostManager]::Default
        }
        if ($null -eq $Server) {
            throw 'No Kestrun server instance found. Please create a Kestrun server instance.'
        }
    }
    return $Server
}
function _NormalizeValueToDictionary([object]$Value, [int]$Depth, [int]$MaxRecursionDepth = 8) {
    if ($null -eq $Value) { return $null }
    if ($Depth -gt $MaxRecursionDepth) { return ($Value.ToString()) }

    # Unwrap PSObject shell
    if ($Value -is [System.Management.Automation.PSObject]) {
        $base = $Value.BaseObject
        if ($null -eq $base -or $base -eq $Value) { return $Value.ToString() }
        return _NormalizeValueToDictionary $base ($Depth + 1)
    }

    # Hashtable / IDictionary → new Dictionary[string, object]
    if ($Value -is [System.Collections.IDictionary]) {
        $out = [System.Collections.Generic.Dictionary[string, object]]::new()
        foreach ($key in $Value.Keys) {
            if ([string]::IsNullOrWhiteSpace([string]$key)) { continue }
            $nv = _NormalizeValue $Value[$key] ($Depth + 1)
            if ($null -ne $nv) { $out[[string]$key] = $nv }
        }
        return $out
    }

    # Enumerable (but not string) → List<object>
    if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
        $list = New-Object System.Collections.Generic.List[object]
        foreach ($item in $Value) { $list.Add((_NormalizeValueToDictionary $item ($depth + 1))) }
        return $list
    }

    return $Value  # primitive / POCO
}
function Get-KrAssignedVariable {
    [CmdletBinding(DefaultParameterSetName = 'Given')]
    [OutputType([object[]])]
    [OutputType([System.Collections.Generic.Dictionary[string, object]])]
    param(
        [Parameter(ParameterSetName = 'Given', Position = 0)]
        [scriptblock]$ScriptBlock,

        # NEW: use the caller's scriptblock (parent frame)
        [Parameter(ParameterSetName = 'FromParent', Mandatory)]
        [switch]$FromParent,

        # How many frames up to climb (1 = immediate caller)
        [Parameter(ParameterSetName = 'FromParent')]
        [int]$Up = 1,

        # Optional: skip frames from these modules when searching
        [Parameter(ParameterSetName = 'FromParent')]
        [string[]]$ExcludeModules = @('Kestrun'),

        [Parameter()]
        [switch]$IncludeSetVariable,

        [Parameter()]
        [switch]$ResolveValues,

        [Parameter()]
        [ValidateSet('Array', 'Dictionary', 'StringObjectMap')]
        [string]$OutputStructure = 'Array',

        [Parameter()]
        [ValidateSet('Local', 'Script', 'Global')]
        [string]$DefaultScope = 'Script',

        [Parameter()]
        [string[]]$ExcludeVariables,

        [Parameter()]
        [switch]$WithoutAttributesOnly
    )

    $excludeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $excludeList = if ($null -ne $ExcludeVariables) { $ExcludeVariables } else { @() }
    foreach ($ev in $excludeList) {
        if ([string]::IsNullOrWhiteSpace($ev)) { continue }
        $n = $ev.Trim()
        if ($n.StartsWith('$')) { $n = $n.Substring(1) }
        if (-not [string]::IsNullOrWhiteSpace($n)) {
            [void]$excludeSet.Add($n)
        }
    }

    $rows = [System.Collections.Generic.List[object]]::new()

    
    function _GetScopeAndNameFromVariablePath([System.Management.Automation.VariablePath] $variablePath) {
        $raw = $variablePath.UserPath
        if (-not $raw) {
            $raw = $variablePath.UnqualifiedPath
        }
        if (-not $raw) {
            return $null
        }

        $name = $raw
        $scopeHint = $DefaultScope

        if ($name -match '^(?<scope>global|script|local|private):(?<rest>.+)$') {
            $scopeHint = ($Matches.scope.Substring(0, 1).ToUpperInvariant() + $Matches.scope.Substring(1).ToLowerInvariant())
            $name = $Matches.rest
        }

        # ignore member/index forms and ${foo}
        if ($name.Contains('.') -or $name.Contains('[')) {
            return $null
        }
        if ($name.StartsWith('{') -and $name.EndsWith('}')) {
            $name = $name.Substring(1, $name.Length - 2)
        }

        if (-not $name) {
            return $null
        }

        [pscustomobject]@{
            Name = $name
            ScopeHint = $scopeHint
            ProviderPath = "variable:$($scopeHint):$name"
        }
    }

    
    function _TryResolveValue([string] $name, [string] $scopeHint) {
        if (-not $ResolveValues.IsPresent) {
            return $null
        }

        # When using -FromParent, prefer numeric scope resolution so we can see the caller's values.
        if ($FromParent.IsPresent) {
            # Use Get-Variable (not provider) so we can distinguish "not found" from "$null".
            # Scope depth can vary depending on wrapper frames (VS Code, Invoke-Command, etc.).
            # Try a small window around the computed scopeUp first, then scan a bounded range.
            $scopeCandidates = [System.Collections.Generic.List[int]]::new()

            if ($scopeUp -ge 0) {
                $scopeCandidates.Add([int]$scopeUp)
            }
            if ($scopeUp -ge 0) {
                $scopeCandidates.Add([int]($scopeUp + 1))
            }
            if ($scopeUp -gt 0) {
                $scopeCandidates.Add([int]($scopeUp - 1))
            }

            $maxAdditionalScopes = 25
            $start = [Math]::Max(1, [int]($scopeUp - 2))
            $end = [Math]::Max($start, [int]($scopeUp + $maxAdditionalScopes))
            for ($s = $start; $s -le $end; $s++) {
                $scopeCandidates.Add([int]$s)
            }

            foreach ($s in ($scopeCandidates | Select-Object -Unique)) {
                try {
                    $v = Get-Variable -Name $name -Scope $s -ErrorAction SilentlyContinue
                    if ($null -ne $v) {
                        return $v.Value
                    }
                } catch {
                    # ignore
                    Write-krLog -Level Verbose -Message "Variable '$name' not found at scope depth $s."
                }
            }

            return $null
        }

        try {
            return (Get-Item -ErrorAction SilentlyContinue "variable:${scopeHint}:$name").Value
        } catch {
            return $null
        }
    }

    
    function _TryGetInitializerValueFromAssignment([System.Management.Automation.Language.AssignmentStatementAst] $assignmentAst) {
        if (-not $ResolveValues.IsPresent) {
            return $null
        }
        if ($assignmentAst.Operator -ne [System.Management.Automation.Language.TokenKind]::Equals) {
            return $null
        }

        $expr = $null
        if ($assignmentAst.Right -is [System.Management.Automation.Language.CommandExpressionAst]) {
            $expr = $assignmentAst.Right.Expression
        } elseif ($assignmentAst.Right -is [System.Management.Automation.Language.PipelineAst]) {
            $p = $assignmentAst.Right
            if ($p.PipelineElements.Count -eq 1 -and $p.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) {
                $expr = $p.PipelineElements[0].Expression
            }
        }

        
        function _EvalStatement([System.Management.Automation.Language.StatementAst] $s) {
            if ($null -eq $s) { return $null }

            if ($s -is [System.Management.Automation.Language.CommandExpressionAst]) {
                return _EvalExpr $s.Expression
            }

            if ($s -is [System.Management.Automation.Language.PipelineAst]) {
                if ($s.PipelineElements.Count -eq 1 -and $s.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) {
                    return _EvalExpr $s.PipelineElements[0].Expression
                }
            }

            return $null
        }

        
        function _EvalKeyValuePart($node) {
            if ($null -eq $node) { return $null }

            if ($node -is [System.Management.Automation.Language.ExpressionAst]) {
                return _EvalExpr $node
            }

            if ($node -is [System.Management.Automation.Language.StatementAst]) {
                return _EvalStatement $node
            }

            return $null
        }
        
        function _EvalExpr([System.Management.Automation.Language.ExpressionAst] $e) {
            if ($null -eq $e) { return $null }

            switch ($e.GetType().FullName) {
                'System.Management.Automation.Language.ConstantExpressionAst' { return $e.Value }
                'System.Management.Automation.Language.StringConstantExpressionAst' { return $e.Value }
                'System.Management.Automation.Language.ExpandableStringExpressionAst' { return $e.Value }
                'System.Management.Automation.Language.TypeExpressionAst' { return $e.TypeName.FullName }
                'System.Management.Automation.Language.ParenthesisExpressionAst' {
                    if ($e.Pipeline -and $e.Pipeline.PipelineElements.Count -eq 1 -and $e.Pipeline.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) {
                        return _EvalExpr $e.Pipeline.PipelineElements[0].Expression
                    }
                    return $null
                }
                'System.Management.Automation.Language.ConvertExpressionAst' {
                    # e.g. [int]42 or [ordered]@{...}
                    if ($e.Child -is [System.Management.Automation.Language.ExpressionAst]) {
                        $childValue = _EvalExpr $e.Child
                        if ($e.Type -and $e.Type.TypeName -and ($e.Type.TypeName.FullName -ieq 'ordered') -and ($e.Child -is [System.Management.Automation.Language.HashtableAst])) {
                            # Rebuild as an ordered dictionary
                            $ordered = [ordered]@{}
                            foreach ($kv in $e.Child.KeyValuePairs) {
                                $k = _EvalKeyValuePart $kv.Item1
                                $v = _EvalKeyValuePart $kv.Item2
                                if ($null -eq $k) { return $null }
                                $ordered[$k] = $v
                            }
                            return $ordered
                        }

                        return $childValue
                    }
                    return $null
                }
                'System.Management.Automation.Language.ArrayExpressionAst' {
                    # @( ... )
                    if (-not $e.SubExpression) { return @() }
                    $items = @()
                    foreach ($st in $e.SubExpression.Statements) {
                        $vv = _EvalStatement $st
                        if ($null -eq $vv) { return $null }
                        $items += $vv
                    }
                    return , $items
                }
                'System.Management.Automation.Language.SubExpressionAst' {
                    # $( ... )
                    $items = @()
                    foreach ($st in $e.Statements) {
                        $vv = _EvalStatement $st
                        if ($null -eq $vv) { return $null }
                        $items += $vv
                    }
                    return , $items
                }
                'System.Management.Automation.Language.UnaryExpressionAst' {
                    $v = $null
                    if ($e.Child -is [System.Management.Automation.Language.ExpressionAst]) {
                        $v = _EvalExpr $e.Child
                    }
                    if ($e.TokenKind -eq [System.Management.Automation.Language.TokenKind]::Minus -and $v -is [ValueType]) {
                        try { return -1 * $v } catch { return $null }
                    }
                    return $null
                }
                'System.Management.Automation.Language.ArrayLiteralAst' {
                    $arr = @()
                    foreach ($el in $e.Elements) {
                        if ($el -isnot [System.Management.Automation.Language.ExpressionAst]) { return $null }
                        $vv = _EvalExpr $el
                        if ($null -eq $vv -and ($el -isnot [System.Management.Automation.Language.ConstantExpressionAst])) { return $null }
                        $arr += $vv
                    }
                    return , $arr
                }
                'System.Management.Automation.Language.HashtableAst' {
                    $ht = @{}
                    foreach ($kv in $e.KeyValuePairs) {
                        $k = _EvalKeyValuePart $kv.Item1
                        $v = _EvalKeyValuePart $kv.Item2
                        if ($null -eq $k) { return $null }
                        $ht[$k] = $v
                    }
                    return $ht
                }
                default { return $null }
            }
        }

        if ($expr -is [System.Management.Automation.Language.ExpressionAst]) {
            return _EvalExpr $expr
        }

        return $null
    }

    
    function _GetTypeInfoFromDeclaredType([string] $declaredType) {
        $underlyingName = $declaredType
        $isNullable = $false

        if (-not [string]::IsNullOrWhiteSpace($declaredType)) {
            # Typical PowerShell nullable syntax: Nullable[datetime]
            if ($declaredType -match '^(System\.)?Nullable(`1)?\[(?<inner>.+)\]$') {
                $underlyingName = $Matches.inner
                $isNullable = $true
            }
        }

        $resolvedType = $null
        if (-not [string]::IsNullOrWhiteSpace($underlyingName)) {
            try {
                $resolvedType = [System.Management.Automation.PSTypeName]::new($underlyingName).Type
            } catch {
                $resolvedType = $null
            }
        }

        return [pscustomobject]@{
            Type = $resolvedType
            DeclaredType = $declaredType
            IsNullable = $isNullable
        }
    }

    # ---------- resolve $ScriptBlock source ----------
    if ($FromParent.IsPresent) {
        $allFrames = Get-PSCallStack
        # 0 = this function, 1 = immediate caller, 2+ = higher parents
        $frames = $allFrames | Select-Object -Skip 1

        if ($ExcludeModules.Count) {
            $frames = $frames | Where-Object {
                $mn = $_.InvocationInfo.MyCommand.ModuleName
                -not ($mn -and ($mn -in $ExcludeModules))
            }
        }

        # pick the desired parent frame
        $frame = $frames | Select-Object -Skip ($Up - 1) -First 1
        if (-not $frame) { throw "No parent frame found (Up=$Up)." }

        # Numeric scope depth for Get-Variable (0=this function, 1=caller, ...).
        # The frame index in Get-PSCallStack already matches the numeric scope depth.
        $scopeUp = $allFrames.IndexOf($frame)
        if ($scopeUp -lt 1) { throw 'Parent frame not found.' }

        # prefer its live ScriptBlock; if null, rebuild from file
        $ScriptBlock = $frame.InvocationInfo.MyCommand.ScriptBlock
        if (-not $ScriptBlock -and $frame.ScriptName) {
            $ScriptBlock = [scriptblock]::Create((Get-Content -Raw -LiteralPath $frame.ScriptName))
        }
        if (-not $ScriptBlock) { throw 'Parent frame has no scriptblock or script file to parse.' }
    }

    if (-not $ScriptBlock) {
        throw 'No scriptblock provided. Use -FromParent or pass a ScriptBlock.'
    }
    # Use the original script text so offsets match exactly
    $scriptText = $ScriptBlock.Ast.Extent.Text

    # Find the first *actual command* invocation named Enable-KrConfiguration
    $enableCmd = $ScriptBlock.Ast.FindAll({
            param($node)

            if ($node -isnot [System.Management.Automation.Language.CommandAst]) { return $false }

            $name = $node.GetCommandName()
            return $name -and ($name -ieq 'Enable-KrConfiguration')
        }, $true) | Select-Object -First 1

    if ($enableCmd) {
        # Cut everything before that command
        $pre = $scriptText.Substring(0, $enableCmd.Extent.StartOffset).TrimEnd()

        # Preserve your brace-closing hack
        if ($pre.TrimStart().StartsWith('{')) {
            $pre += "`n}"
        }

        $ScriptBlock = [scriptblock]::Create($pre)
    }



    
    function _IsInFunction([System.Management.Automation.Language.Ast] $node) {
        $p = $node.Parent
        while ($p) {
            if ($p -is [System.Management.Automation.Language.FunctionDefinitionAst]) { return $true }
            if ($p -is [System.Management.Automation.Language.ScriptBlockAst]) { break }
            $p = $p.Parent
        }
        return $false
    }

    
    function _IsInAssignment([System.Management.Automation.Language.Ast] $node) {
        # IMPORTANT: AST parent chains can escape the scanned ScriptBlockAst
        # (e.g. when the scriptblock literal is part of an outer assignment).
        # We only care about assignments that occur *within* the scanned scriptblock.
        $p = $node.Parent
        while ($p) {
            if ($p -is [System.Management.Automation.Language.AssignmentStatementAst]) { return $true }
            if ($p -is [System.Management.Automation.Language.ScriptBlockAst]) { break }
            $p = $p.Parent
        }
        return $false
    }

    
    function _HasAttributes([System.Management.Automation.Language.Ast] $node) {
        $p = $node
        while ($p) {
            if ($p -is [System.Management.Automation.Language.AttributedExpressionAst]) {
                # Note: AttributedExpressionAst stores a single attribute in the `Attribute` property.
                # Multiple attributes are represented as nested AttributedExpressionAst nodes.
                # Type constraints like [int] are represented as TypeConstraintAst and should NOT
                # count as "attributes" for -WithoutAttributesOnly filtering.
                if ($p.Attribute -is [System.Management.Automation.Language.AttributeAst]) {
                    return $true
                }
                # Otherwise, it's a type constraint (e.g. [int]); keep walking up because
                # there may be an outer AttributedExpressionAst with a real attribute.
            }
            if ($p -is [System.Management.Automation.Language.ScriptBlockAst]) { break }
            $p = $p.Parent
        }
        return $false
    }

    $assignAsts = $ScriptBlock.Ast.FindAll(
        { param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)

    foreach ($a in $assignAsts) {
        $declaredType = $null
        $typeInfo = $null
        $hasAttributes = _HasAttributes $a.Left

        # If assignment target is typed ([int]$x = 1), capture the declared type from the LHS.
        $lhsConvert = $a.Left.Find(
            {
                param($n)
                $n -is [System.Management.Automation.Language.ConvertExpressionAst] -and
                $n.Child -is [System.Management.Automation.Language.VariableExpressionAst]
            },
            $true
        ) | Select-Object -First 1

        if ($lhsConvert -and $lhsConvert.Type -and $lhsConvert.Type.TypeName) {
            $declaredType = $lhsConvert.Type.TypeName.FullName
            $typeInfo = _GetTypeInfoFromDeclaredType -declaredType $declaredType
        }

        $varAst = $a.Left.Find(
            { param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] }, $true
        ) | Select-Object -First 1
        if (-not $varAst) { continue }

        $info = _GetScopeAndNameFromVariablePath $varAst.VariablePath
        if (-not $info) { continue }
        if ($excludeSet.Contains($info.Name)) { continue }

        $val = _TryResolveValue -name $info.Name -scopeHint $info.ScopeHint
        if ($null -eq $val) {
            # If runtime resolution fails (common when scanning caller scripts from a module),
            # fall back to reading simple constant initializers directly from the AST.
            $val = _TryGetInitializerValueFromAssignment -assignmentAst $a
        }

        $type = $null
        if ($null -ne $val) {
            $type = $val.GetType()
        } elseif ($typeInfo -and $null -ne $typeInfo.Type) {
            # If value is $null but LHS is typed, surface the underlying declared type.
            $type = $typeInfo.Type
        }

        [void]$rows.Add([pscustomobject]@{
                Name = $info.Name
                ScopeHint = $info.ScopeHint
                ProviderPath = $info.ProviderPath
                Source = 'Assignment'
                Operator = $a.Operator.ToString()
                Type = $type
                DeclaredType = if ($typeInfo) { $typeInfo.DeclaredType } else { $null }
                IsNullable = if ($typeInfo) { $typeInfo.IsNullable } else { $null }
                HasAttributes = $hasAttributes
                Value = $val
            })
    }

    # Also capture declaration-only typed variables like: [int]$x (no assignment)
    # We scan ConvertExpressionAst directly to reliably catch both plain and attributed declarations.
    $convertAsts = $ScriptBlock.Ast.FindAll(
        {
            param($n)
            $n -is [System.Management.Automation.Language.ConvertExpressionAst] -and
            $n.Child -is [System.Management.Automation.Language.VariableExpressionAst]
        },
        $true
    )

    foreach ($c in $convertAsts) {
        # Skip casts that are part of assignments (those are already handled as assignments)
        if (_IsInAssignment $c) {
            continue
        }

        $hasAttributes = _HasAttributes $c

        $varExpr = [System.Management.Automation.Language.VariableExpressionAst]$c.Child
        $info = _GetScopeAndNameFromVariablePath $varExpr.VariablePath
        if (-not $info) { continue }
        if ($excludeSet.Contains($info.Name)) { continue }

        $declaredType = $c.Type.TypeName.FullName
        $typeInfo = _GetTypeInfoFromDeclaredType -declaredType $declaredType
        $val = _TryResolveValue -name $info.Name -scopeHint $info.ScopeHint

        [void]$rows.Add([pscustomobject]@{
                Name = $info.Name
                ScopeHint = $info.ScopeHint
                ProviderPath = $info.ProviderPath
                Source = 'Declaration'
                Operator = $null
                Type = $typeInfo.Type
                DeclaredType = $typeInfo.DeclaredType
                IsNullable = $typeInfo.IsNullable
                HasAttributes = $hasAttributes
                Value = $val
            })
    }

    if ($IncludeSetVariable) {
        $cmdAsts = $ScriptBlock.Ast.FindAll(
            { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and -not (_IsInFunction $n) }, $true)

        foreach ($c in $cmdAsts) {
            $cmd = $c.GetCommandName()
            if ($cmd -notin 'Set-Variable', 'New-Variable') { continue }
            $named = @{}

            for ($i = 0; $i -lt $c.CommandElements.Count; $i++) {
                $e = $c.CommandElements[$i]
                if ($e -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue }
                if ($e.ParameterName -notin 'Name', 'Scope') { continue }

                $argAst = $e.Argument
                if (-not $argAst -and ($i + 1) -lt $c.CommandElements.Count) {
                    $next = $c.CommandElements[$i + 1]
                    if ($next -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                        $argAst = $next
                        $i++
                    }
                }

                if ($argAst -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                    $named[$e.ParameterName] = $argAst.Value
                }
            }
            if ($named.ContainsKey('Name')) {
                $name = $named['Name']
                if ($excludeSet.Contains($name)) { continue }
                $scope = if ($named.ContainsKey('Scope') -and -not [string]::IsNullOrWhiteSpace($named['Scope'])) { $named['Scope'] } else { $DefaultScope }
                $provider = "variable:$($scope):$name"
                $val = $null; $type = $null
                if ($ResolveValues) {
                    try {
                        $val = (Get-Item -EA SilentlyContinue $provider).Value
                        if ($null -ne $val) { $type = $val.GetType() }
                    } catch {
                        Write-Warning "Failed to resolve variable '$name' in scope '$scope': $_"
                    }
                }
                [pscustomobject]@{
                    Name = $name
                    ScopeHint = $scope
                    ProviderPath = $provider
                    Source = $cmd
                    Operator = $null
                    Type = $type
                    DeclaredType = $null
                    IsNullable = $null
                    HasAttributes = $false
                    Value = $val
                } | ForEach-Object { [void]$rows.Add($_) }
            }
        }
    }

    # keep last occurrence per (ScopeHint, Name)
    $final = @($rows | Group-Object ScopeHint, Name | ForEach-Object { $_.Group[-1] })

    if ($WithoutAttributesOnly.IsPresent) {
        $final = @($final | Where-Object { -not $_.HasAttributes })
    }
    switch ($OutputStructure) {
        'Dictionary' {
            $dict = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($v in $final) {
                # Preserve declared type/nullable metadata for declaration-only (and typed-null) variables.
                # Typed variables (i.e., DeclaredType present) are wrapped so C# can see Type/IsNullable
                # even when the runtime value is non-null (e.g. [int]$paginationLimit = 20).
                # Untyped variables keep the old behavior (value only).
                $wrap = -not [string]::IsNullOrWhiteSpace($v.DeclaredType)
                if ($wrap) {
                    $meta = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
                    $meta['__kestrunVariable'] = $true
                    $meta['Value'] = $v.Value
                    $meta['Type'] = $v.Type
                    $meta['DeclaredType'] = $v.DeclaredType
                    $meta['IsNullable'] = $v.IsNullable
                    $dict[$v.Name] = $meta
                } else {
                    $dict[$v.Name] = $v.Value
                }
            }
            return $dict
        }
        'StringObjectMap' {
            $dict = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($v in $final) {
                $dict[$v.Name] = $v.Value
            }
            return $dict
        }
        default {
            return $final
        }
    }
}
function Convert-DateTimeOffsetToDateTime {
    param(
        [Parameter()]
        [AllowNull()]
        $InputObject
    )
    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [DateTimeOffset]) {
        # Preserve naive timestamps (those without explicit zone) as 'Unspecified' kind so no offset is appended
        $dto = [DateTimeOffset]$InputObject
        return [DateTime]::SpecifyKind($dto.DateTime, [System.DateTimeKind]::Unspecified)
    }
    if ($InputObject -is [System.Collections.IDictionary]) {
        foreach ($k in @($InputObject.Keys)) { $InputObject[$k] = Convert-DateTimeOffsetToDateTime $InputObject[$k] }
        return $InputObject
    }
    if ($InputObject -is [System.Collections.IList]) {
        for ($j = 0; $j -lt $InputObject.Count; $j++) { $InputObject[$j] = Convert-DateTimeOffsetToDateTime $InputObject[$j] }
        return $InputObject
    }
    return $InputObject
}
# Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml)
# Copyright (c) 2016–2024 Cloudbase Solutions Srl
# Licensed under the Apache License, Version 2.0 (Apache-2.0).
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
# Modifications Copyright (c) 2025 Kestrun Contributors


function Convert-HashtableToDictionary {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Data
    )
    # Preserve original insertion order: PowerShell hashtable preserves insertion order internally
    $ordered = [System.Collections.Specialized.OrderedDictionary]::new()
    foreach ($k in $Data.Keys) {
        $ordered.Add($k, (Convert-PSObjectToGenericObject $Data[$k]))
    }
    return $ordered
}
# Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml)
# Copyright (c) 2016–2024 Cloudbase Solutions Srl
# Licensed under the Apache License, Version 2.0 (Apache-2.0).
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
# Modifications Copyright (c) 2025 Kestrun Contributors


function Convert-ListToGenericList {
    param(
        [Parameter(Mandatory = $false)]
        [array]$Data = @()
    )
    $ret = [System.Collections.Generic.List[object]]::new()
    for ($i = 0; $i -lt $Data.Count; $i++) {
        $ret.Add((Convert-PSObjectToGenericObject $Data[$i]))
    }
    # Return the generic list directly (do NOT wrap in a single-element array) so single-element
    # sequences remain proper YAML sequences and do not collapse into mappings during round-trip.
    return $ret
}
# Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml)
# Copyright (c) 2016–2024 Cloudbase Solutions Srl
# Licensed under the Apache License, Version 2.0 (Apache-2.0).
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
# Modifications Copyright (c) 2025 Kestrun Contributors


function Convert-OrderedHashtableToDictionary {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Specialized.OrderedDictionary] $Data
    )
    foreach ($i in $($data.PSBase.Keys)) {
        $Data[$i] = Convert-PSObjectToGenericObject $Data[$i]
    }
    return $Data
}
# Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml)
# Copyright (c) 2016–2024 Cloudbase Solutions Srl
# Licensed under the Apache License, Version 2.0 (Apache-2.0).
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
# Modifications Copyright (c) 2025 Kestrun Contributors


function Convert-PSObjectToGenericObject {
    param(
        [Parameter(Mandatory = $false)]
        [System.Object]$InputObject
    )

    if ($null -eq $InputObject) {
        return $InputObject
    }

    # Use PowerShell's -is operator for efficient type checking
    if ($InputObject -is [System.Collections.Specialized.OrderedDictionary]) {
        return Convert-OrderedHashtableToDictionary $InputObject
    } elseif ($InputObject -is [System.Collections.IDictionary]) {
        return Convert-HashtableToDictionary $InputObject
    } elseif ($InputObject -is [System.Collections.IList]) {
        return Convert-ListToGenericList $InputObject
    }
    return $InputObject
}