IPC.psm1

#Requires -Version 5.1
<#
.SYNOPSIS
    IPC - Intune Properties Catalog (PowerShell module).

.DESCRIPTION
    Query hardware and software inventory from Intune managed devices via
    Microsoft Graph beta API. No Azure app registration required.
#>


# ── Constants ────────────────────────────────────────────────────────────────

$script:INTUNE_CLIENT_ID = '5926fc8e-304e-4f59-8bed-58ca97cc39a4'
$script:BROKER_CLIENT_ID = 'c44b4083-3bb0-49c1-b47d-974e53cbdf3c'
$script:BROKER_URL       = 'https://portal.azure.com/'
$script:GRAPH_BASE_URL   = 'https://graph.microsoft.com/beta'
$script:VAULT_NAME       = 'IPCVault'
$script:SECRET_ACCESS    = 'ipc-access-token'
$script:SECRET_REFRESH   = 'ipc-refresh-token'
$script:SECRET_METADATA  = 'ipc-token-metadata'
$script:SECRET_TENANT    = 'ipc-tenant-id'
$script:BATCH_SIZE       = 20
$script:MAX_BATCH_RETRIES = 5
$script:DEFAULT_RETRY_AFTER = 30
$script:IPC_NOT_FOUND = New-Object psobject
$script:INVENTORY_CATEGORY_ALIASES = @{
    'localaiagent'          = 'LocalAiAgent'
    'localaiagents'         = 'LocalAiAgent'
    'registrykeyproperties' = 'RegistryKeyProperties'
    'registrykey'           = 'RegistryKeyProperties'
    'registrykeys'          = 'RegistryKeyProperties'
    'registry'              = 'RegistryKeyProperties'
    'regkey'                = 'RegistryKeyProperties'
    'regkeys'               = 'RegistryKeyProperties'
}

function Test-IPCIsWindows {
    return [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT
}

function ConvertTo-IPCHashtable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        $InputObject
    )

    process {
        if ($null -eq $InputObject) { return $null }

        if ($InputObject -is [System.Collections.IDictionary]) {
            $result = @{}
            foreach ($key in $InputObject.Keys) {
                $result[$key] = ConvertTo-IPCHashtable -InputObject $InputObject[$key]
            }
            return $result
        }

        if (($InputObject -is [System.Collections.IEnumerable]) -and -not ($InputObject -is [string])) {
            $items = @()
            foreach ($item in $InputObject) {
                $items += ,(ConvertTo-IPCHashtable -InputObject $item)
            }
            return $items
        }

        if ($InputObject -is [pscustomobject]) {
            $result = @{}
            foreach ($property in $InputObject.PSObject.Properties) {
                $result[$property.Name] = ConvertTo-IPCHashtable -InputObject $property.Value
            }
            return $result
        }

        return $InputObject
    }
}

function ConvertFrom-IPCJson {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$Json
    )

    $parsed = $Json | ConvertFrom-Json
    return ConvertTo-IPCHashtable -InputObject $parsed
}

function Get-IPCMemberValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $InputObject,

        [Parameter(Mandatory)]
        [string]$Name
    )

    if ($InputObject -is [System.Collections.IDictionary]) {
        if ($InputObject.Contains($Name)) {
            return $InputObject[$Name]
        }
        return $script:IPC_NOT_FOUND
    }

    $property = $InputObject.PSObject.Properties[$Name]
    if ($property) {
        return $property.Value
    }

    return $script:IPC_NOT_FOUND
}

function Get-IPCFirstValue {
    [CmdletBinding()]
    param(
        $InputObject,

        [Parameter(Mandatory)]
        [string[]]$Names,

        $Default = $null
    )

    if ($null -eq $InputObject) { return $Default }

    foreach ($name in $Names) {
        $value = Get-IPCMemberValue -InputObject $InputObject -Name $name
        if (-not [object]::ReferenceEquals($value, $script:IPC_NOT_FOUND) -and $null -ne $value) {
            return $value
        }
    }

    return $Default
}

# ── Secret Vault ─────────────────────────────────────────────────────────────

# Track whether the vault has been initialized this session
$script:_vaultInitialized = $false

