Private/SwaggerSchema.ps1

# Schema and operation helpers for New-FunctionsFromSwagger. They read the generator state in
# $script:SwaggerGeneratorState (set by New-FunctionsFromSwagger for the duration of one run).

function Resolve-ComponentSchema {
    param([object]$Schema)
    if (-not $Schema) { return $null }

    # Only cache $ref-based schema resolutions to avoid hash code collisions
    # Non-ref schemas are passed through directly without caching
    if (-not ($Schema.PSObject.Properties.Name -contains '$ref')) {
        return $Schema
    }

    $cacheKey = $Schema.'$ref'

    # Check cache first
    if ($script:SwaggerGeneratorState.SchemaCache.ContainsKey($cacheKey)) {
        return $script:SwaggerGeneratorState.SchemaCache[$cacheKey]
    }

    # Resolve the schema
    $refName = $Schema.'$ref' -replace '^#/components/schemas/', ''
    $resolved = if ($script:SwaggerGeneratorState.SchemaTable.ContainsKey($refName)) {
        Write-Verbose " Resolving $refName from schemaTable"
        $fromTable = $script:SwaggerGeneratorState.SchemaTable[$refName]
        Write-Verbose " From table - type: $($fromTable.type), has properties: $($null -ne $fromTable.properties)"
        $fromTable
    }
    else {
        Write-Verbose " WARNING: $refName not found in schemaTable!"
        $Schema
    }

    Write-Verbose " Resolved result - type: $($resolved.type), has properties: $($null -ne $resolved.properties), PSObject properties: $($resolved.PSObject.Properties.Name -join ', ')"

    # Cache the result
    $script:SwaggerGeneratorState.SchemaCache[$cacheKey] = $resolved
    return $resolved
}


function Get-PSTypeFromSchema {
    param([object]$Schema)

    if (-not $Schema) { return '[object]' }

    $resolved = Resolve-ComponentSchema $Schema
    if ($resolved -ne $Schema) { return Get-PSTypeFromSchema $resolved }

    if ($resolved.type) {
        switch ($resolved.type) {
            'integer' { if ($resolved.format -eq 'int64') { return '[long]' } else { return '[int]' } }
            'number' { return '[double]' }
            'boolean' { return '[bool]' }
            'string' { return '[string]' }
            'array' {
                if ($resolved.items) {
                    $innerType = Get-PSTypeFromSchema $resolved.items
                    switch ($innerType) {
                        '[string]' { return '[string[]]' }
                        '[int]' { return '[int[]]' }
                        '[long]' { return '[long[]]' }
                        '[double]' { return '[double[]]' }
                        '[bool]' { return '[bool[]]' }
                        '[hashtable]' { return '[hashtable[]]' }
                        default { return '[object[]]' }
                    }
                }
                return '[object[]]'
            }
            'object' { return '[hashtable]' }
        }
    }

    return '[object]'
}


