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
    foreach ($verDir in $versionDirs) {
        $assemblies = @()

        Get-ChildItem -Path $verDir.FullName -Filter 'Microsoft.*.dll' | ForEach-Object {
            if ($assemblies -notcontains $_.Name) {
                $assemblies += $_.Name
            }
        }
        $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 ($allFound) {
            $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
        }
        return $false
    }

    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."
}
function Add-KrCodeAnalysisType {
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$ModuleRootPath,
        [Parameter(Mandatory = $true)]
        [string]$Version
    )
    $passVerbose = $PSCmdlet.MyInvocation.BoundParameters['Verbose']
    $codeAnalysisassemblyLoadPath = Join-Path -Path $ModuleRootPath -ChildPath 'lib' -AdditionalChildPath 'Microsoft.CodeAnalysis', $Version
    return(
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.dll') -Verbose:$passVerbose) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.Workspaces.dll') -Verbose:$passVerbose) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.dll') -Verbose:$passVerbose) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.Scripting.dll') -Verbose:$passVerbose) -and
        #Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath "Microsoft.CodeAnalysis.Razor.dll") -Verbose:$passVerbose
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.VisualBasic.dll') -Verbose:$passVerbose) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.VisualBasic.Workspaces.dll') -Verbose:$passVerbose) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.Workspaces.dll') -Verbose:$passVerbose) -and
        (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.Scripting.dll') -Verbose:$passVerbose)
    )
}
function Assert-KrAssemblyLoaded {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$AssemblyPath
    )
    if (-not (Test-Path -Path $AssemblyPath -PathType Leaf)) {
        throw "Assembly not found at path: $AssemblyPath"
    }
    $assemblyName = [System.Reflection.AssemblyName]::GetAssemblyName($AssemblyPath).Name
    $loaded = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $assemblyName }
    if (-not $loaded) {
        if ($Verbose) {
            Write-Verbose "Loading assembly: $AssemblyPath"
        }
        try {
            Add-Type -LiteralPath $AssemblyPath
        } catch {
            Write-Error "Failed to load assembly: $AssemblyPath"
            Write-Error $_
            return $false
        }
    } else {
        if ($Verbose) {
            Write-Verbose "Assembly already loaded: $AssemblyPath"
        }
    }
    return $true
}
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 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.Authentication.IOpenApiAuthenticationOptions]::DefaultSchemeName
    )
    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')]
    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'),

        [switch]$IncludeSetVariable,
        [switch]$ResolveValues,
        [ValidateSet('Local', 'Script', 'Global')]
        [string]$DefaultScope = 'Script'
    )

    # ---------- 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)." }

        # Figure out how far “up” that is compared to the original call stack
        $scopeUp = ($allFrames.IndexOf($frame)) + 1
        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."
    }
    $ast = ($ScriptBlock.Ast).ToString()

    $endstring = $ast.IndexOf("Enable-KrConfiguration", [StringComparison]::OrdinalIgnoreCase)
    if ($endstring -lt 0) {
        throw "The provided scriptblock does not appear to contain 'Enable-KrConfiguration' call."
    }
    $ast = $ast.Substring(0, $endstring).Trim()
    if ($ast.StartsWith('{')) {
        $ast += "`n}"
    }
    $ScriptBlock = [scriptblock]::Create($ast)


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

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

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

        $vp = $varAst.VariablePath
        $name = $vp.UnqualifiedPath

        if (-not $name) { $name = $vp.UserPath }            # ← fallback
        if (-not $name) { continue }
        $name = $name -replace '^[^:]*:', ''
        if ($name.Contains('.') -or $name.Contains('[')) { continue }
        if ($name.StartsWith('{') -and $name.EndsWith('}')) {
            $name = $name.Substring(1, $name.Length - 2)        # ← ${foo} → foo
        }
        $val = Get-Variable -Name $name -Scope $scopeUp -ValueOnly -ErrorAction SilentlyContinue
        $type = if ($null -ne $val) { $val.GetType().FullName } else { $null }

        [pscustomobject]@{
            Name = $name
            ScopeHint = $scope
            ProviderPath = $provider
            Source = 'Assignment'
            Operator = $a.Operator.ToString()
            Type = $type
            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 = @{}
            foreach ($e in $c.CommandElements) {
                if ($e -is [System.Management.Automation.Language.CommandParameterAst] -and $e.ParameterName -in 'Name', 'Scope') {
                    $arg = $e.Argument
                    if ($arg -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                        $named[$e.ParameterName] = $arg.Value
                    }
                }
            }
            if ($named.ContainsKey('Name')) {
                $name = $named['Name']
                $scope = $named['Scope'] ?? $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().FullName }
                    } catch {
                        Write-Warning "Failed to resolve variable '$name' in scope '$scope': $_"
                    }
                }
                [pscustomobject]@{
                    Name = $name
                    ScopeHint = $scope
                    ProviderPath = $provider
                    Source = $cmd
                    Operator = $null
                    Type = $type
                    Value = $val
                } | ForEach-Object { [void]$rows.Add($_) }
            }
        }
    }

    # keep last occurrence per (ScopeHint, Name)
    $rows | Group-Object ScopeHint, Name | ForEach-Object { $_.Group[-1] }
}
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
}