function Initialize-IPCSecretVault {
    <#
    .SYNOPSIS
        Ensures SecretManagement + SecretStore modules are installed and the
        IPC vault is registered.
    #>

    [CmdletBinding()]
    param(
        # Pass this switch when calling from an interactive terminal (CLI).
        # Enables the one-time vault setup prompt on first run.
        [switch]$Interactive
    )

    if ($script:_vaultInitialized) {
        if (Get-SecretVault -Name $script:VAULT_NAME -ErrorAction SilentlyContinue -WarningAction SilentlyContinue) {
            return
        }
        $script:_vaultInitialized = $false
    }

    $storePath = if (Test-IPCIsWindows) {
        Join-Path `
            (Join-Path `
                (Join-Path `
                    (Join-Path ([System.Environment]::GetFolderPath('LocalApplicationData')) 'Microsoft') `
                    'PowerShell') `
                'secretmanagement') `
            'localstore'
    } else {
        Join-Path (Join-Path $HOME '.secretmanagement') 'localstore'
    }
    $storeExists = Test-Path $storePath

    foreach ($mod in @('Microsoft.PowerShell.SecretManagement', 'Microsoft.PowerShell.SecretStore')) {
        if (-not (Get-Module -ListAvailable -Name $mod)) {
            Write-Host "[info] Installing $mod ..." -ForegroundColor Cyan
            Install-Module -Name $mod -Scope CurrentUser -Force -AllowClobber
        }
        Import-Module $mod -ErrorAction Stop
    }

    if (-not $storeExists) {
        $usePassword = $false

        if ($Interactive) {
            Write-Host ''
            Write-Host ' +-- IPC Vault Setup --------------------------------------------------+' -ForegroundColor Cyan
            Write-Host ' | |' -ForegroundColor Cyan
            Write-Host ' | Protect the secret vault with a password? |' -ForegroundColor Cyan
            Write-Host ' | |' -ForegroundColor Cyan
            Write-Host ' | [N] No - passwordless (RECOMMENDED) |' -ForegroundColor Green
            Write-Host ' | Tokens always accessible, no prompts. |' -ForegroundColor Green
            Write-Host ' | Works with AI agents and Copilot skills. |' -ForegroundColor Green
            Write-Host ' | |' -ForegroundColor Cyan
            Write-Host ' | [y] Yes - password-protected (CLI USE ONLY) |' -ForegroundColor Yellow
            Write-Host ' | Tokens encrypted with a password you set now. |' -ForegroundColor Yellow
            Write-Host ' | NOT compatible with AI agents or Copilot skills. |' -ForegroundColor Red
            Write-Host ' | The unlock state is process-scoped - agents run |' -ForegroundColor Red
            Write-Host ' | in a new process and cannot see your unlock. |' -ForegroundColor Red
            Write-Host ' | |' -ForegroundColor Cyan
            Write-Host ' +---------------------------------------------------------------------+' -ForegroundColor Cyan
            Write-Host ''
            $answer = Read-Host ' Password-protect vault? [y/N]'
            $usePassword = $answer.Trim().ToLower() -eq 'y'
            Write-Host ''
        }

        New-Item -ItemType Directory -Path $storePath -Force | Out-Null

        if ($usePassword) {
            Write-Host '[info] Configuring vault with password protection...' -ForegroundColor Cyan
            Write-Host '[info] You will be prompted to set your vault password now.' -ForegroundColor Cyan
            $vaultPw = Read-Host -Prompt 'Set vault password' -AsSecureString
            Reset-SecretStore -Authentication Password -Password $vaultPw -PasswordTimeout 28800 -Interaction None -Force -WarningAction SilentlyContinue
            Unlock-SecretStore -Password $vaultPw -PasswordTimeout 28800
            Write-Host '[ok] Vault secured with a password.' -ForegroundColor Green
            Write-Host '[!] AI agents and Copilot skills will NOT work with a password-protected vault.' -ForegroundColor Yellow
            Write-Host ' To switch to passwordless, see the README: Resetting the vault.' -ForegroundColor Yellow
        } else {
            Reset-SecretStore -Authentication None -Interaction None -Force -WarningAction SilentlyContinue
            Write-Host '[ok] Vault configured (no password) - always seamless.' -ForegroundColor Green
        }
    }

    if (-not (Get-SecretVault -Name $script:VAULT_NAME -ErrorAction SilentlyContinue -WarningAction SilentlyContinue)) {
        $existingDefault = Get-SecretVault -ErrorAction SilentlyContinue -WarningAction SilentlyContinue |
            Where-Object { $_.IsDefault }
        $setDefault = -not $existingDefault
        Register-SecretVault -Name $script:VAULT_NAME -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault:$setDefault
        Write-Host "[ok] Secret vault '$($script:VAULT_NAME)' registered." -ForegroundColor Green
    }

    $vaultAccessible = Test-SecretVault -Name $script:VAULT_NAME -ErrorAction SilentlyContinue
    if (-not $vaultAccessible) {
        if ($Interactive) {
            $maxAttempts = 2
            $unlocked = $false
            for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
                Write-Host "[info] Vault is password-protected (attempt $attempt/$maxAttempts)." -ForegroundColor Cyan
                $pw = Read-Host -Prompt 'Enter vault password' -AsSecureString
                try {
                    Unlock-SecretStore -Password $pw -PasswordTimeout 28800
                    $unlocked = $true
                    break
                } catch {
                    if ($attempt -lt $maxAttempts) {
                        Write-Host '[error] Invalid password. Try again.' -ForegroundColor Red
                    }
                }
            }
            if ($unlocked) {
                Write-Host '[ok] Vault unlocked for 8 hours.' -ForegroundColor Green
            } else {
                Write-Host '[error] Invalid password. Exiting.' -ForegroundColor Red
                exit 1
            }
        } else {
            throw @"
IPC vault is password-protected and locked.

Run 'Unlock-IPCVault' (or 'Unlock-SecretStore') in your terminal first.

If you want to switch to passwordless mode, reset IPC:
  Clear-IPCTokens
  Unregister-SecretVault -Name IPCVault

Then reconfigure the SecretStore:
  Reset-SecretStore -Force -Authentication None -Interaction None

Re-run the CLI and IPC will re-register its vault automatically.
⚠ Reset-SecretStore will erase ALL secrets in the store (including other tools).
"@

        }
    }

    $script:_vaultInitialized = $true
}

function Unlock-IPCVault {
    <#
    .SYNOPSIS
        Unlocks the IPC SecretStore vault for the current session.
    .DESCRIPTION
        Run this once before starting an AI agent session (or after a long
        idle period). It prompts you for the vault password interactively so
        the AI agent can access stored tokens without needing your password.

        After unlocking, the vault stays open for 8 hours.
    .EXAMPLE
        Unlock-IPCVault
    #>

    [CmdletBinding()]
    param()

    Initialize-IPCSecretVault

    try {
        Unlock-SecretStore -PasswordTimeout 28800
        Write-Host "[ok] Vault unlocked for 8 hours. You can now run the AI agent." -ForegroundColor Green
    } catch {
        Write-Error "Failed to unlock vault: $_"
    }
}

# ── JWT Helpers ──────────────────────────────────────────────────────────────

function ConvertFrom-JwtPayload {
    <#
    .SYNOPSIS
        Decodes the payload section of a JWT without verifying the signature.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$Token
    )

    try {
        $parts = $Token.Split('.')
        if ($parts.Count -lt 2) { return @{} }
        $payload = $parts[1]
        switch ($payload.Length % 4) {
            2 { $payload += '==' }
            3 { $payload += '='  }
        }
        $payload = $payload.Replace('-', '+').Replace('_', '/')
        $json = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload))
        return (ConvertFrom-IPCJson -Json $json)
    } catch {
        return @{}
    }
}

function Resolve-AccessToken {
    <#
    .SYNOPSIS
        Normalizes user-pasted token input to the raw JWT access token.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$RawToken
    )

    $token = $RawToken.Trim()

    if ($token -match '^(?i)authorization:\s*(.+)$') {
        $token = $Matches[1].Trim()
    }

    while ($token.Length -ge 2 -and $token[0] -eq $token[-1] -and ($token[0] -eq '"' -or $token[0] -eq "'")) {
        $token = $token.Substring(1, $token.Length - 2).Trim()
    }

    if ($token -match '^(?i)bearer\s+(.+)$') {
        $token = $Matches[1].Trim()
    }

    if ($token -match '([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)') {
        return $Matches[1]
    }

    return $token
}

function ConvertTo-FriendlyName {
    <#
    .SYNOPSIS
        Converts camelCase/PascalCase to Title Case: 'cycleCount' → 'Cycle Count'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Name
    )

    $spaced = [regex]::Replace($Name, '(?<=[a-z0-9])([A-Z])', ' $1')
    return (Get-Culture).TextInfo.ToTitleCase($spaced)
}

function Normalize-IPCInventoryCategory {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Category
    )

    return ([regex]::Replace($Category.Trim().ToLowerInvariant(), '[^a-z0-9]', ''))
}

function Resolve-IPCInventoryCategory {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Category,

        [string[]]$AvailableCategories
    )

    $trimmed = $Category.Trim()
    if (-not $trimmed) { return $trimmed }

    if ($trimmed -ieq 'all') { return 'all' }

    $normalized = Normalize-IPCInventoryCategory -Category $trimmed

    if ($AvailableCategories) {
        foreach ($available in $AvailableCategories) {
            if ($available -and $available -ieq $trimmed) { return $available }
        }
    }

    if ($script:INVENTORY_CATEGORY_ALIASES.ContainsKey($normalized)) {
        $aliasTarget = $script:INVENTORY_CATEGORY_ALIASES[$normalized]
        if ($AvailableCategories) {
            foreach ($available in $AvailableCategories) {
                if ($available -and $available -ieq $aliasTarget) { return $available }
            }
        }
        return $aliasTarget
    }

    if ($AvailableCategories) {
        foreach ($available in $AvailableCategories) {
            if ($available -and (Normalize-IPCInventoryCategory -Category $available) -eq $normalized) {
                return $available
            }
        }
    }

    return $trimmed
}

