Private/GraphHelpers.ps1

function Get-InTUIGraphConnectionDisplay {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Context,

        [Parameter()]
        [string]$ClientId,

        [Parameter()]
        [switch]$ClientCredential
    )

    $connectionType = if ($ClientCredential -or [string]$Context.AuthType -eq 'AppOnly') { 'Service Principal' } else { 'User' }
    $account = if ($connectionType -eq 'Service Principal') {
        $Context.AppName ?? $ClientId ?? 'Service Principal'
    }
    else {
        $Context.Account ?? 'Unknown'
    }

    return [pscustomobject]@{
        Account        = $account
        ConnectionType = $connectionType
    }
}

function Show-InTUIGraphScopes {
    [CmdletBinding()]
    param()

    if (-not $script:Connected) {
        Show-InTUIWarning "Not connected to Microsoft Graph."
        Read-InTUIKey
        return
    }

    $context = Get-MgContext -ErrorAction SilentlyContinue
    if (-not $context) {
        Show-InTUIWarning "No active Microsoft Graph context found."
        Read-InTUIKey
        return
    }

    $connectionDisplay = Get-InTUIGraphConnectionDisplay -Context $context
    $scopes = @($context.Scopes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object -Unique)

    if ($scopes.Count -eq 0) {
        Show-InTUIWarning "The current Microsoft Graph context does not report any scopes."
        Read-InTUIKey
        return
    }

    $content = @(
        "[grey]Tenant:[/] $($context.TenantId ?? 'Unknown')"
        "[grey]Account:[/] $($connectionDisplay.Account)"
        "[grey]Auth:[/] $($connectionDisplay.ConnectionType)"
        ''
        "[grey]Scopes:[/]"
    )

    foreach ($scope in $scopes) {
        $content += "- $scope"
    }

    Show-InTUIPanel -Title "[blue]Current Graph Scopes[/]" -Content ($content -join "`n") -BorderColor Blue
    Read-InTUIKey
}

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',
            'DeviceManagementManagedDevices.PrivilegedOperations.All',
            'DeviceManagementApps.ReadWrite.All',
            'User.Read.All',
            'Group.Read.All',
            'GroupMember.Read.All',
            'DeviceManagementConfiguration.Read.All',
            'DeviceManagementServiceConfig.Read.All',
            'Directory.Read.All',
            'AuditLog.Read.All',
            'BitlockerKey.ReadBasic.All',
            'BitlockerKey.Read.All'
        ),

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [string]$ClientId,

        [Parameter()]
        [string]$ClientSecret,

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

        [Parameter()]
        [switch]$UseDeviceCode
    )

    $script:UseDeviceCode = $UseDeviceCode.IsPresent
    $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:$true -Environment $envConfig.MgEnvironment
        }
        else {
            $params = @{
                Scopes       = $Scopes
                ContextScope = 'Process'
                NoWelcome    = $true
                Environment  = $envConfig.MgEnvironment
            }
            if ($TenantId) { $params['TenantId'] = $TenantId }
            if ($UseDeviceCode) {
                $params['UseDeviceCode'] = $true
            }

            Connect-MgGraph @params
        }

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

        $script:Connected = $true
        $script:TenantId = $context.TenantId
        $connectionDisplay = Get-InTUIGraphConnectionDisplay -Context $context -ClientId $ClientId -ClientCredential:$useClientCredential
        $script:Account = $connectionDisplay.Account
        $script:ConnectionType = $connectionDisplay.ConnectionType
        Write-InTUILog -Message "Connected to Microsoft Graph" -Context @{
            TenantId       = $context.TenantId
            Account        = $script:Account
            ConnectionType = $script:ConnectionType
            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 Reconnect-InTUIGraph {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string[]]$Scopes,

        [Parameter()]
        [string]$TenantId,

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

        [Parameter()]
        [switch]$UseDeviceCode
    )

    $context = Get-MgContext -ErrorAction SilentlyContinue
    $currentScopes = if ($context) {
        @($context.Scopes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object -Unique)
    }
    else {
        @()
    }

    if (-not $PSBoundParameters.ContainsKey('TenantId') -and -not [string]::IsNullOrWhiteSpace([string]$script:TenantId)) {
        $TenantId = $script:TenantId
    }

    if (-not $PSBoundParameters.ContainsKey('Environment')) {
        $Environment = if ([string]::IsNullOrWhiteSpace([string]$script:CloudEnvironment)) { 'Global' } else { $script:CloudEnvironment }
    }

    if (-not $PSBoundParameters.ContainsKey('Scopes')) {
        $Scopes = $currentScopes
    }

    $scopeList = @($Scopes)

    if (-not $PSBoundParameters.ContainsKey('UseDeviceCode') -and $script:UseDeviceCode) {
        $UseDeviceCode = [switch]$true
    }

    Write-InTUILog -Message 'Reconnecting to Microsoft Graph' -Context @{
        TenantId      = $TenantId
        Environment   = $Environment
        ScopeCount    = $scopeList.Count
        UseDeviceCode = $UseDeviceCode.IsPresent
    }

    Disconnect-MgGraph -ErrorAction SilentlyContinue
    $script:Connected = $false

    $connectParams = @{ Environment = $Environment }
    if (-not [string]::IsNullOrWhiteSpace($TenantId)) {
        $connectParams['TenantId'] = $TenantId
    }
    if ($scopeList.Count -gt 0) {
        $connectParams['Scopes'] = $scopeList
    }
    if ($UseDeviceCode) {
        $connectParams['UseDeviceCode'] = $true
    }

    return (Connect-InTUIGraph @connectParams)
}

