Private/GraphHelpers.ps1

function Connect-InTUIGraph {
    <#
    .SYNOPSIS
        Connects to Microsoft Graph with required scopes for Intune management.
    .PARAMETER Scopes
        Graph API permission scopes to request (interactive auth only).
    .PARAMETER TenantId
        Optional tenant ID or domain.
    .PARAMETER ClientId
        Application (client) ID for service principal auth.
    .PARAMETER ClientSecret
        Client secret for service principal auth.
    .PARAMETER Environment
        Cloud environment: Global, USGov, USGovDoD, or China.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string[]]$Scopes = @(
            'DeviceManagementManagedDevices.ReadWrite.All',
            'DeviceManagementApps.ReadWrite.All',
            'User.Read.All',
            'Group.Read.All',
            'GroupMember.Read.All',
            'DeviceManagementConfiguration.Read.All',
            'Directory.Read.All',
            'AuditLog.Read.All'
        ),

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [string]$ClientId,

        [Parameter()]
        [string]$ClientSecret,

        [Parameter()]
        [ValidateSet('Global', 'USGov', 'USGovDoD', 'China')]
        [string]$Environment = 'Global',

        [Parameter()]
        [switch]$UseDeviceCode
    )

    $script:CloudEnvironment = $Environment
    $envConfig = $script:CloudEnvironments[$Environment]
    $script:GraphBaseUrl = $envConfig.GraphBaseUrl
    $script:GraphBetaUrl = $envConfig.GraphBetaUrl

    $useClientCredential = $ClientId -and $ClientSecret -and $TenantId

    $authMode = if ($useClientCredential) { 'ClientCredential' }
                elseif ($UseDeviceCode) { 'DeviceCode' }
                else { 'Interactive' }

    Write-InTUILog -Message "Connecting to Microsoft Graph" -Context @{
        Environment  = $Environment
        GraphBaseUrl = $script:GraphBaseUrl
        TenantId     = $TenantId
        AuthMode     = $authMode
    }

    try {
        if ($useClientCredential) {
            $secureSecret = ConvertTo-SecureString -String $ClientSecret -AsPlainText -Force
            $credential = [System.Management.Automation.PSCredential]::new($ClientId, $secureSecret)
            Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $credential -NoWelcome -Environment $envConfig.MgEnvironment
        }
        elseif ($UseDeviceCode) {
            $params = @{
                Scopes        = $Scopes
                UseDeviceCode = $true
                NoWelcome     = $true
                Environment   = $envConfig.MgEnvironment
            }
            if ($TenantId) { $params['TenantId'] = $TenantId }
            Connect-MgGraph @params
        }
        else {
            $params = @{
                Scopes      = $Scopes
                NoWelcome   = $true
                Environment = $envConfig.MgEnvironment
            }
            if ($TenantId) { $params['TenantId'] = $TenantId }
            Connect-MgGraph @params
        }

        $context = Get-MgContext
        if (-not $context) { return $false }

        $script:Connected = $true
        $script:TenantId = $context.TenantId
        $script:Account = $context.Account ?? $ClientId
        Write-InTUILog -Message "Connected to Microsoft Graph" -Context @{
            TenantId    = $context.TenantId
            Account     = $script:Account
            Environment = $Environment
        }
        return $true
    }
    catch {
        Write-InTUILog -Level 'ERROR' -Message "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
        Write-InTUIText "[red]Failed to connect to Microsoft Graph: $($_.Exception.Message)[/]"
        return $false
    }
}

