Get-PowerApp.psm1

#Requires -Version 5.1

# Get-PowerApp Module
# Provides caching and management for Power Platform apps retrieval

#region Private Functions

function Get-CacheFilePath
{
  [CmdletBinding()]
  param()

  $cacheDir = Join-Path $HOME '.powerapps'
  if (-not (Test-Path $cacheDir))
  {
    New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
  }

  return Join-Path $cacheDir 'cache.json'
}

function Get-CacheData
{
  [CmdletBinding()]
  param()

  $cacheFile = Get-CacheFilePath

  if (Test-Path $cacheFile)
  {
    try
    {
      $content = Get-Content $cacheFile -Raw -ErrorAction Stop
      $jsonData = ($content | ConvertFrom-Json)

      # Convert PSCustomObject to hashtables for dynamic property support
      $cache = @{
        tokens = @{}
        environments = @{}
      }

      if ($jsonData.tokens)
      {
        foreach ($prop in $jsonData.tokens.PSObject.Properties)
        {
          $cache.tokens[$prop.Name] = $prop.Value
        }
      }

      if ($jsonData.environments)
      {
        foreach ($prop in $jsonData.environments.PSObject.Properties)
        {
          $cache.environments[$prop.Name] = $prop.Value
        }
      }

      return $cache
    } catch
    {
      Write-Warning "Failed to read cache file: $($_.Exception.Message)"
      return @{ tokens = @{}; environments = @{} }
    }
  }

  return @{ tokens = @{}; environments = @{} }
}

function Set-CacheData
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [object]$CacheData
  )

  $cacheFile = Get-CacheFilePath

  try
  {
    $CacheData | ConvertTo-Json -Depth 10 | Set-Content $cacheFile -ErrorAction Stop
  } catch
  {
    Write-Warning "Failed to write cache file: $($_.Exception.Message)"
  }
}

function Get-TokenClaims
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Token
  )
    
  try
  {
    $claims = ($Token.Split('.')[1].Replace('-','+').Replace('_','/'))
    while($claims.Length % 4)
    {
      $claims += '='
    }
        
    $claimsJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($claims)) | ConvertFrom-Json
        
    return @{
      oid = $claimsJson.oid
      tenantId = $claimsJson.tid
      expiry = [DateTimeOffset]::FromUnixTimeSeconds($claimsJson.exp).DateTime.ToUniversalTime().ToString('o')
      name = $claimsJson.Name
    }
  } catch
  {
    Write-Warning "Failed to extract claims from token: $($_.Exception.Message)"
    return @{
      oid = $null
      tenantId = $null
      expiry = [DateTime]::UtcNow.AddHours(1).ToString('o')
    }
  }
}

function Test-TokenExpiry
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$ExpiresAt
  )

  try
  {
    $expiryTime = [DateTime]::Parse($ExpiresAt).ToUniversalTime()
    $currentTime = [DateTime]::UtcNow
    $bufferTime = $currentTime.AddMinutes(5) # 5-minute buffer

    return $expiryTime -le $bufferTime
  } catch
  {
    Write-Warning "Failed to parse token expiry time: $ExpiresAt"
    return $true # Assume expired if we can't parse
  }
}

function Get-TokenFromCache
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Oid
  )

  $cache = Get-CacheData

  if ($cache.tokens -and $cache.tokens[$Oid])
  {
    $tokenData = $cache.tokens[$Oid]

    if (-not (Test-TokenExpiry -ExpiresAt $tokenData.expiresAt))
    {
      return $tokenData
    } else
    {
      Write-Verbose "Cached token for user $Oid has expired"
    }
  }

  return $null
}

function Set-TokenInCache
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Oid,
    [Parameter(Mandatory = $true)]
    [string]$AccessToken,
    [Parameter(Mandatory = $true)]
    [string]$ExpiresAt,
    [Parameter(Mandatory = $false)]
    [string]$LoginHint,
    [Parameter(Mandatory = $true)]
    [string]$TenantId,
    [Parameter(Mandatory = $false)]
    [string]$Name
  )

  $cache = Get-CacheData

  if (-not $cache.tokens)
  {
    $cache.tokens = @{}
  }

  $cache.tokens[$Oid] = @{
    accessToken = $AccessToken
    expiresAt = $ExpiresAt
    loginHint = $LoginHint
    tenantId = $TenantId
    lastUsed = [DateTime]::UtcNow.ToString('o')
    name = $Name
  }

  Set-CacheData -CacheData $cache
}

