Private/Invoke-FloRecruitRequest.ps1

function Invoke-FloRecruitRequest {
    <#
    .SYNOPSIS
        Central HTTP request handler for FloRecruit API calls.

    .DESCRIPTION
        Handles all HTTP requests to the FloRecruit API including session verification,
        automatic token refresh, rate limiting, error handling, and response parsing.

    .PARAMETER Endpoint
        The API endpoint to call (e.g., '/v1/job-applications').

    .PARAMETER Method
        The HTTP method to use (default: GET).

    .PARAMETER Body
        The request body as a hashtable (will be converted to JSON).

    .PARAMETER QueryParameters
        Query parameters to append to the URL.

    .PARAMETER ReturnHeaders
        Return both data and headers for pagination handling.

    .PARAMETER RawResponse
        Return the raw response object instead of parsed JSON.
    #>

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

        [Parameter()]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method = 'GET',

        [Parameter()]
        [hashtable]$Body,

        [Parameter()]
        [hashtable]$QueryParameters,

        [Parameter()]
        [switch]$ReturnHeaders,

        [Parameter()]
        [switch]$RawResponse
    )

    # Verify session exists
    if (-not $Script:FloRecruitSession) {
        throw "Not connected to FloRecruit. Use Connect-FloRecruit first."
    }

    # Check token expiry and refresh if needed
    if ($Script:FloRecruitSession.TokenExpiry -lt (Get-Date)) {
        Write-Verbose "Token expired. Refreshing authentication..."
        Update-FloRecruitToken
    }

    # Rate limiting check
    $windowElapsed = ((Get-Date) - $Script:FloRecruitSession.RateLimitWindowStart).TotalSeconds
    if ($windowElapsed -ge $Script:RateLimitWindowSeconds) {
        # New window - reset counter
        $Script:FloRecruitSession.RequestCount = 0
        $Script:FloRecruitSession.RateLimitWindowStart = Get-Date
        Write-Verbose "Rate limit window reset"
    }

    # Check if approaching limit
    if ($Script:FloRecruitSession.RequestCount -ge ($Script:RateLimitMaxRequests - 10)) {
        Write-Warning "Approaching rate limit ($($Script:FloRecruitSession.RequestCount)/$Script:RateLimitMaxRequests requests in current window)"
        Start-Sleep -Milliseconds 500
    }

    # Check if at limit
    if ($Script:FloRecruitSession.RequestCount -ge $Script:RateLimitMaxRequests) {
        $sleepSeconds = [math]::Ceiling($Script:RateLimitWindowSeconds - $windowElapsed + 5)
        Write-Warning "Rate limit reached ($Script:RateLimitMaxRequests requests). Waiting $sleepSeconds seconds for new window..."
        Start-Sleep -Seconds $sleepSeconds
        $Script:FloRecruitSession.RequestCount = 0
        $Script:FloRecruitSession.RateLimitWindowStart = Get-Date
    }

    # Build URL
    $baseUrl = $Script:FloRecruitSession.BaseUrl
    $url = "$baseUrl$Endpoint"

    # Add query parameters
    if ($QueryParameters -and $QueryParameters.Count -gt 0) {
        $queryString = ($QueryParameters.GetEnumerator() | ForEach-Object {
            "$([System.Web.HttpUtility]::UrlEncode($_.Key))=$([System.Web.HttpUtility]::UrlEncode($_.Value))"
        }) -join '&'
        $url = "$url`?$queryString"
    }

    # Build headers
    $headers = @{
        'Accept' = 'application/json'
        'Cookie' = $Script:FloRecruitSession.AuthToken
    }

    # Build request parameters
    $requestParams = @{
        Uri     = $url
        Method  = $Method
        Headers = $headers
    }

    # Add body if provided
    if ($Body) {
        $requestParams['Body'] = ($Body | ConvertTo-Json -Depth 10)
        $requestParams['ContentType'] = 'application/json; charset=utf-8'
    }

    try {
        Write-Verbose "$Method $url"

        # Make the request
        $response = Invoke-WebRequest @requestParams -ErrorAction Stop

        # Increment request counter
        $Script:FloRecruitSession.RequestCount++

        # Return raw response if requested
        if ($RawResponse) {
            return $response
        }

        # Parse JSON response
        $data = $null
        if ($response.Content) {
            try {
                $data = $response.Content | ConvertFrom-Json
            }
            catch {
                Write-Warning "Failed to parse JSON response: $_"
                $data = $response.Content
            }
        }

        # Return with headers if requested
        if ($ReturnHeaders) {
            return [PSCustomObject]@{
                Data    = $data
                Headers = $response.Headers
            }
        }

        return $data
    }
    catch {
        # Handle HTTP errors
        $statusCode = $null
        $errorDetails = $null

        if ($_.Exception.Response) {
            $statusCode = [int]$_.Exception.Response.StatusCode

            # Try to read error response body
            try {
                $streamReader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
                $errorDetails = $streamReader.ReadToEnd()
                $streamReader.Close()
            }
            catch {
                $errorDetails = $_.Exception.Message
            }
        }

        # Convert to standardized error
        $errorRecord = ConvertTo-FloRecruitError -Exception $_.Exception -ErrorDetails $errorDetails -StatusCode $statusCode

        # Special handling for 429 (rate limit)
        if ($statusCode -eq 429) {
            Write-Warning "Rate limit exceeded (HTTP 429). Consider reducing request frequency."

            # Check for Retry-After header
            if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                $retryAfter = $_.Exception.Response.Headers['Retry-After']
                Write-Warning "API requests Retry-After: $retryAfter seconds"
            }
        }

        # Special handling for 401 (unauthorized)
        if ($statusCode -eq 401) {
            Write-Warning "Authentication failed (HTTP 401). Token may be invalid. Try reconnecting with Connect-FloRecruit."
        }

        throw $errorRecord
    }
}

