src/core/Helpers.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Common helper utilities for IPSC Kurs Watcher.
#>


function ConvertTo-SafeJson {
    <#
    .SYNOPSIS
    Safely converts an object to JSON string, returning null on error.

    .DESCRIPTION
    Wraps ConvertTo-Json with error handling. Returns $null if conversion fails
    instead of throwing, allowing pipelines to continue.

    .PARAMETER InputObject
    Object to convert to JSON. Accepts pipeline input.

    .PARAMETER Depth
    JSON nesting depth. Defaults to 10.

    .OUTPUTS
    String (JSON) or $null on error.

    .EXAMPLE
    $obj = @{ id = 1; name = 'Course' }
    $obj | ConvertTo-SafeJson
    # Returns: {"id": 1, "name": "Course"}

    .NOTES
    Errors are logged but do not halt execution.
    #>

    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)][ValidateNotNull()][object]$InputObject, [ValidateRange(1, 100)][int]$Depth = 10)
    process {
        try { $InputObject | ConvertTo-Json -Depth $Depth -ErrorAction Stop }
        catch { Write-Error "JSON conversion failed: $_"; $null }
    }
}

function ConvertFrom-SafeJson {
    <#
    .SYNOPSIS
    Safely parses JSON string to object, returning null on error.

    .DESCRIPTION
    Wraps ConvertFrom-Json with error handling. Returns $null if parsing fails
    instead of throwing, allowing pipelines to continue gracefully.

    .PARAMETER JsonString
    JSON string to parse. Accepts pipeline input.

    .OUTPUTS
    PSCustomObject or $null on error.

    .EXAMPLE
    '{"id": 1, "name": "Course"}' | ConvertFrom-SafeJson
    # Returns: PSCustomObject with id and name properties

    .NOTES
    Errors are logged but do not halt execution.
    #>

    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)][ValidateNotNullOrEmpty()][string]$JsonString)
    process {
        try { $JsonString | ConvertFrom-Json -ErrorAction Stop }
        catch { Write-Error "JSON parse error: $_"; $null }
    }
}

function Test-FilePath {
    <#
    .SYNOPSIS
    Tests whether a path exists and is a file (not a directory).

    .DESCRIPTION
    Returns $true only if the path exists and points to a file.
    Returns $false if the path doesn't exist or is a directory.

    .PARAMETER Path
    File path to test.

    .OUTPUTS
    Boolean - $true if file exists, $false otherwise.

    .EXAMPLE
    Test-FilePath -Path 'config/config.json'
    # Returns: $true if file exists

    .NOTES
    Directories return $false; only regular files return $true.
    #>

    [CmdletBinding()]
    param([ValidateNotNullOrEmpty()][string]$Path)
    if (-not (Test-Path $Path)) { return $false }
    if (!(Get-Item $Path).PSIsContainer) { return $true }
    return $false
}

function Test-ValidUrl {
    <#
    .SYNOPSIS
    Validates URL format and scheme for security.

    .DESCRIPTION
    Validates that a URL is well-formed and uses only safe schemes (http/https).
    Prevents URL injection attacks by validating format before use in web requests.

    .PARAMETER Url
    URL string to validate.

    .OUTPUTS
    Boolean - $true if URL is valid, $false otherwise.

    .EXAMPLE
    Test-ValidUrl -Url 'https://www.example.com/path'
    # Returns: $true

    .EXAMPLE
    Test-ValidUrl -Url 'ftp://example.com'
    # Returns: $false (unsupported scheme)

    .EXAMPLE
    Test-ValidUrl -Url 'not a url'
    # Returns: $false (invalid format)

    .NOTES
    Only http and https schemes are allowed. Relative URLs return $false.
    #>

    [CmdletBinding()]
    param([ValidateNotNullOrEmpty()][string]$Url)

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

    try {
        $uri = [System.Uri]::new($Url)

        if (-not $uri.IsAbsoluteUri) {
            return $false
        }

        if ($uri.Scheme -notin @('http', 'https')) {
            return $false
        }

        return $true
    }
    catch {
        return $false
    }
}

function Get-FileDirectory {
    <#
    .SYNOPSIS
    Ensures a directory exists, creating it if necessary.

    .DESCRIPTION
    Tests if the directory exists. If not, creates it.
    Returns the directory path on success, $null on failure.

    .PARAMETER Path
    Directory path to verify or create.

    .OUTPUTS
    String (the path) on success, $null on error.

    .EXAMPLE
    Get-FileDirectory -Path 'data/logs'
    # Creates directory if missing, returns 'data/logs'

    .NOTES
    Errors are logged but do not throw.
    #>

    [CmdletBinding()]
    param([ValidateNotNullOrEmpty()][string]$Path)
    if (-not (Test-Path $Path)) { try { New-Item -ItemType Directory -Path $Path -Force | Out-Null } catch { Write-Error "Failed to create directory $Path : $_"; return $null } }
    return $Path
}