function ConvertTo-CleanInstance {
    <#
    .SYNOPSIS
        Strips OData metadata and converts camelCase keys to friendly Title Case.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Instance
    )

    $cleaned = [ordered]@{}

    foreach ($key in $Instance.Keys) {
        if ($key.StartsWith('@')) { continue }

        $value = $Instance[$key]

        if ($key -eq 'properties' -and $value -is [array]) {
            foreach ($prop in $value) {
                if ($prop -isnot [hashtable] -and $prop -is [System.Collections.IDictionary]) {
                    $prop = [hashtable]$prop
                } elseif ($prop -isnot [hashtable]) {
                    $propHt = @{}
                    foreach ($p in $prop.PSObject.Properties) { $propHt[$p.Name] = $p.Value }
                    $prop = $propHt
                }

                $propName = Get-IPCFirstValue -InputObject $prop -Names @('displayName', 'name', 'propertyName', 'id')
                $propValue = if ($prop.ContainsKey('value')) { $prop['value'] } else { $prop['propertyValue'] }

                if ($propName -and -not $cleaned.Contains((ConvertTo-FriendlyName $propName))) {
                    $cleaned[(ConvertTo-FriendlyName ([string]$propName))] = $propValue
                }
            }
            continue
        }

        $friendlyKey = if ($key -eq 'id') { 'Instance Name' } else { ConvertTo-FriendlyName $key }
        $cleaned[$friendlyKey] = $value
    }

    $instanceName = $cleaned['Instance Name']
    if ($instanceName -is [string] -and $instanceName.Contains('=')) {
        foreach ($part in $instanceName.Split(';')) {
            if (-not $part.Contains('=')) { continue }
            $eqIdx = $part.IndexOf('=')
            $k = $part.Substring(0, $eqIdx).Trim()
            $v = $part.Substring($eqIdx + 1).Trim()
            if ($k -and -not $cleaned.Contains((ConvertTo-FriendlyName $k))) {
                $cleaned[(ConvertTo-FriendlyName $k)] = $v
            }
        }
    }

    return $cleaned
}

# ── Token Management ─────────────────────────────────────────────────────────

function Set-IPCAccessToken {
    <#
    .SYNOPSIS
        Stores a bearer access token (copied from browser DevTools Network tab).
    .PARAMETER AccessToken
        The access token as a SecureString. Use ConvertTo-SecureString -AsPlainText -Force
        to convert a plain-text token, or Read-Host -AsSecureString for interactive input.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [securestring]$AccessToken,

        [int]$ExpiresIn = 3600
    )

    Initialize-IPCSecretVault

    $tokenPlain = [System.Net.NetworkCredential]::new('', $AccessToken).Password
    $token = Resolve-AccessToken -RawToken $tokenPlain
    $payload = ConvertFrom-JwtPayload -Token $token

    if ($payload.ContainsKey('exp')) {
        $expiresAt = [double]$payload['exp']
    } elseif ($ExpiresIn -gt 0) {
        $expiresAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $ExpiresIn
    } else {
        $expiresAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + 3600
    }

    $metadata = @{ expires_at = $expiresAt } | ConvertTo-Json -Compress

    Set-Secret -Name $script:SECRET_ACCESS -Secret $token -Vault $script:VAULT_NAME -ErrorAction Stop
    Set-Secret -Name $script:SECRET_METADATA -Secret $metadata -Vault $script:VAULT_NAME -ErrorAction Stop

    try { Remove-Secret -Name $script:SECRET_REFRESH -Vault $script:VAULT_NAME -ErrorAction Ignore } catch { }
    try { Remove-Secret -Name $script:SECRET_TENANT -Vault $script:VAULT_NAME -ErrorAction Ignore } catch { }

    $expiryUtc = [DateTimeOffset]::FromUnixTimeSeconds([long]$expiresAt).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss UTC')
    Write-Host "[ok] Access token stored (expiry: $expiryUtc)." -ForegroundColor Green
    Write-Host "[info] Refresh token cleared (use option 2 to store one for this tenant)." -ForegroundColor Cyan
}

function Set-IPCRefreshToken {
    <#
    .SYNOPSIS
        Stores a refresh token (copied from browser Session Storage) and
        acquires an access token via the BroCI (Nested App Authentication) flow.
    .DESCRIPTION
        In the browser, go to intune.microsoft.com → DevTools → Application →
        Session Storage → look for an MSAL entry with credentialType "RefreshToken"
        and copy the "secret" field value.

        The refresh token belongs to the Azure Portal SPA (c44b4083). IPC
        exchanges it for an Intune access token using the BroCI broker flow.

        Any previously stored access token is cleared to avoid cross-tenant issues.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [securestring]$RefreshToken,

        [Parameter(Mandatory)]
        [string]$Tenant
    )

    Initialize-IPCSecretVault

    $token = [System.Net.NetworkCredential]::new('', $RefreshToken).Password.Trim()
    $tenantValue = $Tenant.Trim()

    Set-Secret -Name $script:SECRET_REFRESH -Secret $token -Vault $script:VAULT_NAME -ErrorAction Stop
    Set-Secret -Name $script:SECRET_TENANT -Secret $tenantValue -Vault $script:VAULT_NAME -ErrorAction Stop

    try { Remove-Secret -Name $script:SECRET_ACCESS -Vault $script:VAULT_NAME -ErrorAction Ignore } catch { }
    try { Remove-Secret -Name $script:SECRET_METADATA -Vault $script:VAULT_NAME -ErrorAction Ignore } catch { }

    Write-Host "[ok] Refresh token stored for tenant '$tenantValue'." -ForegroundColor Green

    try {
        $null = Update-IPCAccessTokenFromRefresh
        Write-Host "[ok] Access token acquired via BroCI exchange." -ForegroundColor Green
    } catch {
        Write-Warning "Could not acquire access token from refresh token: $_"
        Write-Host "[info] You may need to also store an access token (option 1) or check the refresh token." -ForegroundColor Yellow
    }
}