function Update-FloRecruitToken {
    <#
    .SYNOPSIS
        Re-authenticates using stored credentials to refresh the session token.

    .DESCRIPTION
        Called automatically by Invoke-FloRecruitRequest when the token has expired.
        Uses stored credentials from the session to re-authenticate.
    #>

    [CmdletBinding()]
    param()

    if (-not $Script:FloRecruitSession) {
        throw "No session to refresh"
    }

    Write-Verbose "Re-authenticating to refresh token..."

    try {
        # Build auth URL
        $authUrl = $Script:FloRecruitSession.AuthEndpoint

        # Convert SecureString password back to plain text for authentication
        $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Script:FloRecruitSession.Password)
        $plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
        [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)

        # Build auth body
        $authBody = @{
            email    = $Script:FloRecruitSession.Email
            password = $plainPassword
        }

        # Add MFA if present
        if ($Script:FloRecruitSession.MFASecret) {
            $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Script:FloRecruitSession.MFASecret)
            $plainMFA = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
            $authBody['mfa_secret'] = $plainMFA
        }

        # Make auth request
        $response = Invoke-WebRequest -Uri $authUrl -Method POST -Body ($authBody | ConvertTo-Json) -ContentType 'application/json' -SessionVariable webSession

        # Extract token from cookie
        $authCookie = $null
        $cookieString = $null

        # Try to get cookie from web session first
        if ($webSession -and $webSession.Cookies) {
            $authCookie = $webSession.Cookies.GetCookies($authUrl) | Where-Object { $_.Name -match 'auth|token|session' } | Select-Object -First 1
        }

        if ($authCookie) {
            $cookieString = "$($authCookie.Name)=$($authCookie.Value)"
        }
        else {
            # Fallback: Try to extract from Set-Cookie header
            if ($response.Headers['Set-Cookie']) {
                $setCookieHeader = $response.Headers['Set-Cookie']
                if ($setCookieHeader -match '([^=]+)=([^;]+)') {
                    $cookieName = $Matches[1]
                    $cookieValue = $Matches[2]
                    $cookieString = "$cookieName=$cookieValue"
                }
            }
        }

        if ($cookieString) {
            $Script:FloRecruitSession.AuthToken = $cookieString
            $Script:FloRecruitSession.TokenExpiry = (Get-Date).AddMinutes(30)
            Write-Verbose "Token refreshed successfully"
        }
        else {
            throw "Failed to extract authentication token from response"
        }
    }
    catch {
        throw "Failed to refresh authentication token: $_"
    }
    finally {
        # Clear sensitive data
        if ($plainPassword) { Clear-Variable -Name plainPassword -ErrorAction SilentlyContinue }
        if ($plainMFA) { Clear-Variable -Name plainMFA -ErrorAction SilentlyContinue }
    }
}