function Get-EnvironmentsFromCache
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Oid
  )

  $cache = Get-CacheData

  if ($cache.environments -and $cache.environments[$Oid])
  {
    $envData = $cache.environments[$Oid]

    # Check if cache is less than 24 hours old
    if ($envData.lastUpdated)
    {
      try
      {
        $lastUpdated = [DateTime]::Parse($envData.lastUpdated).ToUniversalTime()
        $cacheAge = [DateTime]::UtcNow - $lastUpdated

        if ($cacheAge.TotalHours -lt 24)
        {
          return $envData
        } else
        {
          Write-Verbose "Environment cache for user $Oid is stale (age: $($cacheAge.TotalHours.ToString('F1')) hours)"
        }
      } catch
      {
        Write-Warning "Failed to parse environment cache timestamp: $($envData.lastUpdated)"
      }
    }
  }

  return $null
}

function Set-EnvironmentsInCache
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Oid,
    [Parameter(Mandatory = $true)]
    [array]$Environments,
    [Parameter(Mandatory = $false)]
    [string]$LastSelected
  )

  $cache = Get-CacheData

  if (-not $cache.environments)
  {
    $cache.environments = @{}
  }

  $envData = @{
    lastUpdated = [DateTime]::UtcNow.ToString('o')
    environments = $Environments
  }

  if ($LastSelected)
  {
    $envData.lastSelected = $LastSelected
  } elseif ($cache.environments[$Oid] -and $cache.environments[$Oid].lastSelected)
  {
    # Preserve existing last selected
    $envData.lastSelected = $cache.environments[$Oid].lastSelected
  }

  $cache.environments[$Oid] = $envData
  Set-CacheData -CacheData $cache
}

function Get-PpapiUrl
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$BaseGuid,
    [Parameter(Mandatory = $false)]
    [string]$Kind = "environment"
  )

  $processedGuid = $BaseGuid.Replace("-", "")
  $formattedGuid = $processedGuid.Substring(0, $processedGuid.Length - 1) + "." + $processedGuid.Substring($processedGuid.Length - 1)

  return "https://$formattedGuid.$Kind.api.test.powerplatform.com"
}

function Build-QueryString
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [hashtable]$QueryParams
  )

  return ($QueryParams.GetEnumerator() | ForEach-Object {
      [System.Web.HttpUtility]::UrlEncode($_.Key) + '=' + [System.Web.HttpUtility]::UrlEncode($_.Value)
    }) -join '&'
}

function Show-CachedUserMenu
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [object]$CacheData
  )

  $users = @()

  foreach ($oid in $CacheData.tokens.Keys)
  {
    $tokenData = $CacheData.tokens[$oid]
    $users += @{
      oid = $oid
      loginHint = $tokenData.loginHint
      lastUsed = $tokenData.lastUsed
      name = $tokenData.name
    }
  }

  # Sort by last used (most recent first)
  $users = @($users | Sort-Object { [DateTime]::Parse($_.lastUsed) } -Descending)

  Write-Host "`n=== Cached Users ===" -ForegroundColor Cyan
  Write-Host ""

  for ($i = 0; $i -lt $users.Count; $i++)
  {
    $user = $users[$i]
    $displayName = if ($user.loginHint)
    { 
      $user.loginHint 
    } elseif($user.name)
    { "$($user.name) ($($user.oid.Substring(0,8))...)" 
    } else
    { "User ($($user.oid.Substring(0,8))...)" 
    }

    Write-Host "[$i] $displayName" -ForegroundColor Green
    if ($user.lastUsed)
    {
      $lastUsedDate = [DateTime]::Parse($user.lastUsed).ToLocalTime().ToString('yyyy-MM-dd HH:mm')
      Write-Host " Last used: $lastUsedDate" -ForegroundColor Gray
    }
    Write-Host ""
  }

  Write-Host "[$($users.Count)] Authenticate as new user" -ForegroundColor Yellow
  Write-Host ""

  do
  {
    $selection = Read-Host "Please select a user by index (0-$($users.Count))"

    if ($selection -match '^\d+$' -and [int]$selection -ge 0 -and [int]$selection -le $users.Count)
    {
      $selectedIndex = [int]$selection
      $valid = $true
    } else
    {
      Write-Host "Invalid selection. Please enter a number between 0 and $($users.Count)." -ForegroundColor Red
      $valid = $false
    }
  } while (-not $valid)

  if ($selectedIndex -eq $users.Count)
  {
    return $null # New user authentication
  }

  return $users[$selectedIndex].oid
}