function Update-IPCAccessTokenFromRefresh {
    <#
    .SYNOPSIS
        Exchanges the stored broker refresh token for a fresh Intune access
        token via the BroCI (Nested App Authentication) flow.
    .DESCRIPTION
        The refresh token belongs to the Azure Portal SPA (c44b4083).
        BroCI exchanges it for an Intune access token (5926fc8e) by passing
        broker parameters and Origin/Referer headers that satisfy the SPA
        cross-origin restriction.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $refreshToken = Get-Secret -Name $script:SECRET_REFRESH -Vault $script:VAULT_NAME -AsPlainText -ErrorAction Stop

    $tenant = 'common'
    try {
        $storedTenant = Get-Secret -Name $script:SECRET_TENANT -Vault $script:VAULT_NAME -AsPlainText -ErrorAction SilentlyContinue
        if ($storedTenant) { $tenant = $storedTenant }
    } catch { }

    $brokerHost = ([System.Uri]$script:BROKER_URL).Host

    $body = @{
        grant_type       = 'refresh_token'
        client_id        = $script:INTUNE_CLIENT_ID
        scope            = 'https://graph.microsoft.com/.default'
        refresh_token    = $refreshToken
        redirect_uri     = "brk-$($script:BROKER_CLIENT_ID)://$brokerHost"
        brk_client_id    = $script:BROKER_CLIENT_ID
        brk_redirect_uri = $script:BROKER_URL
    }

    # Origin + Referer headers
    $headers = @{
        'Content-Type' = 'application/x-www-form-urlencoded'
        Origin          = $script:BROKER_URL.TrimEnd('/')
        Referer         = $script:BROKER_URL
        'User-Agent'    = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0'
    }

    $response = Invoke-RestMethod `
        -Uri "https://login.microsoftonline.com/$tenant/oauth2/v2.0/token" `
        -Method POST -Body $body -Headers $headers -ErrorAction Stop

    $accessToken = $response.access_token
    if (-not $accessToken) {
        $err = Get-IPCFirstValue -InputObject $response -Names @('error') -Default 'unknown_error'
        $desc = Get-IPCFirstValue -InputObject $response -Names @('error_description') -Default ''
        throw "BroCI token exchange failed: $err - $desc"
    }

    $payload = ConvertFrom-JwtPayload -Token $accessToken

    $expiresAt = if ($payload.ContainsKey('exp')) {
        [double]$payload['exp']
    } else {
        [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + (Get-IPCFirstValue -InputObject $response -Names @('expires_in') -Default 3600)
    }

    $metadata = @{ expires_at = $expiresAt } | ConvertTo-Json -Compress

    Set-Secret -Name $script:SECRET_ACCESS -Secret $accessToken -Vault $script:VAULT_NAME -ErrorAction Stop
    Set-Secret -Name $script:SECRET_METADATA -Secret $metadata -Vault $script:VAULT_NAME -ErrorAction Stop

    if ($response.refresh_token) {
        Set-Secret -Name $script:SECRET_REFRESH -Secret $response.refresh_token -Vault $script:VAULT_NAME -ErrorAction Stop
    }

    return $accessToken
}

function Get-IPCValidToken {
    <#
    .SYNOPSIS
        Returns a valid Bearer access token. Auto-refreshes if expired and a
        refresh token is available.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    Initialize-IPCSecretVault

    $accessToken = $null
    try {
        $accessToken = Get-Secret -Name $script:SECRET_ACCESS -Vault $script:VAULT_NAME -AsPlainText -ErrorAction Stop
    } catch { }

    if (-not $accessToken) {
        try {
            return Update-IPCAccessTokenFromRefresh
        } catch {
            throw "No token stored. Use Set-IPCAccessToken or Set-IPCRefreshToken first."
        }
    }

    $metadataJson = $null
    try {
        $metadataJson = Get-Secret -Name $script:SECRET_METADATA -Vault $script:VAULT_NAME -AsPlainText -ErrorAction SilentlyContinue
    } catch { }

    $expiresAt = 0
    if ($metadataJson) {
        $metadata = ConvertFrom-IPCJson -Json $metadataJson
        $expiresAt = [double](Get-IPCFirstValue -InputObject $metadata -Names @('expires_at') -Default 0)
    }

    $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

    if ($now -lt $expiresAt) {
        return (Resolve-AccessToken -RawToken $accessToken)
    }

    try {
        return Update-IPCAccessTokenFromRefresh
    } catch {
        throw "Access token has expired. Paste a fresh token (option 1) or refresh token (option 2)."
    }
}

function Get-IPCTokenInfo {
    <#
    .SYNOPSIS
        Returns human-readable info about the stored token.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    Initialize-IPCSecretVault

    $accessToken = $null
    try {
        $accessToken = Get-Secret -Name $script:SECRET_ACCESS -Vault $script:VAULT_NAME -AsPlainText -ErrorAction Stop
    } catch {
        return $null
    }

    if (-not $accessToken) { return $null }

    $payload = ConvertFrom-JwtPayload -Token $accessToken
    $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

    $metadataJson = $null
    try {
        $metadataJson = Get-Secret -Name $script:SECRET_METADATA -Vault $script:VAULT_NAME -AsPlainText -ErrorAction SilentlyContinue
    } catch { }

    $expiresAt = 0
    if ($metadataJson) {
        $meta = ConvertFrom-IPCJson -Json $metadataJson
        $expiresAt = [double](Get-IPCFirstValue -InputObject $meta -Names @('expires_at') -Default (Get-IPCFirstValue -InputObject $payload -Names @('exp') -Default 0))
    } elseif ($payload.ContainsKey('exp')) {
        $expiresAt = [double]$payload['exp']
    }

    $secondsLeft = $expiresAt - $now
    $expiryUtc = [DateTimeOffset]::FromUnixTimeSeconds([long]$expiresAt).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss UTC')

    $hasRefresh = $false
    try {
        $rt = Get-Secret -Name $script:SECRET_REFRESH -Vault $script:VAULT_NAME -AsPlainText -ErrorAction SilentlyContinue
        $hasRefresh = [bool]$rt
    } catch { }

    return @{
        User         = Get-IPCFirstValue -InputObject $payload -Names @('upn', 'unique_name', 'preferred_username') -Default 'unknown'
        Tenant       = Get-IPCFirstValue -InputObject $payload -Names @('tid') -Default 'unknown'
        ExpiresAt    = $expiryUtc
        ExpiresIn    = if ($secondsLeft -gt 0) { "{0}h {1}m" -f [math]::Floor($secondsLeft / 3600), [math]::Floor(($secondsLeft % 3600) / 60) } else { 'EXPIRED' }
        Expired      = $secondsLeft -le 0
        HasRefresh   = $hasRefresh
    }
}

# ── Graph Client ─────────────────────────────────────────────────────────────

function Invoke-GraphRequest {
    <#
    .SYNOPSIS
        Performs an authenticated request to the Microsoft Graph API.
    #>

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

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

        [hashtable]$Body,

        [hashtable]$QueryParameters
    )

    $token = Get-IPCValidToken

    $uri = $script:GRAPH_BASE_URL.TrimEnd('/') + '/' + $Path.TrimStart('/')

    if ($QueryParameters -and $QueryParameters.Count -gt 0) {
        $qsParts = foreach ($k in $QueryParameters.Keys) {
            "$k=$([System.Uri]::EscapeDataString($QueryParameters[$k]))"
        }
        $uri += '?' + ($qsParts -join '&')
    }

    $headers = @{
        Authorization  = "Bearer $token"
        'Content-Type' = 'application/json'
        Accept         = 'application/json'
    }

    $splat = @{
        Uri     = $uri
        Method  = $Method
        Headers = $headers
    }

    if ($Body) {
        $splat['Body'] = ($Body | ConvertTo-Json -Depth 20 -Compress)
    }

    try {
        $response = Invoke-RestMethod @splat -ErrorAction Stop
        return $response
    } catch {
        $statusCode = $_.Exception.Response.StatusCode.value__
        $errorBody = $_.ErrorDetails.Message
        $msg = $errorBody
        $code = ''
        try {
            $parsed = ConvertFrom-IPCJson -Json $errorBody
            $parsedError = Get-IPCFirstValue -InputObject $parsed -Names @('error')
            $msg = Get-IPCFirstValue -InputObject $parsedError -Names @('message') -Default $errorBody
            $code = Get-IPCFirstValue -InputObject $parsedError -Names @('code') -Default ''
        } catch { }
        throw "Graph API error $statusCode [$code]: $msg"
    }
}