function Invoke-InTUIGraphRequest {
    <#
    .SYNOPSIS
        Wrapper around Invoke-MgGraphRequest with error handling and pagination support.
    #>

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

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

        [Parameter()]
        [hashtable]$Body,

        [Parameter()]
        [switch]$Beta,

        [Parameter()]
        [switch]$All,

        [Parameter()]
        [int]$Top = 0,

        [Parameter()]
        [hashtable]$Headers
    )

    if (-not $script:Connected) {
        Write-InTUILog -Level 'WARN' -Message "Graph request attempted while not connected" -Context @{ Uri = $Uri }
        Write-InTUIText "[red]Not connected to Microsoft Graph. Run Connect-InTUI first.[/]"
        return $null
    }

    $baseUrl = if ($Beta) { $script:GraphBetaUrl } else { $script:GraphBaseUrl }

    if ($Uri -notmatch '^https://') {
        $fullUri = "$baseUrl/$($Uri.TrimStart('/'))"
    }
    else {
        $fullUri = $Uri
    }

    if ($Top -gt 0 -and $Method -eq 'GET') {
        $separator = if ($fullUri -match '\?') { '&' } else { '?' }
        $fullUri = "$fullUri$separator`$top=$Top"
    }

    # Check cache for GET requests
    if ($Method -eq 'GET' -and $script:CacheEnabled) {
        $cached = Get-InTUICachedResponse -Uri $fullUri -Method $Method -Beta:$Beta
        if ($null -ne $cached) {
            return $cached
        }
    }

    Write-InTUILog -Message "Graph API request" -Context @{
        Method = $Method
        Uri = $fullUri
        Beta = [bool]$Beta
        All = [bool]$All
        Environment = $script:CloudEnvironment
    }

    # Record action for script recording (only write operations)
    if ($script:RecordingEnabled -and $Method -ne 'GET') {
        Add-InTUIRecordedAction -Method $Method -Uri $Uri -Body $Body -Beta:$Beta
    }

    $params = @{
        Uri    = $fullUri
        Method = $Method
        OutputType = 'PSObject'
    }

    if ($Headers) {
        $params['Headers'] = $Headers
    }

    if ($Body) {
        $params['Body'] = $Body | ConvertTo-Json -Depth 10
        $params['ContentType'] = 'application/json'
    }

    try {
        $response = Invoke-MgGraphRequest @params

        if ($All -and $Method -eq 'GET') {
            $allResults = [System.Collections.Generic.List[object]]::new()
            if ($response.value) {
                $allResults.AddRange(@($response.value))
            }

            $pageCount = 1
            while ($response.'@odata.nextLink') {
                $pageCount++
                Write-InTUILog -Message "Fetching pagination page $pageCount" -Context @{ NextLink = $response.'@odata.nextLink' }
                $response = Invoke-MgGraphRequest -Uri $response.'@odata.nextLink' -Method GET -OutputType PSObject
                if ($response.value) {
                    $allResults.AddRange(@($response.value))
                }
            }

            Write-InTUILog -Message "Graph API request completed" -Context @{ TotalResults = $allResults.Count; Pages = $pageCount }

            # Cache the paginated results
            if ($script:CacheEnabled) {
                Set-InTUICachedResponse -Uri $fullUri -Data $allResults -Method $Method -Beta:$Beta
            }

            return $allResults
        }

        $resultCount = if ($response.value) { @($response.value).Count } else { 1 }
        Write-InTUILog -Message "Graph API request completed" -Context @{ ResultCount = $resultCount }

        # Cache single-page response
        if ($Method -eq 'GET' -and $script:CacheEnabled) {
            Set-InTUICachedResponse -Uri $fullUri -Data $response -Method $Method -Beta:$Beta
        }

        # Return $true for no-content success (e.g., 204) so $null exclusively means error
        return ($response ?? $true)
    }
    catch {
        $errorMessage = $_.Exception.Message
        if ($_.ErrorDetails.Message) {
            try {
                $errorDetail = $_.ErrorDetails.Message | ConvertFrom-Json
                $errorMessage = "$($errorDetail.error.code): $($errorDetail.error.message)"
            }
            catch {
                $errorMessage = $_.ErrorDetails.Message
            }
        }
        # Fallback if message is still empty
        if ([string]::IsNullOrWhiteSpace($errorMessage)) {
            $errorMessage = "Request failed (HTTP $($_.Exception.Response.StatusCode))"
            if ($_.Exception.Response.ReasonPhrase) {
                $errorMessage += " - $($_.Exception.Response.ReasonPhrase)"
            }
        }
        Write-InTUILog -Level 'ERROR' -Message "Graph API Error: $errorMessage" -Context @{ Uri = $fullUri; Method = $Method }
        Write-InTUIText "[red]Graph API Error: $errorMessage[/]"
        return $null
    }
}

function Get-InTUIPagedResults {
    <#
    .SYNOPSIS
        Gets paged results from Graph API with navigation support.
    #>

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

        [Parameter()]
        [switch]$Beta,

        [Parameter()]
        [int]$PageSize = $script:PageSize,

        [Parameter()]
        [string]$Filter,

        [Parameter()]
        [string]$Search,

        [Parameter()]
        [string]$Select,

        [Parameter()]
        [string]$OrderBy,

        [Parameter()]
        [string]$Expand,

        [Parameter()]
        [hashtable]$Headers,

        [Parameter()]
        [switch]$IncludeCount
    )

    $queryParams = @()

    if ($PageSize -gt 0) {
        $queryParams += "`$top=$PageSize"
    }
    if ($Filter) {
        $queryParams += "`$filter=$Filter"
    }
    if ($Search) {
        $queryParams += "`$search=`"$Search`""
    }
    if ($Select) {
        $queryParams += "`$select=$Select"
    }
    if ($OrderBy) {
        $queryParams += "`$orderby=$OrderBy"
    }
    if ($Expand) {
        $queryParams += "`$expand=$Expand"
    }
    if ($IncludeCount) {
        $queryParams += "`$count=true"
    }

    $fullUri = $Uri
    if ($queryParams.Count -gt 0) {
        $fullUri = "$Uri`?$($queryParams -join '&')"
    }

    $params = @{ Uri = $fullUri }
    if ($Beta) { $params['Beta'] = $true }
    if ($Headers) { $params['Headers'] = $Headers }

    $response = Invoke-InTUIGraphRequest @params

    # Guard: null or non-object response (e.g. $true from 204 No Content)
    if ($null -eq $response -or $response -is [bool]) {
        return @{ Results = @(); NextLink = $null; TotalCount = 0 }
    }

    $results = if ($response.value) { @($response.value) }
               elseif ($response -is [array]) { $response }
               else { @() }

    $resultCount = @($results).Count
    $odataCount = $response.'@odata.count'
    # Use @odata.count only when present and sensible (>= page results); otherwise use actual count
    $totalCount = if ($null -ne $odataCount -and $odataCount -ge $resultCount) { $odataCount } else { $resultCount }

    return @{
        Results    = $results
        NextLink   = $response.'@odata.nextLink'
        TotalCount = $totalCount
    }
}