function Invoke-WithRetry {
    <#
    .SYNOPSIS
    Executes a script block with exponential backoff retry logic.

    .DESCRIPTION
    Attempts to run the script block up to MaxAttempts times.
    On failure, waits with exponential backoff (2^(attempt-1) * BaseDelaySeconds)
    before retrying. Throws the last error if all attempts fail.

    .PARAMETER ScriptBlock
    Script block to execute.

    .PARAMETER MaxAttempts
    Maximum number of attempts. Defaults to 3.

    .PARAMETER BaseDelaySeconds
    Base delay for exponential backoff. Defaults to 1 second.
    Actual delay = 2^(attempt-1) * BaseDelaySeconds.

    .OUTPUTS
    Return value of the script block if successful.

    .EXAMPLE
    Invoke-WithRetry -ScriptBlock { Invoke-WebRequest 'https://api.example.com' } -MaxAttempts 3

    .NOTES
    Retries: 1s, 2s, 4s for default MaxAttempts=3, BaseDelaySeconds=1.
    Final attempt failure throws the error; no swallowing.
    #>

    [CmdletBinding()]
    param([ValidateNotNull()][scriptblock]$ScriptBlock, [ValidateRange(1, 100)][int]$MaxAttempts = 3, [ValidateRange(0.1, 300)][int]$BaseDelaySeconds = 1)
    $attempt = 0; $lastError = $null
    while ($attempt -lt $MaxAttempts) { try { return & $ScriptBlock } catch { $attempt++; $lastError = $_; if ($attempt -lt $MaxAttempts) { $delay = [Math]::Pow(2, $attempt - 1) * $BaseDelaySeconds; Start-Sleep -Seconds $delay } } }
    throw $lastError
}

function Protect-SensitiveData {
    <#
    .SYNOPSIS
    Masks sensitive information in strings for safe logging.

    .DESCRIPTION
    Replaces passwords, API keys, webhook URLs, and email addresses with ***MASKED***
    or similar placeholders. Used to sanitize log output before display or storage.

    .PARAMETER InputString
    String containing potentially sensitive data.

    .OUTPUTS
    String with sensitive parts masked.

    .EXAMPLE
    'password="secret123"' | Protect-SensitiveData
    # Returns: 'password="***MASKED***"'

    .EXAMPLE
    'user@example.com' | Protect-SensitiveData
    # Returns: '***@***.***'

    .NOTES
    Masks: passwords, api_key, webhook_url, and email addresses.
    Safe for logging and error messages.
    #>

    [CmdletBinding()]
    param([string]$InputString)
    if (-not $InputString) { return $InputString }
    $masked = $InputString
    $masked = $masked -replace '(password[^=]*=")[^"]*"', '$1***MASKED***"'
    $masked = $masked -replace '(api_key=")[^"]*"', '$1***MASKED***"'
    $masked = $masked -replace '(webhook_url=")[^"]*"', '$1***MASKED***"'
    $masked = $masked -replace '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '***@***.***'
    return $masked
}