function Invoke-GraphBatch {
    <#
    .SYNOPSIS
        Executes multiple Graph API requests using the JSON batch endpoint.
        Requests are chunked into groups of 20 with throttle retry.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$Requests,

        [scriptblock]$OnChunk
    )

    if ($Requests.Count -eq 0) { return @{} }

    $results = @{}
    $total = $Requests.Count
    $completed = 0

    for ($chunkStart = 0; $chunkStart -lt $total; $chunkStart += $script:BATCH_SIZE) {
        $chunkEnd = [math]::Min($chunkStart + $script:BATCH_SIZE, $total) - 1
        $chunk = $Requests[$chunkStart..$chunkEnd]
        $pending = @{}
        foreach ($req in $chunk) { $pending[$req.id] = $req }

        for ($attempt = 0; $attempt -lt $script:MAX_BATCH_RETRIES; $attempt++) {
            if ($pending.Count -eq 0) { break }

            $batchBody = @{ requests = @($pending.Values) }

            try {
                $envelope = Invoke-GraphRequest -Path '/$batch' -Method POST -Body $batchBody
            } catch {
                if ($_ -match '429' -and $attempt -lt ($script:MAX_BATCH_RETRIES - 1)) {
                    Write-Warning "Batch envelope throttled (429); retrying in $($script:DEFAULT_RETRY_AFTER)s..."
                    Start-Sleep -Seconds $script:DEFAULT_RETRY_AFTER
                    continue
                }
                throw
            }

            $throttledIds = @()
            $maxRetryAfter = 0

            foreach ($item in @(Get-IPCFirstValue -InputObject $envelope -Names @('responses') -Default @())) {
                $itemId = [string]$item.id
                $status = Get-IPCFirstValue -InputObject $item -Names @('status') -Default 200
                $body = Get-IPCFirstValue -InputObject $item -Names @('body') -Default @{}
                $itemHeaders = Get-IPCFirstValue -InputObject $item -Names @('headers') -Default @{}

                if ($status -eq 429) {
                    $retryAfter = [double](Get-IPCFirstValue -InputObject $itemHeaders -Names @('Retry-After', 'retry-after') -Default $script:DEFAULT_RETRY_AFTER)
                    $retryAfter = [math]::Max(1.0, $retryAfter)
                    $maxRetryAfter = [math]::Max($maxRetryAfter, $retryAfter)
                    $throttledIds += $itemId
                } else {
                    $results[$itemId] = @{ status = $status; body = $body }
                    $pending.Remove($itemId)
                }
            }

            if ($throttledIds.Count -gt 0) {
                $newPending = @{}
                foreach ($id in $throttledIds) {
                    if ($pending.ContainsKey($id)) { $newPending[$id] = $pending[$id] }
                }
                $pending = $newPending
                if ($attempt -lt ($script:MAX_BATCH_RETRIES - 1)) {
                    Write-Warning "$($throttledIds.Count) item(s) throttled; retrying in ${maxRetryAfter}s..."
                    Start-Sleep -Seconds $maxRetryAfter
                } else {
                    foreach ($id in $throttledIds) {
                        $results[$id] = @{
                            status = 429
                            body   = @{ error = @{ code = 'TooManyRequests'; message = 'Exceeded retry limit' } }
                        }
                    }
                }
            } else {
                break
            }
        }

        $completed += $chunk.Count
        if ($OnChunk) {
            & $OnChunk ([math]::Min($completed, $total)) $total
        }
    }

    return $results
}

# ── IPC Explorer ─────────────────────────────────────────────────────────────

function Get-IPCManagedDevices {
    <#
    .SYNOPSIS
        Lists managed devices visible to the authenticated user.
    #>

    [CmdletBinding()]
    param(
        [string]$Filter,
        [string[]]$Select,
        [int]$Top = 100
    )

    $params = @{ '$top' = $Top }
    if ($Filter) { $params['$filter'] = $Filter }
    if ($Select) { $params['$select'] = $Select -join ',' }

    $results = @()
    $response = Invoke-GraphRequest -Path '/deviceManagement/managedDevices' -QueryParameters $params
    $results += @(Get-IPCFirstValue -InputObject $response -Names @('value') -Default @())

    $nextLink = $response.'@odata.nextLink'
    while ($nextLink) {
        $response = Invoke-GraphRequest -Path $nextLink
        $results += @(Get-IPCFirstValue -InputObject $response -Names @('value') -Default @())
        $nextLink = $response.'@odata.nextLink'
    }

    return $results
}

function Get-IPCManagedDevice {
    <#
    .SYNOPSIS
        Fetches a single managed device by its Intune device ID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DeviceId
    )

    return Invoke-GraphRequest -Path "/deviceManagement/managedDevices/$DeviceId"
}

function Get-IPCDeviceInventoryCategories {
    <#
    .SYNOPSIS
        Returns the inventory categories available for a device.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DeviceId
    )

    $response = Invoke-GraphRequest -Path "/deviceManagement/managedDevices('$DeviceId')/deviceInventories"
    if ($null -eq $response) { return @() }
    return @(Get-IPCFirstValue -InputObject $response -Names @('value') -Default @())
}

function Get-IPCDeviceInventory {
    <#
    .SYNOPSIS
        Returns cleaned inventory instances for a device and category.
    #>

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

        [Parameter(Mandatory)]
        [string]$Category
    )

    $resolvedCategory = Resolve-IPCInventoryCategory -Category $Category
    $instances = Get-IPCInventoryInstances -DeviceId $DeviceId -Category $resolvedCategory
    $hydrated = foreach ($inst in $instances) {
        Resolve-SimpleInstance -DeviceId $DeviceId -Category $resolvedCategory -Instance $inst
    }

    return @(foreach ($inst in $hydrated) {
        $ht = if ($inst -is [hashtable]) { $inst }
              elseif ($inst -is [System.Collections.IDictionary]) { [hashtable]$inst }
              else {
                  $h = @{}
                  foreach ($p in $inst.PSObject.Properties) { $h[$p.Name] = $p.Value }
                  $h
              }
        ConvertTo-CleanInstance -Instance $ht
    })
}

