Private/Misc.ps1

function Format-PiHoleSecond {
    param (
        [int]$TimeInSeconds
    )

    $Data = [math]::Round($TimeInSeconds)

    $Object = [PSCustomObject]@{
        TimeInSeconds = $Data
    }

    $ObjectFinal = $Object
    Write-Output $ObjectFinal
}

function Convert-PiHoleUnixTimeToLocalTime {
    param (
        [int]$UnixTime
    )

    $ConvertedTime = [timezone]::CurrentTimeZone.ToLocalTime(([datetime]'1/1/1970').AddSeconds($UnixTime))

    $ObjectFinal = @()

    $Object = [PSCustomObject]@{
        LocalTime = $ConvertedTime
        UnixTime  = $UnixTime
    }

    $ObjectFinal = $Object
    Write-Output $ObjectFinal
}

function Convert-LocalTimeToPiHoleUnixTime {
    param (
        [datetime]$Date
    )

    $ConvertedTime = [int64](Get-Date $Date -UFormat %s)

    $Object = [PSCustomObject]@{
        LocalTime = $Date
        UnixTime  = $ConvertedTime
    }

    $ObjectFinal = $Object
    Write-Output $ObjectFinal
}

function ConvertTo-PiHolePascalCase {
    #INTERNAL FUNCTION
    param (
        [string]$Name
    )

    if ([string]::IsNullOrEmpty($Name)) {
        return $Name
    }

    $Segments = $Name -split '_' | Where-Object { $_.Length -gt 0 }
    $PascalSegments = foreach ($Segment in $Segments) {
        $Segment.Substring(0, 1).ToUpper() + $Segment.Substring(1)
    }
    return ($PascalSegments -join '')
}

function ConvertTo-PiHolePascalCaseObject {
    #INTERNAL FUNCTION
    #
    # Recursively rebuilds an API response as nested PSCustomObjects/arrays with PascalCase
    # property names (e.g. EXTERNAL_BLOCKED_IP / app_pwhash -> ExternalBlockedIp / AppPwhash),
    # so deep/wide response trees don't need every field hardcoded by hand to be PowerShell
    # object friendly - and so newly added API fields show up automatically instead of being
    # silently dropped.
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )
    process {
        if ($null -eq $InputObject) {
            return $null
        }

        if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
            $Result = [ordered]@{}
            foreach ($Prop in $InputObject.PSObject.Properties) {
                $Key = ConvertTo-PiHolePascalCase -Name $Prop.Name
                $Result[$Key] = ConvertTo-PiHolePascalCaseObject -InputObject $Prop.Value
            }
            return [PSCustomObject]$Result
        }

        if (($InputObject -is [System.Collections.IEnumerable]) -and ($InputObject -isnot [string])) {
            return @($InputObject | ForEach-Object { ConvertTo-PiHolePascalCaseObject -InputObject $_ })
        }

        return $InputObject
    }
}

function ConvertTo-PiHoleFriendlyErrorMessage {
    #INTERNAL FUNCTION
    #
    # Pi-hole's own error responses often carry a clearer message/hint than the generic HTTP
    # exception text (e.g. "Unable to change configuration (read-only): ...app_sudo is false"
    # vs just "403 Forbidden"). This extracts and combines them when present, and adds a
    # concrete pointer to fix the most common cause of a blocked config write - the app
    # password's app_sudo setting - since Pi-hole's own hint says what's wrong but not how to
    # fix it.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "", Justification = "Falls back to the raw exception message when the response body isn't valid JSON - nothing to report.")]
    param (
        [Parameter(Mandatory = $true)]
        $ErrorRecord
    )

    $Message = $ErrorRecord.Exception.Message

    if ($ErrorRecord.ErrorDetails.Message) {
        try {
            $ApiError = ($ErrorRecord.ErrorDetails.Message | ConvertFrom-Json).error
            if ($ApiError.message) {
                $Message = $ApiError.message
                if ($ApiError.hint) {
                    $Message += ": $($ApiError.hint)"
                }
                if ($ApiError.hint -like '*app_sudo*') {
                    $Message += " Enable it in your Pi-hole admin UI under Settings > All Settings by searching for 'app_sudo' and setting webserver.api.app_sudo to true."
                }
            }
        }
        catch {
            # ErrorDetails.Message wasn't valid JSON - fall back to the raw exception message
        }
    }

    return $Message
}

function Remove-PiHoleCurrentAuthSession {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "", Justification = "It removes sessions from PiHole only")]
    [CmdletBinding()]
    param (
        [System.URI]$PiHoleServer,
        [string]$Sid,
        [bool]$IgnoreSsl = $false
    )
    $Params = @{
        Headers              = @{sid = $($Sid) }
        Uri                  = "$($PiHoleServer.OriginalString)/api/auth"
        Method               = "Delete"
        SkipCertificateCheck = $IgnoreSsl
        ContentType          = "application/json"
    }

    try {
        $null = Invoke-RestMethod @Params
    }

    catch {
        # Best-effort logout, called from every public function's finally block - a transient
        # failure here (e.g. the server briefly unreachable right after a restart) must never
        # fail the caller. Write-Error would do exactly that under $ErrorActionPreference =
        # 'Stop', which Azure Pipelines' pwsh task sets by default.
        Write-Warning -Message "Failed to close Pi-hole session: $($_.Exception.Message)"
    }
}