function Get-InTUIGraphErrorRawDetails {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $rawDetails = $ErrorRecord.ErrorDetails.Message
    if (-not [string]::IsNullOrWhiteSpace($rawDetails)) {
        return $rawDetails
    }

    $responseContent = $ErrorRecord.Exception.Response?.Content
    if ($null -eq $responseContent) {
        return $null
    }

    try {
        return $responseContent.ReadAsStringAsync().GetAwaiter().GetResult()
    }
    catch {
        return $null
    }
}

function Get-InTUIIntuneNestedErrorMessage {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$Text
    )

    if ([string]::IsNullOrWhiteSpace($Text)) {
        return $null
    }

    $nestedCodeMatch = [regex]::Match($Text, '(?:\\"|")ErrorCode(?:\\"|")\s*:\s*(?:\\"|")(?<code>[^"\\]+)')
    $activityIdMatch = [regex]::Match($Text, 'Activity ID:\s*(?<activityId>[0-9a-fA-F-]{36})')
    if (-not $nestedCodeMatch.Success -or -not $activityIdMatch.Success) {
        return $null
    }

    return "$($nestedCodeMatch.Groups['code'].Value): Intune service rejected the request. Activity ID: $($activityIdMatch.Groups['activityId'].Value)"
}

function ConvertFrom-InTUIGraphErrorJson {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$RawDetails
    )

    if ([string]::IsNullOrWhiteSpace($RawDetails)) {
        return $null
    }

    try {
        $errorDetail = $RawDetails | ConvertFrom-Json
    }
    catch {
        return $null
    }

    $intuneMessage = Get-InTUIIntuneNestedErrorMessage -Text $RawDetails
    if ($intuneMessage) {
        return $intuneMessage
    }

    if ($errorDetail.error) {
        $code = [string]$errorDetail.error.code
        $message = [string]$errorDetail.error.message
        if ([string]::IsNullOrWhiteSpace($code)) {
            return $message
        }

        return "$code`: $message"
    }

    if ($errorDetail.message) {
        return [string]$errorDetail.message
    }

    return $RawDetails
}

function Get-InTUIGraphErrorMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $errorMessage = $ErrorRecord.Exception.Message
    $rawDetails = Get-InTUIGraphErrorRawDetails -ErrorRecord $ErrorRecord
    $errorText = $rawDetails

    if ([string]::IsNullOrWhiteSpace($errorText) -and $errorMessage -match '\{"error"') {
        $jsonStart = $errorMessage.IndexOf('{"error"')
        if ($jsonStart -ge 0) {
            $rawDetails = $errorMessage.Substring($jsonStart)
            $errorText = $rawDetails
        }
    }

    $parsedErrorMessage = (ConvertFrom-InTUIGraphErrorJson -RawDetails $errorText) ??
        (Get-InTUIIntuneNestedErrorMessage -Text $errorText) ??
        (Get-InTUIIntuneNestedErrorMessage -Text $errorMessage)

    if ($parsedErrorMessage) {
        $errorMessage = $parsedErrorMessage
    }

    if ([string]::IsNullOrWhiteSpace($errorMessage)) {
        $errorMessage = "Request failed (HTTP $($ErrorRecord.Exception.Response.StatusCode))"
        if ($ErrorRecord.Exception.Response.ReasonPhrase) {
            $errorMessage += " - $($ErrorRecord.Exception.Response.ReasonPhrase)"
        }
    }

    return [pscustomobject]@{
        Message = $errorMessage
        RawBody = $rawDetails
    }
}

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,

        [Parameter()]
        [ValidateRange(1, 10000)]
        [int]$MaxPages = 500,

        [Parameter()]
        [switch]$NoCache,

        [Parameter()]
        [switch]$SuppressErrorOutput
    )

    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 -and -not $NoCache) {
        $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
    }

    $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 {
        $script:LastGraphError = $null
        $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++
                if ($pageCount -gt $MaxPages) {
                    throw "Graph pagination exceeded max page limit of $MaxPages for '$fullUri'."
                }

                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 -and -not $NoCache) {
                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 -and -not $NoCache) {
            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 {
        $graphError = Get-InTUIGraphErrorMessage -ErrorRecord $_
        $errorMessage = $graphError.Message
        Write-InTUILog -Level 'ERROR' -Message "Graph API Error: $errorMessage" -Context @{ Uri = $fullUri; Method = $Method }
        $script:LastGraphError = [pscustomobject]@{
            Message    = $errorMessage
            Uri        = $fullUri
            Method     = $Method
            StatusCode = $_.Exception.Response.StatusCode
            RawBody    = $graphError.RawBody
        }
        if (-not $SuppressErrorOutput) {
            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) {
        $searchValue = if ($Search.StartsWith('"')) { $Search } else { "`"$Search`"" }
        $queryParams += "`$search=$searchValue"
    }
    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[/]' }
    }
}