function Get-IPCSoftwareInventory {
    <#
    .SYNOPSIS
        Returns cleaned software (application) inventory for a device.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DeviceId
    )

    $expandParam = 'instances($expand=Microsoft.Graph.deviceInventorySimpleItem/properties)'
    $response = Invoke-GraphRequest -Path "/deviceManagement/managedDevices('$DeviceId')/deviceInventories('ApplicationProperties')" `
        -QueryParameters @{ '$expand' = $expandParam }

    if ($null -eq $response) { return @() }
    $instances = @(Get-IPCFirstValue -InputObject $response -Names @('instances') -Default @())

    return @(foreach ($inst in $instances) {
        $ht = if ($inst -is [hashtable]) { $inst }
              elseif ($inst -is [System.Collections.IDictionary]) { [hashtable]$inst }
              else {
                  $h = @{}
                  foreach ($p in $inst.PSObject.Properties) { $h[$p.Name] = $p.Value }
                  $h
              }
        ConvertTo-CleanInstance -Instance $ht
    })
}

function Get-IPCInventoryBatch {
    <#
    .SYNOPSIS
        Fetches inventory for multiple devices and categories using Graph batching.
    #>

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

        [Parameter(Mandatory)]
        [string[]]$Categories,

        [scriptblock]$OnChunk
    )

    if ($DeviceIds.Count -eq 0 -or $Categories.Count -eq 0) { return @{} }

    $resolvedCategories = @($Categories |
        ForEach-Object { Resolve-IPCInventoryCategory -Category $_ } |
        Where-Object { $_ -and $_ -ine 'all' } |
        Select-Object -Unique)
    if ($resolvedCategories.Count -eq 0) { return @{} }

    $expandParam = 'instances($expand=Microsoft.Graph.deviceInventorySimpleItem/properties)'

    $requests = @()
    foreach ($deviceId in $DeviceIds) {
        foreach ($category in $resolvedCategories) {
            $requests += @{
                id     = "$deviceId||$category"
                method = 'GET'
                url    = "/deviceManagement/managedDevices('$deviceId')/deviceInventories('$category')?`$expand=$expandParam"
            }
        }
    }

    $batchResults = Invoke-GraphBatch -Requests $requests -OnChunk $OnChunk

    $raw = @{}
    $hydrationRequests = @()

    foreach ($compositeId in $batchResults.Keys) {
        if ($compositeId -notmatch '\|\|') { continue }
        $parts = $compositeId.Split('||', 2)
        $deviceId = $parts[0]
        $category = $parts[1]
        $result = $batchResults[$compositeId]
        $status = $result.status
        $body = $result.body

        if ($status -in 400, 404) { continue }
        if ($status -lt 200 -or $status -ge 300) {
            Write-Warning "Inventory $deviceId/$category : unexpected status $status"
            continue
        }

        $instances = @(Get-IPCFirstValue -InputObject $body -Names @('instances') -Default @())
        if (-not $raw.ContainsKey($deviceId)) { $raw[$deviceId] = @{} }
        $raw[$deviceId][$category] = $instances

        foreach ($inst in $instances) {
            $ht = if ($inst -is [hashtable]) { $inst }
                  else {
                      $h = @{}
                      foreach ($p in $inst.PSObject.Properties) { $h[$p.Name] = $p.Value }
                      $h
                  }
            $nonMeta = @($ht.Keys | Where-Object { -not $_.StartsWith('@') })
            if ($nonMeta.Count -eq 1 -and $nonMeta[0] -eq 'id' -and $ht['id'] -is [string]) {
                $encodedId = [System.Uri]::EscapeDataString($ht['id'])
                $hydrationRequests += @{
                    id     = "$deviceId||$category||$($ht['id'])"
                    method = 'GET'
                    url    = "/deviceManagement/managedDevices('$deviceId')/deviceInventories('$category')/instances('$encodedId')"
                }
            }
        }
    }

    if ($hydrationRequests.Count -gt 0) {
        $hydrationResults = Invoke-GraphBatch -Requests $hydrationRequests
        foreach ($compositeId in $hydrationResults.Keys) {
            $parts = $compositeId.Split('||', 3)
            if ($parts.Count -ne 3) { continue }
            $deviceId = $parts[0]
            $category = $parts[1]
            $instId = $parts[2]
            $result = $hydrationResults[$compositeId]
            if ($result.status -lt 200 -or $result.status -ge 300) { continue }
            $body = $result.body
            if (-not $body -or -not $body.id) { continue }

            $instances = $raw[$deviceId][$category]
            for ($i = 0; $i -lt $instances.Count; $i++) {
                $inst = $instances[$i]
                $id = if ($inst -is [hashtable]) { $inst['id'] } else { $inst.id }
                if ($id -eq $instId) {
                    $instances[$i] = $body
                    break
                }
            }
        }
    }

    $output = @{}
    foreach ($deviceId in $raw.Keys) {
        $output[$deviceId] = @{}
        foreach ($cat in $raw[$deviceId].Keys) {
            $output[$deviceId][$cat] = @(foreach ($inst in $raw[$deviceId][$cat]) {
                $ht = if ($inst -is [hashtable]) { $inst }
                      elseif ($inst -is [System.Collections.IDictionary]) { [hashtable]$inst }
                      else {
                          $h = @{}
                          foreach ($p in $inst.PSObject.Properties) { $h[$p.Name] = $p.Value }
                          $h
                      }
                ConvertTo-CleanInstance -Instance $ht
            })
        }
    }

    return $output
}

function Get-IPCSoftwareInventoryBatch {
    <#
    .SYNOPSIS
        Fetches software inventory for multiple devices using Graph batching.
    #>

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

        [scriptblock]$OnChunk
    )

    if ($DeviceIds.Count -eq 0) { return @{} }

    $expandParam = 'instances($expand=Microsoft.Graph.deviceInventorySimpleItem/properties)'

    $requests = @(foreach ($deviceId in $DeviceIds) {
        @{
            id     = $deviceId
            method = 'GET'
            url    = "/deviceManagement/managedDevices('$deviceId')/deviceInventories('ApplicationProperties')?`$expand=$expandParam"
        }
    })

    $batchResults = Invoke-GraphBatch -Requests $requests -OnChunk $OnChunk

    $output = @{}
    foreach ($deviceId in $batchResults.Keys) {
        $result = $batchResults[$deviceId]
        $status = $result.status
        $body = $result.body

        if ($status -in 400, 404) { continue }
        if ($status -lt 200 -or $status -ge 300) {
            Write-Warning "Software inventory $deviceId : unexpected status $status"
            continue
        }

        $instances = @(Get-IPCFirstValue -InputObject $body -Names @('instances') -Default @())
        $output[$deviceId] = @(foreach ($inst in $instances) {
            $ht = if ($inst -is [hashtable]) { $inst }
                  elseif ($inst -is [System.Collections.IDictionary]) { [hashtable]$inst }
                  else {
                      $h = @{}
                      foreach ($p in $inst.PSObject.Properties) { $h[$p.Name] = $p.Value }
                      $h
                  }
            ConvertTo-CleanInstance -Instance $ht
        })
    }

    return $output
}

# ── Internal helpers ─────────────────────────────────────────────────────────

function Get-IPCInventoryInstances {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$DeviceId,
        [Parameter(Mandatory)][string]$Category
    )

    $resolvedCategory = Resolve-IPCInventoryCategory -Category $Category

    try {
        $response = Invoke-GraphRequest -Path "/deviceManagement/managedDevices('$DeviceId')/deviceInventories('$resolvedCategory')/instances"
        if ($null -ne $response -and $response.value -is [array]) {
            return @($response.value)
        }
    } catch {
        if ($_ -notmatch '40[04]') { throw }
    }

    $expandFull = 'instances($expand=Microsoft.Graph.deviceInventorySimpleItem/properties)'
    try {
        $fallback = Invoke-GraphRequest -Path "/deviceManagement/managedDevices('$DeviceId')/deviceInventories('$resolvedCategory')" `
            -QueryParameters @{ '$expand' = $expandFull }
    } catch {
        $expandSimple = 'instances'
        $fallback = Invoke-GraphRequest -Path "/deviceManagement/managedDevices('$DeviceId')/deviceInventories('$resolvedCategory')" `
            -QueryParameters @{ '$expand' = $expandSimple }
    }

    if ($null -eq $fallback) { return @() }
    return @(Get-IPCFirstValue -InputObject $fallback -Names @('instances') -Default @())
}