function Get-NormalizedSchema {
    param([object]$Schema)

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

    $resolved = Resolve-ComponentSchema $Schema
    $nullable = $false
    if ($resolved.PSObject.Properties.Name -contains 'nullable' -and $resolved.nullable) { $nullable = [bool]$resolved.nullable }
    # Repair swagger inconsistencies: if a property has default:null but lacks nullable:true, treat it as nullable.
    # This aligns conversion schema nullability inference with body builder's AllowsNull logic (lines ~1800+) and
    # prevents converter throws when default:null is present. OpenAPI 3 spec technically requires nullable:true for null defaults;
    # so this heuristic only activates when swagger metadata is inconsistent.
    if (-not $nullable -and $resolved.PSObject.Properties.Name -contains 'default' -and $null -eq $resolved.default) { $nullable = $true }

    if ($resolved.PSObject.Properties.Name -contains 'allOf' -and $resolved.allOf) {
        $merged = [ordered]@{ type = 'object'; nullable = $nullable; properties = [ordered]@{}; required = @() }
        foreach ($component in $resolved.allOf) {
            $child = Get-NormalizedSchema $component
            if (-not $child) { continue }

            if ($child.type -eq 'object') {
                if ($child.PSObject.Properties.Name -contains 'properties' -and $child.properties) {
                    foreach ($prop in $child.properties.GetEnumerator()) { $merged.properties[$prop.Key] = $prop.Value }
                }
                if ($child.PSObject.Properties.Name -contains 'required' -and $child.required) {
                    $merged.required += $child.required
                }
                if ($child.PSObject.Properties.Name -contains 'additionalProperties') {
                    $merged.additionalProperties = $child.additionalProperties
                }
            }
            elseif ($child.type -eq 'array' -and (-not $merged.Contains('items'))) {
                $merged.items = $child.items
            }
        }
        if ($resolved.PSObject.Properties.Name -contains 'properties' -and $resolved.properties) {
            foreach ($prop in $resolved.properties.PSObject.Properties) {
                $merged.properties[$prop.Name] = Get-NormalizedSchema $prop.Value
            }
        }
        if ($resolved.PSObject.Properties.Name -contains 'required' -and $resolved.required) {
            $merged.required += @($resolved.required)
        }
        if ($resolved.PSObject.Properties.Name -contains 'additionalProperties') {
            $merged.additionalProperties = $resolved.additionalProperties
        }
        return $merged
    }

    $typeValue = $resolved.type
    if (-not $typeValue) {
        if ($resolved.PSObject.Properties.Name -contains 'properties' -and $resolved.properties) { $typeValue = 'object' }
        elseif ($resolved.PSObject.Properties.Name -contains 'items' -and $resolved.items) { $typeValue = 'array' }
    }

    switch ($typeValue) {
        'object' {
            $descriptor = [ordered]@{
                type       = 'object'
                nullable   = $nullable
                required   = @()
                properties = [ordered]@{}
            }
            if ($resolved.PSObject.Properties.Name -contains 'required' -and $resolved.required) {
                $descriptor.required = @($resolved.required)
            }
            if ($resolved.PSObject.Properties.Name -contains 'properties' -and $resolved.properties) {
                foreach ($prop in $resolved.properties.PSObject.Properties) {
                    $descriptor.properties[$prop.Name] = Get-NormalizedSchema $prop.Value
                }
            }
            if ($resolved.PSObject.Properties.Name -contains 'additionalProperties') {
                $descriptor.additionalProperties = $resolved.additionalProperties
            }
            return $descriptor
        }
        'array' {
            $descriptor = [ordered]@{
                type     = 'array'
                nullable = $nullable
                items    = $null
            }
            if ($resolved.PSObject.Properties.Name -contains 'items') {
                $descriptor.items = Get-NormalizedSchema $resolved.items
            }
            if ($resolved.PSObject.Properties.Name -contains 'minItems') { $descriptor.minItems = [int]$resolved.minItems }
            if ($resolved.PSObject.Properties.Name -contains 'maxItems') { $descriptor.maxItems = [int]$resolved.maxItems }
            return $descriptor
        }
        default {
            $descriptor = [ordered]@{
                type     = if ($typeValue) { $typeValue } else { 'object' }
                nullable = $nullable
            }
            if ($resolved.PSObject.Properties.Name -contains 'enum' -and $resolved.enum) {
                $descriptor.enum = @($resolved.enum)
            }
            if ($resolved.PSObject.Properties.Name -contains 'format' -and $resolved.format) {
                $descriptor.format = $resolved.format
            }
            return $descriptor
        }
    }
}


