internal/functions/semantic/Invoke-SldgAIRequest.ps1

function Invoke-SldgAIRequest {
    <#
    .SYNOPSIS
        Sends a request to the configured AI provider (OpenAI, Azure OpenAI, or Ollama).
    .DESCRIPTION
        Includes retry with exponential backoff, configurable timeout, and rate limiting.
        When -Purpose is specified, checks for per-purpose model overrides first.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$SystemPrompt,

        [Parameter(Mandatory)]
        [string]$UserMessage,

        [string]$Purpose
    )

    # Resolve provider/model — per-purpose override takes priority over global config
    $override = $null
    if ($Purpose -and $script:SldgState.AIModelOverrides.ContainsKey($Purpose)) {
        $override = $script:SldgState.AIModelOverrides[$Purpose]
        Write-PSFMessage -Level Verbose -Message ($script:strings.'AI.ModelOverrideUsing' -f $Purpose, $override['Provider'], $override['Model'])
    }

    $aiProvider = if ($override) { $override['Provider'] } else { Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.Provider' }

    $apiKey = $null
    if ($override -and $override['ApiKey']) {
        $apiKeyRaw = $override['ApiKey']
    }
    else {
        $apiKeyRaw = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.ApiKey'
    }
    try {
        $apiKey = if ($apiKeyRaw -is [securestring]) {
            [System.Net.NetworkCredential]::new('', $apiKeyRaw).Password
        } elseif ($apiKeyRaw) { [string]$apiKeyRaw } else { $null }
    }
    catch {
        Write-PSFMessage -Level Warning -Message ($script:strings.'AI.ApiKeyFailed' -f $_)
        return $null
    }

    $endpoint = if ($override -and $override['Endpoint']) { $override['Endpoint'] } else { Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.Endpoint' }
    $model = if ($override -and $override['Model']) { $override['Model'] } else { Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.Model' }
    $maxTokens = if ($override -and $override['MaxTokens']) { $override['MaxTokens'] } else { Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.MaxTokens' }
    $retryCount = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.RetryCount'
    $retryDelay = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.RetryDelaySeconds'
    $timeoutSec = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.TimeoutSeconds'
    $rateLimit = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.RateLimitPerMinute'

    if ($aiProvider -eq 'None') {
        return $null
    }

    # Ollama does not require an API key
    if ($aiProvider -ne 'Ollama' -and -not $apiKey) {
        return $null
    }

    # Circuit breaker: skip AI calls after consecutive failures to avoid hammering a broken provider.
    # A-2: Per-purpose breaker — a failure in 'batch-generation' must not disable 'column-analysis'.
    $cbThreshold = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.CircuitBreakerThreshold'
    $cbCooldown  = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.CircuitBreakerCooldownSeconds'
    $cbKey       = if ($Purpose) { $Purpose } else { '' }
    $cb          = Get-SldgCircuitBreaker -Purpose $cbKey
    if ($cb.ConsecutiveFailures -ge $cbThreshold) {
        $elapsed = ([datetime]::UtcNow - $cb.OpenedAt).TotalSeconds
        if ($elapsed -lt $cbCooldown) {
            # Circuit is still open for this purpose — skip this call
            return $null
        }
        # Cooldown expired — reset and allow a probe request
        Write-PSFMessage -Level Warning -Message ($script:strings.'AI.CircuitBreakerResetPurpose' -f $cbKey)
        $cb.ConsecutiveFailures = 0
        $cb.OpenedAt = $null
    }

    # Rate limiting: enforce max requests per minute
    # Always clean up stale timestamps to prevent unbounded queue growth
    # Use lock to make check-wait-enqueue atomic across concurrent callers
    $rateLimitLock = $script:SldgState.AIRateLimitLock
    [System.Threading.Monitor]::Enter($rateLimitLock)
    try {
        $now = [datetime]::UtcNow
        $windowStart = $now.AddMinutes(-1)
        $peeked = [datetime]::MinValue
        while ($script:SldgState.AIRequestTimestamps.Count -gt 0 -and $script:SldgState.AIRequestTimestamps.TryPeek([ref]$peeked) -and $peeked -lt $windowStart) {
            $null = $script:SldgState.AIRequestTimestamps.TryDequeue([ref]$peeked)
        }
        if ($rateLimit -and $rateLimit -gt 0) {
            if ($script:SldgState.AIRequestTimestamps.Count -ge $rateLimit) {
                $oldest = [datetime]::MinValue
                $null = $script:SldgState.AIRequestTimestamps.TryPeek([ref]$oldest)
                $waitUntil = $oldest.AddMinutes(1)
                $waitSeconds = [math]::Ceiling(($waitUntil - $now).TotalSeconds)
                if ($waitSeconds -gt 0) {
                    Write-PSFMessage -Level Verbose -Message ($script:strings.'AI.RateLimitWaiting' -f $waitSeconds)
                    # Release lock during sleep so other callers are not blocked
                    [System.Threading.Monitor]::Exit($rateLimitLock)
                    try {
                        Start-Sleep -Seconds $waitSeconds
                    }
                    finally {
                        [System.Threading.Monitor]::Enter($rateLimitLock)
                    }
                }
                # Clean up again after waiting
                $now = [datetime]::UtcNow
                $windowStart = $now.AddMinutes(-1)
                $peeked = [datetime]::MinValue
                while ($script:SldgState.AIRequestTimestamps.Count -gt 0 -and $script:SldgState.AIRequestTimestamps.TryPeek([ref]$peeked) -and $peeked -lt $windowStart) {
                    $null = $script:SldgState.AIRequestTimestamps.TryDequeue([ref]$peeked)
                }
            }
        }
        # Record timestamp before request (outside retry loop — one logical request per invocation)
        $script:SldgState.AIRequestTimestamps.Enqueue([datetime]::UtcNow)
    }
    finally {
        [System.Threading.Monitor]::Exit($rateLimitLock)
    }

    # Periodic cache maintenance: trim caches only when they exceed max entries
    $maxCacheEntries = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.Cache.MaxEntries'
    $cacheTTL = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.Cache.TTLMinutes'
    $sep = $script:CacheKeySeparator
    foreach ($cacheName in @('AIValueCache', 'AILocaleCache', 'AILocaleCategoryCache')) {
        $cache = $script:SldgState.$cacheName
        if ($cache.Count -gt $maxCacheEntries) {
            # Evict entries older than TTL; if still over limit, remove oldest half
            $cutoff = [datetime]::UtcNow.AddMinutes(-$cacheTTL)
            $timestamps = $script:SldgState.CacheTimestamps
            $expiredKeys = @($cache.Keys | Where-Object { $timestamps.ContainsKey("${cacheName}${sep}$_") -and $timestamps["${cacheName}${sep}$_"] -lt $cutoff })
            foreach ($key in $expiredKeys) {
                $cache.Remove($key)
                $timestamps.Remove("${cacheName}${sep}$key")
            }
            # If still over limit after TTL eviction, remove oldest half by timestamp
            if ($cache.Count -gt $maxCacheEntries) {
                $toRemove = @($cache.Keys | Sort-Object { if ($timestamps.ContainsKey("${cacheName}${sep}$_")) { $timestamps["${cacheName}${sep}$_"] } else { [datetime]::MinValue } } | Select-Object -First ([math]::Floor($cache.Count / 2)))
                foreach ($key in $toRemove) { $cache.Remove($key); $timestamps.Remove("${cacheName}${sep}$key") }
            }
        }
    }

    $body = @{
        model    = $model
        messages = @(
            @{ role = 'system'; content = $SystemPrompt }
            @{ role = 'user'; content = $UserMessage }
        )
    }

    # Token limit parameter: newer OpenAI models require 'max_completion_tokens',
    # older models use 'max_tokens'. We start with 'max_completion_tokens' and
    # auto-fallback to 'max_tokens' if the API returns 'unsupported_parameter'.
    $tokenParamName = $null
    $tokenParamSwitched = $false
    if ($aiProvider -in @('OpenAI', 'AzureOpenAI')) {
        $tokenParamName = 'max_completion_tokens'
        $body[$tokenParamName] = $maxTokens
    }

    # Ollama supports additional options
    if ($aiProvider -eq 'Ollama') {
        $temperature = if ($override -and $null -ne $override['Temperature']) { $override['Temperature'] } else { Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.Ollama.Temperature' }
        if ($temperature) {
            $body['options'] = @{ temperature = $temperature }
        }
        $body['stream'] = $false
    }

    $bodyJson = $body | ConvertTo-Json -Depth 10

    $headers = @{ 'Content-Type' = 'application/json' }

    $uri = switch ($aiProvider) {
        'OpenAI' {
            $headers['Authorization'] = "Bearer $apiKey"
            'https://api.openai.com/v1/chat/completions'
        }
        'AzureOpenAI' {
            $headers['api-key'] = $apiKey
            $azureApiVersion = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.AzureApiVersion'
            "$endpoint/openai/deployments/$model/chat/completions?api-version=$azureApiVersion"
        }
        'Ollama' {
            $ollamaEndpoint = if ($endpoint) { $endpoint.TrimEnd('/') } else { 'http://localhost:11434' }
            "$ollamaEndpoint/api/chat"
        }
        default {
            Write-PSFMessage -Level Warning -Message ($script:strings.'AI.UnknownProvider' -f $aiProvider)
            return $null
        }
    }

    # Clear plaintext API key from variable — already embedded in headers
    # Note: .NET strings are immutable; we can only remove the reference, not scrub memory.
    if ($apiKey) {
        Remove-Variable -Name apiKey -ErrorAction SilentlyContinue
    }

    $params = @{
        Uri         = $uri
        Method      = 'Post'
        Headers     = $headers
        Body        = $bodyJson
        ErrorAction = 'Stop'
    }

    # Per-request timeout
    if ($timeoutSec -gt 0) {
        $params['TimeoutSec'] = $timeoutSec
    }

    # Ollama may use self-signed certs in dev — scoped to Ollama only.
    # S-3: Removed the ambient environment-variable gate (any child process could set it).
    # Skipping TLS validation now requires an explicit, audited opt-in via Set-SldgAIProvider -SkipCertificateCheck,
    # which only flips the PSFConfig for the current session (not persisted by default).
    if ($aiProvider -eq 'Ollama') {
        $skipCertCheck = Get-PSFConfigValue -FullName 'SqlLabDataGenerator.AI.Ollama.SkipCertificateCheck'
        if ($skipCertCheck) {
            # Only honour the skip when the endpoint resolves to a loopback address.
            # Self-signed certs are a development convenience and must not weaken production traffic.
            $endpointHost = $null
            try {
                $uriObj = [System.Uri]::new($uri)
                $endpointHost = $uriObj.Host
            } catch { $endpointHost = $null }
            $isLoopback = $endpointHost -and ($endpointHost -in @('localhost', '127.0.0.1', '::1') -or `
                ($endpointHost -as [System.Net.IPAddress] -and ([System.Net.IPAddress]$endpointHost).IsIPv4MappedToIPv6 -eq $false -and ([System.Net.IPAddress]::IsLoopback([System.Net.IPAddress]$endpointHost))))
            if ($isLoopback) {
                Write-PSFMessage -Level Warning -Message $script:strings.'AI.TLSSkipActive'
                $params['SkipCertificateCheck'] = $true
            } else {
                Write-PSFMessage -Level Warning -Message $script:strings.'AI.TLSSkipBlocked'
            }
        }
    }

    # Retry loop with exponential backoff
    $lastError = $null
    try {
        for ($attempt = 1; $attempt -le ($retryCount + 1); $attempt++) {
            try {
                $response = Invoke-RestMethod @params

                # Ollama /api/chat returns message directly, OpenAI-compatible returns choices array
                if ($response.message) {
                    $cb.ConsecutiveFailures = 0
                    return $response.message.content
                }
                elseif ($response.choices) {
                    $cb.ConsecutiveFailures = 0
                    return $response.choices[0].message.content
                }
                else {
                    Write-PSFMessage -Level Warning -Message ($script:strings.'AI.UnexpectedResponse' -f $aiProvider)
                    return $null
                }
            }
            catch {
                $lastError = $_

                # Determine HTTP status code for intelligent retry decisions
                $statusCode = 0
                if ($_.Exception.Response) {
                    try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $null = $_ }
                }

                # Auto-fallback: if API rejects the token-limit parameter, switch and retry immediately (once only)
                if ($statusCode -eq 400 -and $tokenParamName -and -not $tokenParamSwitched -and $_.ErrorDetails.Message -match 'unsupported_parameter') {
                    $tokenParamSwitched = $true
                    $oldParam = $tokenParamName
                    if ($tokenParamName -eq 'max_completion_tokens') {
                        $tokenParamName = 'max_tokens'
                    } else {
                        $tokenParamName = 'max_completion_tokens'
                    }
                    $body.Remove($oldParam)
                    $body[$tokenParamName] = $maxTokens
                    $bodyJson = $body | ConvertTo-Json -Depth 10
                    $params['Body'] = $bodyJson
                    Write-PSFMessage -Level Verbose -Message "Switching token parameter from '$oldParam' to '$tokenParamName' and retrying."
                    $attempt-- # do not consume a retry attempt for parameter negotiation
                    continue
                }

                # Do not retry on authentication/authorization failures
                if ($statusCode -in @(401, 403)) {
                    Write-PSFMessage -Level Warning -Message ($script:strings.'AI.RequestFailed' -f $_)
                    return $null
                }

                if ($attempt -le $retryCount) {
                    $delay = $retryDelay * [math]::Pow(2, $attempt - 1)

                    # Honor Retry-After header from rate-limited responses (429)
                    if ($statusCode -eq 429 -and $_.Exception.Response.Headers) {
                        try {
                            $retryAfter = $_.Exception.Response.Headers | Where-Object { $_.Key -eq 'Retry-After' } | Select-Object -First 1 -ExpandProperty Value
                            if ($retryAfter) {
                                $retryAfterSec = 0
                                if ([int]::TryParse(($retryAfter | Select-Object -First 1), [ref]$retryAfterSec) -and $retryAfterSec -gt $delay) {
                                    $delay = $retryAfterSec
                                }
                            }
                        } catch { $null = $_ }
                    }

                    Write-PSFMessage -Level Warning -Message ($script:strings.'AI.RetryAttempt' -f $attempt, $retryCount, $delay, $_)
                    Start-Sleep -Seconds $delay
                }
            }
        }

        Write-PSFMessage -Level Warning -Message ($script:strings.'AI.RequestFailed' -f $lastError)

        # Track failure for the per-purpose circuit breaker
        $cb.ConsecutiveFailures++
        if ($cb.ConsecutiveFailures -ge $cbThreshold) {
            $cb.OpenedAt = [datetime]::UtcNow
            Write-PSFMessage -Level Warning -Message ($script:strings.'AI.CircuitBreakerOpenPurpose' -f $cbKey, $cbThreshold, $cbCooldown)
        }
        $null
    }
    finally {
        # Remove sensitive API key references from headers
        foreach ($headerKey in @('Authorization', 'api-key')) {
            if ($headers.ContainsKey($headerKey)) {
                $headers.Remove($headerKey)
            }
        }
        $headers.Clear()
    }
}