function Resolve-SimpleInstance {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$DeviceId,
        [Parameter(Mandatory)][string]$Category,
        [Parameter(Mandatory)]$Instance
    )

    $resolvedCategory = Resolve-IPCInventoryCategory -Category $Category

    $ht = if ($Instance -is [hashtable]) { $Instance }
          elseif ($Instance -is [System.Collections.IDictionary]) { [hashtable]$Instance }
          else {
              $h = @{}
              foreach ($p in $Instance.PSObject.Properties) { $h[$p.Name] = $p.Value }
              $h
          }

    $nonMeta = @($ht.Keys | Where-Object { -not $_.StartsWith('@') })
    if ($nonMeta.Count -ne 1 -or $nonMeta[0] -ne 'id') { return $Instance }

    $instanceId = $ht['id']
    if ($instanceId -isnot [string] -or -not $instanceId) { return $Instance }

    try {
        $encodedId = [System.Uri]::EscapeDataString($instanceId)
        $detail = Invoke-GraphRequest -Path "/deviceManagement/managedDevices('$DeviceId')/deviceInventories('$resolvedCategory')/instances('$encodedId')"

        if ($null -ne $detail) {
            $detailHt = if ($detail -is [hashtable]) { $detail }
                        else {
                            $h = @{}
                            foreach ($p in $detail.PSObject.Properties) { $h[$p.Name] = $p.Value }
                            $h
                        }
            $hasExtra = @($detailHt.Keys | Where-Object { -not $_.StartsWith('@') -and $_ -ne 'id' })
            if ($detailHt.ContainsKey('id') -and $hasExtra.Count -gt 0) {
                return $detail
            }
        }
    } catch { }

    return $Instance
}

# ── High-level skill interface ────────────────────────────────────────────────