function Show-EnvironmentMenu
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [array]$Environments,
    [Parameter(Mandatory = $false)]
    [string]$LastSelected
  )

  Write-Host "`n=== Available Environments ===" -ForegroundColor Cyan
  Write-Host ""

  $defaultIndex = -1
  for ($i = 0; $i -lt $Environments.Count; $i++)
  {
    $env = $Environments[$i]
    $displayName = $env.properties.displayName
    $envId = $env.name

    $prefix = "[$i]"
    if ($LastSelected -and $envId -eq $LastSelected)
    {
      $prefix = "[$i] *"
      $defaultIndex = $i
    }

    Write-Host "$prefix $displayName" -ForegroundColor Green
    Write-Host " Environment ID: $envId" -ForegroundColor Gray
    Write-Host ""
  }

  if ($defaultIndex -ge 0)
  {
    Write-Host "* = Last selected environment" -ForegroundColor Yellow
    Write-Host ""
  }

  do
  {
    $prompt = "Please select an environment by index (0-$($Environments.Count - 1))"
    if ($defaultIndex -ge 0)
    {
      $prompt += " [default: $defaultIndex]"
    }

    $selection = Read-Host $prompt

    # Handle default selection
    if ([string]::IsNullOrWhiteSpace($selection) -and $defaultIndex -ge 0)
    {
      $selection = $defaultIndex.ToString()
    }

    if ($selection -match '^\d+$' -and [int]$selection -ge 0 -and [int]$selection -lt $Environments.Count)
    {
      $selectedIndex = [int]$selection
      $valid = $true
    } else
    {
      Write-Host "Invalid selection. Please enter a number between 0 and $($Environments.Count - 1)." -ForegroundColor Red
      $valid = $false
    }
  } while (-not $valid)

  $selectedEnvironment = $Environments[$selectedIndex]
  $selectedEnvId = $selectedEnvironment.name
  $selectedDisplayName = $selectedEnvironment.properties.displayName

  Write-Host "`n=== Selection Confirmed ===" -ForegroundColor Green
  Write-Host "Selected: $selectedDisplayName" -ForegroundColor Yellow
  Write-Host "Environment ID: $selectedEnvId" -ForegroundColor Yellow
  Write-Host ""

  return $selectedEnvId
}

function Get-PowerAppsFromEnvironment
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$EnvironmentId,

    [Parameter(Mandatory = $true)]
    [string]$AccessToken
  )

  try
  {
    $rpBaseUrl = Get-PpapiUrl -BaseGuid $EnvironmentId

    $appsQueryParams = @{
      'api-version' = '1'
      '$expand' = 'unpublishedAppDefinition'
    }

    $appsQueryString = Build-QueryString -QueryParams $appsQueryParams
    $appsUrl = "$rpBaseUrl/powerapps/apps?$appsQueryString"

    $rpReqId = [System.Guid]::NewGuid()
    $rpCorrId = [System.Guid]::NewGuid()

    Write-Verbose "Retrieving Power Apps from: $appsUrl"

    $response = Invoke-WebRequest -UseBasicParsing -Uri $appsUrl -Headers @{
      "authorization" = "Bearer $AccessToken"
      "x-ms-client-request-id" = $rpReqId
      "x-ms-correlation-id" = $rpCorrId
    }

    return $response.Content
  } catch
  {
    Write-Error "Failed to retrieve Power Apps: $($_.Exception.Message)"
    return $null
  }
}