function Add-ConversionDefinition {
    param(
        [string]$Key,
        [object]$Schema,
        [string]$Context
    )

    if ([string]::IsNullOrWhiteSpace($Key) -or -not $Schema) { return $null }
    if ($script:SwaggerGeneratorState.ConversionDefinitions.Contains($Key)) { return $Key }

    $descriptor = Get-NormalizedSchema $Schema
    if (-not $descriptor) { return $null }

    $script:SwaggerGeneratorState.ConversionDefinitions[$Key] = [ordered]@{
        Context = $Context
        Schema  = $descriptor
    }
    [void]$script:SwaggerGeneratorState.ConversionKeySet.Add($Key)
    return $Key
}


# Applies -StripPathPrefix / -EnsurePathPrefix to an operation path
function Format-GeneratedPath {
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }

    $normalized = $Path.Trim()
    if (-not $normalized.StartsWith('/')) { $normalized = '/' + $normalized }

    if ($script:SwaggerGeneratorState.StripPrefix) {
        $prefixLength = $script:SwaggerGeneratorState.StripPrefix.Length
        if ($normalized.StartsWith($script:SwaggerGeneratorState.StripPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
            $normalized = $normalized.Substring($prefixLength)
        }
        $normalized = '/' + $normalized.TrimStart('/')
        return $normalized
    }

    if ($script:SwaggerGeneratorState.EnsurePrefix) {
        if (-not $normalized.StartsWith($script:SwaggerGeneratorState.EnsurePrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
            $normalized = $script:SwaggerGeneratorState.EnsurePrefix.TrimEnd('/') + '/' + $normalized.TrimStart('/')
        }
        if (-not $normalized.StartsWith('/')) { $normalized = '/' + $normalized }
        return $normalized
    }

    return $normalized
}


function Get-SchemaDetail {
    param([object]$Schema)

    $resolved = Resolve-ComponentSchema $Schema
    $meta = [ordered]@{
        Schema         = $resolved
        PSType         = Get-PSTypeFromSchema $resolved
        Nullable       = $false
        ReadOnly       = $false
        WriteOnly      = $false
        HasDefault     = $false
        DefaultValue   = $null
        DefaultLiteral = $null
        EnumValues     = @()
        Format         = $null
        Description    = $null
    }

    if ($resolved) {
        if ($resolved.PSObject.Properties.Name -contains 'nullable' -and $resolved.nullable) { $meta.Nullable = [bool]$resolved.nullable }
        if ($resolved.PSObject.Properties.Name -contains 'readOnly' -and $resolved.readOnly) { $meta.ReadOnly = [bool]$resolved.readOnly }
        if ($resolved.PSObject.Properties.Name -contains 'writeOnly' -and $resolved.writeOnly) { $meta.WriteOnly = [bool]$resolved.writeOnly }
        if ($resolved.PSObject.Properties.Name -contains 'default') {
            $meta.HasDefault = $true
            $meta.DefaultValue = $resolved.default

            if ($meta.DefaultValue -is [string]) {
                $jsonCandidate = $meta.DefaultValue.Trim()
                $isArrayType = $meta.PSType -match '\[\]'
                $isHashtableType = $meta.PSType -eq '[hashtable]' -or $meta.PSType -eq '[object]' -or $meta.PSType -eq '[pscustomobject]'
                $isHashtableArrayType = $meta.PSType -eq '[hashtable[]]' -or $meta.PSType -eq '[object[]]'

                if ($jsonCandidate -like '`[*`]') {
                    if ($isArrayType -or $isHashtableArrayType) {
                        try {
                            $parsedArray = ConvertFrom-Json -InputObject $jsonCandidate -ErrorAction Stop
                            if ($null -ne $parsedArray) {
                                $meta.DefaultValue = $parsedArray
                            }
                        }
                        catch {
                            # fall back to original string default
                            Write-Debug "Ignored: $($_.Exception.Message)"
                        }
                    }
                }
                elseif ($jsonCandidate -like '{*}') {
                    if ($isHashtableType) {
                        try {
                            $parsedObject = ConvertFrom-Json -InputObject $jsonCandidate -ErrorAction Stop
                            if ($null -ne $parsedObject) {
                                $meta.DefaultValue = $parsedObject
                            }
                        }
                        catch {
                            # fall back to original string default
                            Write-Debug "Ignored: $($_.Exception.Message)"
                        }
                    }
                }
            }

            $meta.DefaultLiteral = ConvertTo-PowerShellLiteral $meta.DefaultValue
        }
        if ($resolved.PSObject.Properties.Name -contains 'enum' -and $resolved.enum) { $meta.EnumValues = @($resolved.enum) }
        if ($resolved.PSObject.Properties.Name -contains 'format' -and $resolved.format) { $meta.Format = $resolved.format }
        if ($resolved.PSObject.Properties.Name -contains 'description' -and $resolved.description) { $meta.Description = $resolved.description -replace "`r|`n", ' ' }
    }

    return [pscustomobject]$meta
}


function Get-BodyParamDefinition {
    param(
        [object]$Schema,
        [string[]]$PathSegments,
        [hashtable]$DescriptionHints,
        [hashtable]$UsedNames,
        [System.Collections.ArrayList]$ReadOnlyCollector,
        [bool]$ParentIsRequired = $false
    )

    $definitions = [System.Collections.Generic.List[object]]::new()
    if (-not $Schema) { return $definitions }

    $resolved = Resolve-ComponentSchema $Schema
    if (-not $resolved) { return $definitions }

    $compositeSchemas = [System.Collections.Generic.List[object]]::new()
    if ($resolved.PSObject.Properties.Name -contains 'allOf') {
        foreach ($component in $resolved.allOf) { $compositeSchemas.Add($component) }
    }

    if ($compositeSchemas.Count -gt 0) {
        foreach ($component in $compositeSchemas) {
            $childDefs = Get-BodyParamDefinition -Schema $component -PathSegments $PathSegments -DescriptionHints $DescriptionHints -UsedNames $UsedNames -ReadOnlyCollector $ReadOnlyCollector -ParentIsRequired $ParentIsRequired
            foreach ($def in $childDefs) { $definitions.Add($def) }
        }
    }

    $effectiveSchema = $resolved
    if (-not $effectiveSchema.properties) {
        Write-Verbose " No properties found in effectiveSchema for path: $($PathSegments -join '.')"
        Write-Verbose " effectiveSchema type: $($effectiveSchema.GetType().FullName)"
        Write-Verbose " effectiveSchema.PSObject.Properties.Name: $($effectiveSchema.PSObject.Properties.Name -join ', ')"
        return $definitions
    }

    $requiredProps = @()
    if ($effectiveSchema.required) { $requiredProps = @($effectiveSchema.required) }

    foreach ($prop in $effectiveSchema.properties.PSObject.Properties) {
        $origPropName = $prop.Name
        $childPath = $PathSegments + $origPropName

        $propMeta = Get-SchemaDetail $prop.Value
        if ($propMeta.ReadOnly) {
            if ($ReadOnlyCollector) { [void]$ReadOnlyCollector.Add(($childPath -join '.')) }
            continue
        }

        $resolvedChild = Resolve-ComponentSchema $propMeta.Schema
        $hasChildProperties = $false
        if ($resolvedChild) {
            if ($resolvedChild.PSObject.Properties.Name -contains 'properties' -and $resolvedChild.properties) {
                if ($resolvedChild.properties.PSObject.Properties.Count -gt 0) { $hasChildProperties = $true }
            }
            if (-not $hasChildProperties -and $resolvedChild.PSObject.Properties.Name -contains 'allOf') {
                foreach ($component in $resolvedChild.allOf) {
                    $resolvedComponent = Resolve-ComponentSchema $component
                    if ($resolvedComponent -and $resolvedComponent.PSObject.Properties.Name -contains 'properties' -and $resolvedComponent.properties -and $resolvedComponent.properties.PSObject.Properties.Count -gt 0) {
                        $hasChildProperties = $true
                        break
                    }
                }
            }
        }

        $paramName = Get-UniqueBodyParameterName -Segments $childPath -UsedNames $UsedNames
        $psType = $propMeta.PSType
        $isRequired = $requiredProps -contains $origPropName

        $hasEffectiveDefault = $false
        # Treat empty strings, nulls, and all-zeros GUIDs as "no effective default" so they're omitted when not provided
        if ($propMeta.HasDefault -and $propMeta.DefaultLiteral -and
            $propMeta.DefaultLiteral -ne "''" -and
            $propMeta.DefaultLiteral -ne '$null' -and
            $propMeta.DefaultLiteral -ne "'00000000-0000-0000-0000-000000000000'") {
            $hasEffectiveDefault = $true
        }
        $allowsNull = [bool]$propMeta.Nullable
        if (-not $allowsNull -and $propMeta.HasDefault -and $null -eq $propMeta.DefaultValue) { $allowsNull = $true }
        if (-not $allowsNull -and $propMeta.DefaultLiteral -eq '$null') { $allowsNull = $true }

        $description = $propMeta.Description
        if (-not $description) { $description = "Body property ${origPropName}" }
        $description = Format-HelpTextSegment $description
        if (-not $description) { $description = "Body property ${origPropName}" }
        $hint = Get-HintForPath -DescriptionHints $DescriptionHints -PathSegments $childPath -ParameterName $paramName
        if ($hint) { $hint = Format-HelpTextSegment $hint }

        $definitions.Add([pscustomobject]@{
                Name                = $paramName
                PathSegments        = $childPath
                OrigName            = $origPropName
                Type                = $psType
                Required            = $isRequired
                ParentRequired      = $ParentIsRequired
                HasDefault          = $propMeta.HasDefault
                HasEffectiveDefault = $hasEffectiveDefault
                DefaultLiteral      = $propMeta.DefaultLiteral
                DefaultValue        = $propMeta.DefaultValue
                AllowsNull          = $allowsNull
                Enum                = $propMeta.EnumValues
                Format              = $propMeta.Format
                Nullable            = $propMeta.Nullable
                WriteOnly           = $propMeta.WriteOnly
                Hint                = $hint
                HelpSummary         = "(body: $($childPath -join '.')) - $description"
                Description         = $description
                HasChildProperties  = $hasChildProperties
            })

        if ($resolvedChild -and $resolvedChild.type -eq 'object' -and $resolvedChild.properties) {
            $childDefs = Get-BodyParamDefinition -Schema $propMeta.Schema -PathSegments $childPath -DescriptionHints $DescriptionHints -UsedNames $UsedNames -ReadOnlyCollector $ReadOnlyCollector -ParentIsRequired ($isRequired -or $ParentIsRequired)
            foreach ($def in $childDefs) { $definitions.Add($def) }
        }
    }

    return $definitions
}


function Get-SwaggerParamValidation {
    param([string]$operationKey)
    return $script:SwaggerGeneratorState.Validation[$operationKey]
}


function Get-SwaggerParamLabelToId {
    param([string]$operationKey)
    return $script:SwaggerGeneratorState.LabelToId[$operationKey]
}


function Get-OperationKey {
    param([string]$Path, [string]$Method)
    $normalizedPath = Format-GeneratedPath -Path $Path
    return (($normalizedPath.TrimStart('/')) -replace '[^A-Za-z0-9]', '_') + '_' + $Method.ToLowerInvariant()
}


# Fills the Validation and LabelToId maps from parameter enums and "name": "label=id|..." hints in operation descriptions
function Initialize-SwaggerParamMapping {
    # 1) explicit enums from components/schemas
    if ($script:SwaggerGeneratorState.Swagger.components -and $script:SwaggerGeneratorState.Swagger.components.schemas) {
        foreach ($schemaName in $script:SwaggerGeneratorState.Swagger.components.schemas.PSObject.Properties.Name) {
            $schema = $script:SwaggerGeneratorState.Swagger.components.schemas.$schemaName
            if ($schema.properties) {
                foreach ($propName in $schema.properties.PSObject.Properties.Name) {
                    $prop = $schema.properties.$propName
                    if ($prop.enum) {
                        $key = "schema::$schemaName"
                        if (-not $script:SwaggerGeneratorState.Validation.ContainsKey($key)) { $script:SwaggerGeneratorState.Validation[$key] = @{} }
                        $script:SwaggerGeneratorState.Validation[$key][$propName] = @($prop.enum)
                    }
                }
            }
        }
    }

    # 2) operations: parameter enums and parameter hints in the description
    foreach ($path in $script:SwaggerGeneratorState.Swagger.paths.PSObject.Properties.Name) {
        $pathObj = $script:SwaggerGeneratorState.Swagger.paths.$path
        foreach ($methodProp in $pathObj.PSObject.Properties) {
            if ($script:SwaggerGeneratorState.HttpMethodNames -notcontains $methodProp.Name.ToLowerInvariant()) { continue }
            $op = $methodProp.Value
            if ($null -eq $op) { continue }
            $opKey = Get-OperationKey -Path $path -Method $methodProp.Name
            $localMap = @{}

            # a) explicit parameter schemas with enum
            if ($op.parameters) {
                foreach ($p in $op.parameters) {
                    if ($p.schema -and $p.schema.enum) {
                        $localMap[[string]$p.name] = @($p.schema.enum)
                    }
                }
            }

            # b) description with a JSON-like block: attempt to extract quoted key:value pairs
            if ($op.description) {
                $desc = [string]$op.description
                $start = $desc.IndexOf('{')
                if ($start -ge 0) {
                    $searchStart = [Math]::Min($start + 1, [Math]::Max(0, $desc.Length - 1))
                    $end = $desc.IndexOf('}', $searchStart)
                }
                else {
                    $end = -1
                }
                if ($start -ge 0 -and $end -gt $start) {
                    $block = $desc.Substring($start, $end - $start + 1)
                    $regex = '"([^\"]+)"\s*:\s*"([^\"]+)"'
                    foreach ($pairMatch in [regex]::Matches($block, $regex)) {
                        $k = $pairMatch.Groups[1].Value
                        $v = $pairMatch.Groups[2].Value
                        $parts = $v -split '\|'
                        if ($parts.Count -eq 1) { $parts = $v -split ',' }
                        $vals = [System.Collections.Generic.List[string]]::new()
                        $idMap = [ordered]@{}
                        foreach ($part in $parts) {
                            $s = $part.Trim()
                            if ($s -match '^(.*?)\s*=\s*([0-9]+)\s*$') {
                                $label = ($matches[1]).Trim()
                                $vals.Add($label)
                                $idMap[$label] = [int]$matches[2]
                            }
                            elseif ($s -match '^(.*?)\s*=\s*([0-9a-fA-F\-]{1,})\s*$') {
                                $label = ($matches[1]).Trim()
                                $vals.Add($label)
                                $idMap[$label] = $matches[2]
                            }
                            else {
                                $vals.Add($s)
                            }
                        }
                        if ($vals.Count -gt 0) {
                            $localMap[$k] = @($vals)
                            if ($idMap.Keys.Count -gt 0) {
                                if (-not $script:SwaggerGeneratorState.LabelToId.ContainsKey($opKey)) { $script:SwaggerGeneratorState.LabelToId[$opKey] = @{} }
                                $script:SwaggerGeneratorState.LabelToId[$opKey][$k] = $idMap
                            }
                        }
                    }
                }
            }

            if ($localMap.Keys.Count -gt 0) {
                $script:SwaggerGeneratorState.Validation[$opKey] = $localMap
            }
        }
    }
}