function Invoke-IPC {
    <#
    .SYNOPSIS
        Single entry-point for AI agents and scripts. Combines device lookup
        and inventory retrieval in one call.

    .DESCRIPTION
        Resolves devices by partial name, GUID, or all Windows devices, then
        fetches the requested inventory type (hardware categories, software,
        or device list). Returns structured objects ready for display or
        further processing.

    .PARAMETER Action
        What to retrieve:
          ListDevices - Search/list managed devices
          HardwareInventory - Fetch hardware inventory categories
          SoftwareInventory - Fetch installed applications
          ListCategories - List available inventory categories for a device

    .PARAMETER DeviceName
        Partial device name to search for. Mutually exclusive with DeviceId
        and AllDevices.

    .PARAMETER DeviceId
        Exact Intune device GUID. Mutually exclusive with DeviceName and
        AllDevices.

    .PARAMETER AllDevices
        Target all Windows managed devices in the tenant.

    .PARAMETER Category
        One or more hardware inventory category IDs (e.g. 'bios', 'battery',
        'diskDrive'). Use 'all' to fetch every available category. Only
        applies to HardwareInventory action.

    .PARAMETER Filter
        Text filter applied to results. For SoftwareInventory, filters
        application names/properties containing this text. For
        HardwareInventory, filters instance property values.

    .PARAMETER Top
        Maximum number of devices to return when searching (default 100).

    .EXAMPLE
        # "Show me all MSI software on computer1"
        Invoke-IPC -Action SoftwareInventory -DeviceName 'computer1' -Filter 'msi'

    .EXAMPLE
        # "Check bios information for all devices"
        Invoke-IPC -Action HardwareInventory -AllDevices -Category 'bios'

    .EXAMPLE
        # "Full software inventory of computer2"
        Invoke-IPC -Action SoftwareInventory -DeviceName 'computer2'

    .EXAMPLE
        # "List all devices matching LAPTOP"
        Invoke-IPC -Action ListDevices -DeviceName 'LAPTOP'

    .EXAMPLE
        # "What inventory categories are available for this device?"
        Invoke-IPC -Action ListCategories -DeviceId '1904d94c-d00f-42ea-abb5-e05673c61ff2'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('ListDevices', 'HardwareInventory', 'SoftwareInventory', 'ListCategories')]
        [string]$Action,

        [string]$DeviceName,

        [string]$DeviceId,

        [switch]$AllDevices,

        [string[]]$Category,

        [string]$Filter,

        [int]$Top = 100
    )

    # Validate parameter combinations
    $deviceSelectors = @(
        $(if ($DeviceName) { 1 } else { 0 }),
        $(if ($DeviceId) { 1 } else { 0 }),
        $(if ($AllDevices) { 1 } else { 0 })
    )
    if (($deviceSelectors | Measure-Object -Sum).Sum -gt 1) {
        throw 'Specify only one of -DeviceName, -DeviceId, or -AllDevices.'
    }
    if (($deviceSelectors | Measure-Object -Sum).Sum -eq 0 -and $Action -ne 'ListDevices') {
        throw 'Specify -DeviceName, -DeviceId, or -AllDevices to identify target device(s).'
    }

    # Resolve devices──────
    $devices = @()
    $winFilter = "operatingSystem eq 'Windows'"

    if ($DeviceId) {
        $devices = @(Get-IPCManagedDevice -DeviceId $DeviceId)
    } elseif ($AllDevices) {
        $devices = @(Get-IPCManagedDevices -Filter $winFilter -Top $Top `
            -Select @('id', 'deviceName', 'operatingSystem', 'complianceState'))
    } elseif ($DeviceName) {
        $devices = @(Get-IPCManagedDevices `
            -Filter "startswith(deviceName,'$DeviceName') and $winFilter" `
            -Select @('id', 'deviceName', 'operatingSystem', 'complianceState') `
            -Top $Top)

        if ($devices.Count -eq 0) {
            $all = @(Get-IPCManagedDevices -Filter $winFilter -Top $Top `
                -Select @('id', 'deviceName', 'operatingSystem', 'complianceState'))
            $devices = @($all | Where-Object { $_.deviceName -like "*$DeviceName*" })
        }
    } elseif ($Action -eq 'ListDevices') {
        $devices = @(Get-IPCManagedDevices -Filter $winFilter -Top $Top `
            -Select @('id', 'deviceName', 'operatingSystem', 'complianceState'))
    }

    switch ($Action) {

        'ListDevices' {
            $result = @($devices | ForEach-Object {
                @{
                    DeviceId        = Get-IPCFirstValue -InputObject $_ -Names @('id', 'deviceId') -Default ''
                    DeviceName      = Get-IPCFirstValue -InputObject $_ -Names @('deviceName') -Default ''
                    OperatingSystem = Get-IPCFirstValue -InputObject $_ -Names @('operatingSystem') -Default ''
                    ComplianceState = Get-IPCFirstValue -InputObject $_ -Names @('complianceState') -Default ''
                }
            })
            if ($Filter) {
                $result = @($result | Where-Object {
                    ($_.Values -join ' ') -match [regex]::Escape($Filter)
                })
            }
            return @{
                Action      = 'ListDevices'
                DeviceCount = $result.Count
                Devices     = $result
            }
        }

        'ListCategories' {
            if ($devices.Count -eq 0) {
                return @{ Action = 'ListCategories'; DeviceCount = 0; Categories = @() }
            }
            $firstId = Get-IPCFirstValue -InputObject $devices[0] -Names @('id', 'deviceId') -Default ''
            $cats = Get-IPCDeviceInventoryCategories -DeviceId $firstId
            $catIds = @($cats | ForEach-Object { Get-IPCFirstValue -InputObject $_ -Names @('id', 'inventoryId') -Default '' } | Where-Object { $_ })
            return @{
                Action      = 'ListCategories'
                DeviceId    = $firstId
                DeviceName  = Get-IPCFirstValue -InputObject $devices[0] -Names @('deviceName') -Default $firstId
                Categories  = $catIds
            }
        }

        'HardwareInventory' {
            if ($devices.Count -eq 0) {
                return @{ Action = 'HardwareInventory'; DeviceCount = 0; Results = @{} }
            }

            $deviceIdToName = @{}
            foreach ($d in $devices) {
                $did = Get-IPCFirstValue -InputObject $d -Names @('id', 'deviceId') -Default ''
                $deviceIdToName[$did] = Get-IPCFirstValue -InputObject $d -Names @('deviceName') -Default $did
            }
            $deviceIds = @($deviceIdToName.Keys)

            $selectedCats = @()
            if (-not $Category -or $Category -contains 'all') {
                $firstId = $deviceIds[0]
                $available = Get-IPCDeviceInventoryCategories -DeviceId $firstId
                $selectedCats = @($available | ForEach-Object { Get-IPCFirstValue -InputObject $_ -Names @('id', 'inventoryId') -Default '' } | Where-Object { $_ })
            } else {
                $availableCatIds = @()
                if ($deviceIds.Count -gt 0) {
                    $firstId = $deviceIds[0]
                    $available = Get-IPCDeviceInventoryCategories -DeviceId $firstId
                    $availableCatIds = @($available | ForEach-Object { Get-IPCFirstValue -InputObject $_ -Names @('id', 'inventoryId') -Default '' } | Where-Object { $_ })
                }
                $selectedCats = @($Category |
                    ForEach-Object { Resolve-IPCInventoryCategory -Category $_ -AvailableCategories $availableCatIds } |
                    Where-Object { $_ } |
                    Select-Object -Unique)
            }

            if ($selectedCats.Count -eq 0) {
                return @{ Action = 'HardwareInventory'; DeviceCount = $devices.Count; Results = @{} }
            }

            $batchResult = Get-IPCInventoryBatch -DeviceIds $deviceIds -Categories $selectedCats

            $output = @{}
            foreach ($deviceId in $batchResult.Keys) {
                $name = Get-IPCFirstValue -InputObject $deviceIdToName -Names @($deviceId) -Default $deviceId
                $output[$name] = $batchResult[$deviceId]
            }

            if ($Filter) {
                $filtered = @{}
                foreach ($name in $output.Keys) {
                    $filteredCats = @{}
                    foreach ($cat in $output[$name].Keys) {
                        $filteredInstances = @($output[$name][$cat] | Where-Object {
                            ($_.Values -join ' ') -match [regex]::Escape($Filter)
                        })
                        if ($filteredInstances.Count -gt 0) {
                            $filteredCats[$cat] = $filteredInstances
                        }
                    }
                    if ($filteredCats.Count -gt 0) {
                        $filtered[$name] = $filteredCats
                    }
                }
                $output = $filtered
            }

            $unwrapped = if ($output.Count -eq 1) { $output.Values | Select-Object -First 1 } else { $output }

            return @{
                Action      = 'HardwareInventory'
                DeviceCount = $output.Count
                Categories  = $selectedCats
                Results     = $unwrapped
            }
        }

        'SoftwareInventory' {
            if ($devices.Count -eq 0) {
                return @{ Action = 'SoftwareInventory'; DeviceCount = 0; Results = @{} }
            }

            $deviceIdToName = @{}
            foreach ($d in $devices) {
                $did = Get-IPCFirstValue -InputObject $d -Names @('id', 'deviceId') -Default ''
                $deviceIdToName[$did] = Get-IPCFirstValue -InputObject $d -Names @('deviceName') -Default $did
            }
            $deviceIds = @($deviceIdToName.Keys)

            $batchResult = Get-IPCSoftwareInventoryBatch -DeviceIds $deviceIds

            $output = @{}
            foreach ($deviceId in $batchResult.Keys) {
                $name = Get-IPCFirstValue -InputObject $deviceIdToName -Names @($deviceId) -Default $deviceId
                $apps = @($batchResult[$deviceId])

                if ($Filter) {
                    $apps = @($apps | Where-Object {
                        ($_.Values -join ' ') -match [regex]::Escape($Filter)
                    })
                }

                $output[$name] = $apps
            }

            $unwrapped = if ($output.Count -eq 1) { $output.Values | Select-Object -First 1 } else { $output }
            $totalApps = if ($unwrapped -is [array]) { $unwrapped.Count } else {
                $s = 0; foreach ($v in $unwrapped.Values) { if ($v -is [array]) { $s += $v.Count } }; $s
            }

            return @{
                Action          = 'SoftwareInventory'
                DeviceCount     = $output.Count
                ApplicationCount = $totalApps
                Results         = $unwrapped
            }
        }
    }
}

# ── Token Cleanup ────────────────────────────────────────────────────────────

function Clear-IPCTokens {
    <#
    .SYNOPSIS
        Removes all stored tokens (access, refresh, metadata, tenant) from the vault.
    #>

    [CmdletBinding()]
    param()

    Initialize-IPCSecretVault

    foreach ($name in @($script:SECRET_ACCESS, $script:SECRET_REFRESH, $script:SECRET_METADATA, $script:SECRET_TENANT)) {
        try { Remove-Secret -Name $name -Vault $script:VAULT_NAME -ErrorAction Ignore } catch { }
    }
}

# ── Exported functions ───────────────────────────────────────────────────────

Export-ModuleMember -Function @(
    'Initialize-IPCSecretVault'
    'Unlock-IPCVault'
    'ConvertFrom-JwtPayload'
    'Resolve-AccessToken'
    'ConvertTo-FriendlyName'
    'ConvertTo-CleanInstance'
    'Set-IPCAccessToken'
    'Set-IPCRefreshToken'
    'Update-IPCAccessTokenFromRefresh'
    'Get-IPCValidToken'
    'Get-IPCTokenInfo'
    'Clear-IPCTokens'
    'Invoke-GraphRequest'
    'Invoke-GraphBatch'
    'Invoke-IPC'
    'Get-IPCManagedDevices'
    'Get-IPCManagedDevice'
    'Get-IPCDeviceInventoryCategories'
    'Get-IPCDeviceInventory'
    'Get-IPCSoftwareInventory'
    'Get-IPCInventoryBatch'
    'Get-IPCSoftwareInventoryBatch'
)