#endregion

#region Public Functions

function Get-PowerApp
{
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [string]$LoginHint,

    [Parameter(Mandatory = $false)]
    [switch]$IgnoreTokenCache,

    [Parameter(Mandatory = $false)]
    [switch]$IgnoreEnvironmentCache,

    [Parameter(Mandatory = $false)]
    [string]$EnvironmentId
  )

  Write-Verbose "Starting Get-PowerApp"

  # Step 1: Handle authentication and token retrieval
  $accessToken = $null
  $oid = $null
  $tenantId = $null

  if (-not $IgnoreTokenCache)
  {
    $cache = Get-CacheData

    if ($LoginHint)
    {
      # Find cached user by login hint
      foreach ($cachedOid in $cache.tokens.Keys)
      {
        $tokenData = $cache.tokens[$cachedOid]
        if ($tokenData.loginHint -eq $LoginHint -or $tokenData.name -eq $LoginHint)
        {
          $cachedToken = Get-TokenFromCache -Oid $cachedOid
          if ($cachedToken)
          {
            Write-Verbose "Using cached token for $LoginHint"
            $accessToken = $cachedToken.accessToken
            $oid = $cachedOid
            $tenantId = $cachedToken.tenantId
            break
          }
        }
      }
    } else
    {
      # Show user selection menu if we have cached users
      if ($cache.tokens -and $cache.tokens.Count -gt 0)
      {
        $selectedOid = Show-CachedUserMenu -CacheData $cache

        if ($selectedOid)
        {
          $cachedToken = Get-TokenFromCache -Oid $selectedOid
          if ($cachedToken)
          {
            Write-Verbose "Using cached token for selected user"
            $accessToken = $cachedToken.accessToken
            $oid = $selectedOid
            $tenantId = $cachedToken.tenantId
            $LoginHint = $cachedToken.loginHint
          }
        }
      }
    }
  }

  # If no cached token found, authenticate
  if (-not $accessToken)
  {
    Write-Host "Authenticating..." -ForegroundColor Yellow

    try
    {
      if ($LoginHint)
      {
        $tokenResponse = Get-AzureToken -Scopes "https://api.test.powerplatform.com/.default" -LoginHint $LoginHint
      } else
      {
        $tokenResponse = Get-AzureToken -Scopes "https://api.test.powerplatform.com/.default"
      }

      $accessToken = $tokenResponse.AccessToken
      $claims = Get-TokenClaims $accessToken
      $oid = $claims.oid
      $tenantId = $claims.tenantId
      $expiry = $claims.expiry
      $name = $claims.name

      if ($oid -and $tenantId)
      {
        # Cache the new token
        Set-TokenInCache -Oid $oid -AccessToken $accessToken -ExpiresAt $expiry -LoginHint $LoginHint -TenantId $tenantId -Name $name
        Write-Verbose "Cached new token for user $oid"
      }
    } catch
    {
      Write-Error "Failed to authenticate: $($_.Exception.Message)"
      return
    }
  }

  if (-not $accessToken -or -not $oid -or -not $tenantId)
  {
    Write-Error "Failed to obtain required authentication information"
    return
  }

  # Step 2: Handle environment retrieval
  $environments = $null
  $lastSelected = $null

  if (-not $IgnoreEnvironmentCache)
  {
    $cachedEnvData = Get-EnvironmentsFromCache -Oid $oid
    if ($cachedEnvData)
    {
      Write-Verbose "Using cached environment data"
      $environments = $cachedEnvData.environments
      $lastSelected = $cachedEnvData.lastSelected
    }
  }

  # If no cached environments or ignoring cache, fetch from API
  if (-not $environments)
  {
    Write-Host "Retrieving environments..." -ForegroundColor Yellow

    try
    {
      $baseUrl = Get-PpapiUrl -BaseGuid $tenantId -Kind "tenant"

      $queryParams = @{
        '$expand' = 'properties.permissions'
        'api-version' = '1'
        '$top' = '200'
        '$filter' = "minimumAppPermission eq 'CanEdit' and properties.environmentSku ne 'Teams'"
      }

      $queryString = Build-QueryString -QueryParams $queryParams
      $finalUrl = "$baseUrl/powerapps/environments?$queryString"

      $sessId = [System.Guid]::NewGuid()
      $envReqId = [System.Guid]::NewGuid()

      $response = Invoke-WebRequest -UseBasicParsing -Uri $finalUrl -Headers @{
        "method" = "GET"
        "authorization" = "Bearer $accessToken"
        "origin" = "https://make.test.powerapps.com"
        "x-ms-client-request-id" = $envReqId
        "x-ms-client-session-id" = $sessId
      }

      $environmentsData = ($response.Content | ConvertFrom-Json).value

      if ($environmentsData.Count -eq 0)
      {
        Write-Warning "No environments found for the current user"
        return
      }

      # Cache the environments
      Set-EnvironmentsInCache -Oid $oid -Environments $environmentsData
      $environments = $environmentsData

      Write-Verbose "Retrieved and cached $($environments.Count) environments"
    } catch
    {
      Write-Error "Failed to retrieve environments: $($_.Exception.Message)"
      return
    }
  }

  # Step 3: Handle environment selection
  $selectedEnvId = $null

  if ($EnvironmentId)
  {
    # Validate the provided environment ID
    $matchingEnv = $environments | Where-Object { $_.name -eq $EnvironmentId }
    if ($matchingEnv)
    {
      $selectedEnvId = $EnvironmentId
      Write-Verbose "Using provided environment ID: $EnvironmentId"
    } else
    {
      Write-Warning "Provided environment ID '$EnvironmentId' not found. Showing environment selection."
    }
  }

  if (-not $selectedEnvId)
  {
    $selectedEnvId = Show-EnvironmentMenu -Environments $environments -LastSelected $lastSelected

    # Update last selected in cache
    if ($selectedEnvId)
    {
      $cache = Get-CacheData
      if ($cache.environments -and $cache.environments[$oid])
      {
        if (-not ($cache.environments.$oid | Get-Member -Name "lastSelected" -MemberType Properties))
        {
          $cache.environments.$oid | Add-Member -MemberType NoteProperty -Name "lastSelected" -Value $selectedEnvId
        }
        $cache.environments[$oid].lastSelected = $selectedEnvId
        Set-CacheData -CacheData $cache
      }
    }
  }

  if (-not $selectedEnvId)
  {
    Write-Error "No environment selected"
    return
  }

  # Step 4: Retrieve Power Apps from selected environment
  Write-Host "Retrieving Power Apps..." -ForegroundColor Yellow

  $selectedEnv = $environments | Where-Object { $_.name -eq $selectedEnvId }
  Write-Host "`n=== Selected Environment ===" -ForegroundColor Green
  Write-Host "Environment: $($selectedEnv.properties.displayName)" -ForegroundColor Yellow
  Write-Host "Environment ID: $selectedEnvId" -ForegroundColor Gray
  Write-Host ""

  $powerAppsData = Get-PowerAppsFromEnvironment -EnvironmentId $selectedEnvId -AccessToken $accessToken

  if ($powerAppsData)
  {
    Write-Host "=== Power Apps Retrieved Successfully ===" -ForegroundColor Green
    return $powerAppsData
  } else
  {
    Write-Error "Failed to retrieve Power Apps from environment"
    return
  }
}

function Clear-PowerAppsCache
{
  [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
  param()

  $cacheFile = Get-CacheFilePath

  if (Test-Path $cacheFile)
  {
    if ($PSCmdlet.ShouldProcess($cacheFile, "Delete cache file"))
    {
      try
      {
        Remove-Item $cacheFile -Force
        Write-Host "PowerApps cache cleared successfully." -ForegroundColor Green
      } catch
      {
        Write-Error "Failed to clear cache: $($_.Exception.Message)"
      }
    }
  } else
  {
    Write-Host "No cache file found to clear." -ForegroundColor Yellow
  }
}

#endregion

# Export module members
Export-ModuleMember -Function Get-PowerApp, Clear-PowerAppsCache