function ConvertTo-InTUISafeFilterValue {
    <#
    .SYNOPSIS
        Escapes a string for safe use inside an OData $filter expression.
    #>

    param([string]$Value)

    if ([string]::IsNullOrEmpty($Value)) { return $Value }
    return $Value -replace "'", "''"
}

function Format-InTUIDate {
    <#
    .SYNOPSIS
        Formats a date string for display.
    #>

    param([string]$DateString)

    if ([string]::IsNullOrEmpty($DateString)) { return 'N/A' }

    try {
        $date = [DateTime]::Parse($DateString)
        $now = [DateTime]::UtcNow
        $diff = $now - $date

        if ($diff.TotalMinutes -lt 60) {
            return "$([math]::Floor($diff.TotalMinutes))m ago"
        }
        elseif ($diff.TotalHours -lt 24) {
            return "$([math]::Floor($diff.TotalHours))h ago"
        }
        elseif ($diff.TotalDays -lt 7) {
            return "$([math]::Floor($diff.TotalDays))d ago"
        }
        else {
            return $date.ToString('yyyy-MM-dd HH:mm')
        }
    }
    catch {
        return $DateString
    }
}

function Get-InTUIComplianceColor {
    <#
    .SYNOPSIS
        Returns markup color name based on compliance state.
    #>

    param([string]$State)

    switch ($State) {
        'compliant'     { return 'green' }
        'noncompliant'  { return 'red' }
        'error'         { return 'red' }
        'inGracePeriod' { return 'yellow' }
        'configManager' { return 'blue' }
        'conflict'      { return 'orange1' }
        default         { return 'grey' }
    }
}

function Get-InTUIInstallStateColor {
    <#
    .SYNOPSIS
        Returns markup color name based on app install state.
    #>

    param([string]$State)

    switch ($State) {
        'installed'       { return 'green' }
        'failed'          { return 'red' }
        'uninstallFailed' { return 'red' }
        'notInstalled'    { return 'grey' }
        'notApplicable'   { return 'grey' }
        default           { return 'yellow' }
    }
}

function Get-InTUIDeviceIcon {
    <#
    .SYNOPSIS
        Returns an icon character based on OS type.
    #>

    param([string]$OperatingSystem)

    switch -Wildcard ($OperatingSystem) {
        '*Windows*' { return '[blue]W[/]' }
        '*iOS*'     { return '[grey]i[/]' }
        '*iPadOS*'  { return '[grey]P[/]' }
        '*macOS*'   { return '[grey]m[/]' }
        '*Android*' { return '[green]A[/]' }
        '*Linux*'   { return '[yellow]L[/]' }
        default     { return '[grey]-[/]' }
    }
}

function Get-InTUIAppTypeIcon {
    <#
    .SYNOPSIS
        Returns an icon based on application type.
    #>

    param([string]$AppType)

    switch -Wildcard ($AppType) {
        '*win32*'           { return '[blue]W[/]' }
        '*msi*'             { return '[blue]M[/]' }
        '*ios*'             { return '[grey]i[/]' }
        '*android*'         { return '[green]A[/]' }
        '*webApp*'          { return '[cyan]w[/]' }
        '*office*'          { return '[orange1]O[/]' }
        '*microsoft*'       { return '[blue]M[/]' }
        '*store*'           { return '[cyan]S[/]' }
        '*managed*'         { return '[yellow]m[/]' }
        default             { return '[grey]-[/]' }
    }
}

function Get-InTUIPolicyIcon {
    <#
    .SYNOPSIS
        Returns an icon based on policy type.
    #>

    param([string]$PolicyType)

    switch -Wildcard ($PolicyType) {
        '*compliance*'      { return '[green]+[/]' }
        '*configuration*'   { return '[blue]*[/]' }
        '*conditional*'     { return '[yellow]![/]' }
        '*security*'        { return '[red]#[/]' }
        '*update*'          { return '[cyan]~[/]' }
        default             { return '[grey]-[/]' }
    }
}

function Get-InTUISecurityIcon {
    <#
    .SYNOPSIS
        Returns security-related icons.
    #>

    param(
        [Parameter(Mandatory)]
        [ValidateSet('Shield', 'Lock', 'Unlock', 'Key', 'Warning', 'Error', 'Check', 'Cross')]
        [string]$Type
    )

    switch ($Type) {
        'Shield'  { return '[blue]#[/]' }
        'Lock'    { return '[green]#[/]' }
        'Unlock'  { return '[yellow]-[/]' }
        'Key'     { return '[yellow]k[/]' }
        'Warning' { return '[yellow]![/]' }
        'Error'   { return '[red]x[/]' }
        'Check'   { return '[green]+[/]' }
        'Cross'   { return '[red]x[/]' }
    }
}