function Invoke-SecureWebRequest {
    <#
    .SYNOPSIS
    Invoke-WebRequest with logging for security-critical endpoints.

    .DESCRIPTION
    Wrapper around Invoke-WebRequest that logs requests to critical endpoints
    (Azure AD, Microsoft Graph) for audit trail. Uses Windows certificate validation
    via system CA store. Supports all Invoke-WebRequest parameters.

    .PARAMETER Uri
    URI to request. Must be absolute HTTPS URL (validation via Test-ValidUrl).

    .PARAMETER Method
    HTTP method (GET, POST, etc). Defaults to GET.

    .PARAMETER Headers
    Request headers hashtable.

    .PARAMETER Body
    Request body (bytes or string).

    .PARAMETER TimeoutSeconds
    Request timeout in seconds. Defaults to 30.

    .OUTPUTS
    Invoke-WebRequest response object.

    .EXAMPLE
    $response = Invoke-SecureWebRequest -Uri 'https://graph.microsoft.com/v1.0/me' -Method GET

    .NOTES
    Critical endpoints logged: login.microsoftonline.com, graph.microsoft.com
    Falls back to standard Invoke-WebRequest for non-critical endpoints.
    #>

    [CmdletBinding()]
    param(
        [ValidateNotNullOrEmpty()][string]$Uri,
        [ValidateNotNullOrEmpty()][string]$Method = 'GET',
        [hashtable]$Headers,
        [object]$Body,
        [ValidateRange(1, 600)][int]$TimeoutSeconds = 30
    )

    try {
        $uri_obj = [System.Uri]::new($Uri)

        # Log requests to critical endpoints for audit trail
        $criticalEndpoints = @('login.microsoftonline.com', 'graph.microsoft.com')
        if ($uri_obj.Host -in $criticalEndpoints) {
            Write-Log -Level DEBUG -Message "Secure web request" `
                -Context @{ endpoint = $uri_obj.Host; method = $Method; timeout_seconds = $TimeoutSeconds }
        }

        # Build Invoke-WebRequest parameters
        $params = @{
            Uri             = $Uri
            Method          = $Method
            TimeoutSec      = $TimeoutSeconds
            UseBasicParsing = $true
        }

        if ($Headers) { $params.Headers = $Headers }
        if ($Body) { $params.Body = $Body }

        # Execute request (Windows validates certificates via system CA store)
        return Invoke-WebRequest @params
    }
    catch {
        $errorMsg = if ($_.Exception) { $_.Exception.Message } else { $_.ToString() }
        if (-not $errorMsg) { $errorMsg = $_.FullyQualifiedErrorId }
        $httpStatus = if ($_.Exception.Response) { $_.Exception.Response.StatusCode } else { 'Unknown' }
        Write-Log -Level ERROR -Message "Secure web request failed" `
            -Context @{ uri = $Uri; method = $Method; http_status = $httpStatus; error = $errorMsg } -Exception $_
        throw
    }
}

function Protect-OAuthError {
    <#
    .SYNOPSIS
    Masks sensitive information in OAuth2 error messages for safe logging.

    .DESCRIPTION
    Sanitizes OAuth2 error messages by masking client_secret, client_id, tenant_id,
    and email addresses. Prevents accidental exposure of credentials in logs.

    .PARAMETER ErrorMessage
    OAuth2 error message to sanitize.

    .OUTPUTS
    String with sensitive parts masked.

    .EXAMPLE
    $error = "Invalid client_secret: xyz123abc for tenant_id: tenant-guid"
    Protect-OAuthError -ErrorMessage $error
    # Returns: "Invalid client_secret: [REDACTED_SECRET] for tenant_id: [REDACTED_TENANT]"

    .NOTES
    Used in OAuth2 token refresh and authentication error handling.
    #>

    [CmdletBinding()]
    param([string]$ErrorMessage)

    if (-not $ErrorMessage) { return $ErrorMessage }

    $masked = $ErrorMessage
    # Mask credential values (everything after : or = until space or end)
    $masked = $masked -replace '(client_secret)[:\s=]+([^\s]+)', '$1: [REDACTED_SECRET]'
    $masked = $masked -replace '(client_id)[:\s=]+([^\s]+)', '$1: [REDACTED_ID]'
    $masked = $masked -replace '(tenant[_-]?id)[:\s=]+([^\s]+)', '$1: [REDACTED_TENANT]'
    $masked = $masked -replace '(tenant)[:\s=]+([^\s]+)', '$1: [REDACTED_TENANT]'
    # Mask email addresses
    $masked = $masked -replace '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '[REDACTED_EMAIL]'

    return $masked
}

function Get-UtcTimestamp {
    <#
    .SYNOPSIS
    Returns the current UTC time as ISO 8601 string.

    .DESCRIPTION
    Returns [datetime]::UtcNow formatted as ISO 8601 (o format).
    Useful for consistent timestamp logging and state files.

    .OUTPUTS
    String in ISO 8601 format (e.g., '2026-07-03T10:30:45.1234567Z').

    .EXAMPLE
    $timestamp = Get-UtcTimestamp
    # Returns: '2026-07-03T10:30:45.1234567Z'

    .NOTES
    Always UTC, never local time. Use for state files and logs.
    #>

    [CmdletBinding()]
    param()
    [datetime]::UtcNow.ToString('o')
}

function ConvertTo-UnixTimestamp {
    <#
    .SYNOPSIS
    Converts a DateTime object to Unix timestamp (seconds since 1970-01-01).

    .DESCRIPTION
    Takes a DateTime and returns the equivalent Unix timestamp as an integer.
    Useful for API calls and time comparisons.

    .PARAMETER DateTime
    DateTime object to convert.

    .OUTPUTS
    Int64 - Unix timestamp (seconds since 1970-01-01 UTC).

    .EXAMPLE
    ConvertTo-UnixTimestamp -DateTime '2026-07-03T10:30:00Z'
    # Returns: 1778899800

    .NOTES
    Uses PowerShell's Get-Date -UFormat %s for conversion.
    #>

    [CmdletBinding()]
    param([ValidateNotNull()][datetime]$DateTime)
    [int64][double]::Parse((Get-Date $DateTime -UFormat %s))
}