Get-UnmanagedLayers.ps1

<#PSScriptInfo
.VERSION 1.0.1
.GUID 7844f1fb-6004-4a9f-81fb-a7faeddaff22
.AUTHOR PowerThomas
.DESCRIPTION Interactive PowerShell tool to detect, inspect and remove unmanaged layers in Power Platform / Dataverse managed solutions. Authenticates via Azure CLI — no MSAL or app registration required.
.COPYRIGHT (c) 2026 PowerThomas. Licensed under the MIT License.
.TAGS PowerPlatform Dataverse PowerShell UnmanagedLayers ALM SolutionLayers
.LICENSEURI https://github.com/PowerThomas/unmanaged-layers-analyser/blob/master/LICENSE
.PROJECTURI https://github.com/PowerThomas/unmanaged-layers-analyser
.RELEASENOTES
    1.0.0 - Initial release.
    1.0.1 - Repository hygiene, PowerShell 7 requirement, Azure CLI login fix, README updates, and CI validation.
#>


#Requires -Version 7.0
<#
.SYNOPSIS
    Detects unmanaged layers in a Power Platform / Dataverse solution.
.DESCRIPTION
    Interactive script:
      1. Select an environment
      2. Select a solution
      3. All unmanaged ("Active") layers are retrieved and displayed
      4. Optional CSV export
.NOTES
    Requires: Azure CLI (https://aka.ms/installazurecliwindows)
    Authentication: via 'az login' — works with Conditional Access policies
#>


[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

#region ── Constants ──────────────────────────────────────────────────────────

# Resource URLs for az account get-access-token
$script:ADMIN_API_RESOURCE  = 'https://service.powerapps.com/'
$script:ADMIN_API_BASE      = 'https://api.bap.microsoft.com'
$script:DATAVERSE_VERSION = 'v9.2'
$script:BATCH_SIZE        = 50

# componenttype integer → human-readable name
$script:COMPONENT_TYPE_MAP = @{
    1   = 'Entity';              2   = 'Attribute';              3   = 'Relationship'
    4   = 'AttributePicklistValue'; 5 = 'AttributeLookupValue';  6   = 'ViewAttribute'
    7   = 'LocalizedLabel';      9   = 'OptionSet';              10  = 'EntityRelationship'
    13  = 'ManagedProperty';     14  = 'EntityKey';              16  = 'Privilege'
    20  = 'Role';                21  = 'RolePrivilege';          22  = 'DisplayString'
    24  = 'Form';                26  = 'SavedQuery';             29  = 'Workflow'
    31  = 'Report';              36  = 'EmailTemplate';          44  = 'DuplicateRule'
    47  = 'AttributeMap';        48  = 'RibbonCommand';          50  = 'RibbonCustomization'
    55  = 'RibbonDiff';          59  = 'SavedQueryVisualization'; 60 = 'SystemForm'
    61  = 'WebResource';         62  = 'SiteMap';                63  = 'ConnectionRole'
    66  = 'CustomControl';       70  = 'FieldSecurityProfile';   71  = 'FieldPermission'
    90  = 'PluginType';          91  = 'PluginAssembly';         92  = 'SDKMessageProcessingStep'
    95  = 'ServiceEndpoint';     152 = 'SLA';                    153 = 'SLAItem'
    161 = 'MobileOfflineProfile'; 300 = 'CanvasApp';             371 = 'Connector'
    380 = 'EnvironmentVariableDefinition'; 381 = 'EnvironmentVariableValue'
    401 = 'AIProject';           402 = 'AIConfiguration'
}

#endregion

#region ── UI helpers ─────────────────────────────────────────────────────────

function Write-Banner {
    Write-Host ''
    Write-Host ' ╔══════════════════════════════════════════════════════╗' -ForegroundColor Cyan
    Write-Host ' ║ Unmanaged Layers Analyser — Power Platform ║' -ForegroundColor Cyan
    Write-Host ' ╚══════════════════════════════════════════════════════╝' -ForegroundColor Cyan
    Write-Host ''
}

function Write-Section {
    param([string]$Title)
    $bar = '─' * ($Title.Length + 4)
    Write-Host ''
    Write-Host " ┌$bar┐" -ForegroundColor DarkCyan
    Write-Host " │ $Title │" -ForegroundColor Cyan
    Write-Host " └$bar┘" -ForegroundColor DarkCyan
    Write-Host ''
}

function Write-Step    { param([string]$Msg) Write-Host " ▸ $Msg" -ForegroundColor DarkCyan }
function Write-Ok      { param([string]$Msg) Write-Host " ✓ $Msg" -ForegroundColor Green }
function Write-Warning2 { param([string]$Msg) Write-Host " ! $Msg" -ForegroundColor Yellow }

function Invoke-MenuChoice {
    param([string]$Prompt, [int]$Min, [int]$Max)
    while ($true) {
        Write-Host " $Prompt" -ForegroundColor White -NoNewline
        $raw = Read-Host
        if ($raw -match '^\d+$') {
            $n = [int]$raw
            if ($n -ge $Min -and $n -le $Max) { return $n }
        }
        Write-Warning2 "Enter a number between $Min and $Max."
    }
}

function Get-ComponentTypeName {
    param([int]$Code)
    if ($script:COMPONENT_TYPE_MAP.ContainsKey($Code)) { return $script:COMPONENT_TYPE_MAP[$Code] }
    return "Type($Code)"
}

#endregion

#region ── Azure CLI auth ────────────────────────────────────────────────────

function Assert-AzureCli {
    if (-not (Get-Command 'az' -ErrorAction SilentlyContinue)) {
        throw "Azure CLI not found. Install from: https://aka.ms/installazurecliwindows"
    }

    $account = az account show --output json 2>$null | ConvertFrom-Json

    if (-not $account) {
        Write-Warning2 'Not logged in to Azure CLI. Starting az login...'
        az login --output none

        if ($LASTEXITCODE -ne 0) {
            throw 'az login failed.'
        }

        $account = az account show --output json 2>$null | ConvertFrom-Json
    }

    if (-not $account) {
        throw 'Azure CLI login succeeded, but no active account was found.'
    }

    Write-Ok "Logged in as: $($account.user.name) (tenant: $($account.tenantId))"
}

function Get-AccessToken {
    <#
    .SYNOPSIS
        Retrieves an access token via Azure CLI for the specified resource URL.
        Works with Conditional Access policies because az login has already been performed on the device.
    #>

    param([string]$Resource)

    $result = az account get-access-token --resource $Resource --output json 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "az account get-access-token failed for resource '$Resource': $result"
    }
    return ($result | ConvertFrom-Json).accessToken
}

#endregion

#region ── REST helpers ───────────────────────────────────────────────────────

function New-ApiHeaders {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'This helper only creates an in-memory headers hashtable.')]
    param([string]$Token)
    return @{
        Authorization    = "Bearer $Token"
        Accept           = 'application/json'
        'OData-MaxVersion' = '4.0'
        'OData-Version'    = '4.0'
    }
}

function Invoke-DataverseGet {
    <#
    .SYNOPSIS
        Executes a GET request against the Dataverse Web API with automatic paging.
    #>

    param(
        [string]$BaseUrl,
        [hashtable]$Headers,
        [switch]$AllPages
    )
    $all  = [System.Collections.Generic.List[object]]::new()
    $url  = $BaseUrl
    do {
        $resp = Invoke-RestMethod -Uri $url -Headers $Headers -Method Get -ErrorAction Stop
        if ($resp.PSObject.Properties['value'] -and $resp.value) { $all.AddRange([object[]]$resp.value) }
        $nextLink = $resp.PSObject.Properties['@odata.nextLink']
        $url = if ($AllPages -and $nextLink -and $nextLink.Value) { $nextLink.Value } else { $null }
    } while ($url)

    return $all
}

#endregion

#region ── Environment ────────────────────────────────────────────────────────

function Get-Environments {
    param([string]$Token)
    Write-Step 'Retrieving environments via Power Platform Admin API...'
    $headers = New-ApiHeaders -Token $Token
    $url = "$script:ADMIN_API_BASE/providers/Microsoft.BusinessAppPlatform/environments" +
           '?api-version=2016-11-01&$expand=properties'
    $raw = Invoke-DataverseGet -BaseUrl $url -Headers $headers

    $envs = $raw |
        Where-Object {
            # Safe property check: Set-StrictMode would otherwise throw on missing properties
            # (non-Dataverse environments such as Teams environments have no linkedEnvironmentMetadata)
            $meta = $_.properties.PSObject.Properties['linkedEnvironmentMetadata']
            $meta -and $meta.Value -and $meta.Value.PSObject.Properties['instanceUrl'] -and $meta.Value.instanceUrl
        } |
        ForEach-Object {
            [PSCustomObject]@{
                Id          = $_.name
                DisplayName = $_.properties.displayName
                OrgUrl      = $_.properties.linkedEnvironmentMetadata.instanceUrl.TrimEnd('/')
                Location    = $_.location
                Type        = $_.properties.environmentSku
            }
        } |
        Sort-Object DisplayName

    return $envs
}

function Select-Environment {
    param([object[]]$Envs)
    Write-Section 'Select an environment'
    for ($i = 0; $i -lt $Envs.Count; $i++) {
        $e = $Envs[$i]
        Write-Host (" [{0,2}] {1,-48} {2}" -f ($i + 1), $e.DisplayName, $e.Type) -ForegroundColor White
    }
    Write-Host ''
    $idx = Invoke-MenuChoice -Prompt "Environment (1-$($Envs.Count)): " -Min 1 -Max $Envs.Count
    return $Envs[$idx - 1]
}

#endregion

#region ── Solution ───────────────────────────────────────────────────────────

function Get-Solutions {
    param([string]$OrgUrl, [string]$Token)
    Write-Step 'Retrieving solutions...'
    $headers = New-ApiHeaders -Token $Token
    $url = "$OrgUrl/api/data/$script:DATAVERSE_VERSION/solutions" +
           '?$select=solutionid,uniquename,friendlyname,version' +
           '&$filter=ismanaged eq true and isvisible eq true' +
           '&$orderby=friendlyname asc'
    return Invoke-DataverseGet -BaseUrl $url -Headers $headers
}

function Select-Solution {
    param([object[]]$Solutions)
    Write-Section 'Select a solution'
    for ($i = 0; $i -lt $Solutions.Count; $i++) {
        $s = $Solutions[$i]
        Write-Host (" [{0,2}] {1,-50} v{2}" -f ($i + 1), $s.friendlyname, $s.version) -ForegroundColor White
    }
    Write-Host ''
    $idx = Invoke-MenuChoice -Prompt "Solution (1-$($Solutions.Count)): " -Min 1 -Max $Solutions.Count
    return $Solutions[$idx - 1]
}

#endregion

#region ── Layer detection ────────────────────────────────────────────────────

function Get-SolutionComponentIds {
    param([string]$OrgUrl, [string]$SolutionId, [string]$Token)
    Write-Step 'Retrieving solution components...'
    $headers = New-ApiHeaders -Token $Token
    $url = "$OrgUrl/api/data/$script:DATAVERSE_VERSION/solutioncomponents" +
           "?`$select=objectid,componenttype&`$filter=_solutionid_value eq $SolutionId"
    $components = Invoke-DataverseGet -BaseUrl $url -Headers $headers -AllPages
    Write-Ok "$($components.Count) component(s) in the solution."
    return $components
}

function Get-UnmanagedLayers {
    <#
    .SYNOPSIS
        Queries all layers per component (type + id, no solutionname filter).
        Filters client-side on 'Active' = unmanaged, stores managed layer as _baseJson for diff.
    #>

    param([string]$OrgUrl, [object[]]$Components, [string]$Token)

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

    $headers = New-ApiHeaders -Token $Token
    $select  = 'msdyn_name,msdyn_componentid,msdyn_solutioncomponentname,msdyn_solutionname,' +
               'msdyn_order,msdyn_publishername,msdyn_changes,msdyn_componentjson'
    $results = [System.Collections.Generic.List[object]]::new()
    $total   = $Components.Count
    $done    = 0
    $errCnt  = 0

    foreach ($comp in $Components) {
        $done++
        $id       = $comp.objectid
        $typeName = Get-ComponentTypeName -Code ([int]$comp.componenttype)

        # type + id returns all layers (managed + unmanaged) without solutionname filter
        $filter = "msdyn_solutioncomponentname eq '$typeName' and msdyn_componentid eq '$id'"
        $url    = "$OrgUrl/api/data/$script:DATAVERSE_VERSION/msdyn_componentlayers" +
                  "?`$select=$select&`$filter=$filter"

        try {
            $resp = Invoke-RestMethod -Uri $url -Headers $headers -Method Get -ErrorAction Stop
            $val  = $resp.PSObject.Properties['value']
            if ($val -and $val.Value) {
                $allLayers     = @($val.Value)
                $unmanagedLayer = $allLayers | Where-Object { $_.msdyn_solutionname -eq 'Active' }
                # Managed layer with highest order = the direct base layer
                $baseLayer     = $allLayers |
                    Where-Object { $_.msdyn_solutionname -ne 'Active' } |
                    Sort-Object   msdyn_order |
                    Select-Object -Last 1

                foreach ($ul in $unmanagedLayer) {
                    $ul | Add-Member -NotePropertyName '_baseJson' `
                        -NotePropertyValue ($baseLayer.PSObject.Properties['msdyn_componentjson']?.Value) `
                        -Force
                    $ul | Add-Member -NotePropertyName '_baseSolution' `
                        -NotePropertyValue ($baseLayer.PSObject.Properties['msdyn_solutionname']?.Value) `
                        -Force
                    $results.Add($ul)
                }
            }
        }
        catch {
            $errCnt++
            if ($errCnt -le 3) {
                Write-Warning2 "Query error ($typeName): $($_.ErrorDetails.Message ?? $_.Exception.Message)"
            }
        }

        $pct = [Math]::Round(($done / $total) * 100)
        Write-Progress -Activity 'Detecting unmanaged layers...' `
            -Status "$done / $total ($($results.Count) layers found)" `
            -PercentComplete $pct
    }
    Write-Progress -Activity 'Detecting unmanaged layers...' -Completed
    if ($errCnt -gt 0) { Write-Warning2 "$errCnt component(s) could not be retrieved." }
    return @($results)
}

#endregion

#region ── Output ─────────────────────────────────────────────────────────────

function Get-LayerAttributes {
    param([string]$Json)
    if ([string]::IsNullOrWhiteSpace($Json)) { return @{} }
    try {
        $lookup = @{}
        ($Json | ConvertFrom-Json).Attributes | ForEach-Object { $lookup[$_.Key] = $_.Value }
        return $lookup
    }
    catch { return @{} }
}

function Get-ChangedAttributes {
    param([string]$ChangesJson)
    if ([string]::IsNullOrWhiteSpace($ChangesJson)) { return @() }
    try {
        $obj = $ChangesJson | ConvertFrom-Json
        return $obj.Attributes | ForEach-Object { [PSCustomObject]@{ Attribute = $_.Key; Value = $_.Value } }
    }
    catch { return @() }
}

function Show-Diff {
    param([object[]]$Layers)
    Write-Host ''
    foreach ($layer in ($Layers | Sort-Object msdyn_solutioncomponentname, msdyn_name)) {
        $changes  = Get-ChangedAttributes $layer.msdyn_changes
        if ($changes.Count -eq 0) { continue }

        $baseAttrs = Get-LayerAttributes ($layer.PSObject.Properties['_baseJson']?.Value)
        $baseSol   = $layer.PSObject.Properties['_baseSolution']?.Value
        if (-not $baseSol) { $baseSol = 'managed' }

        # Header
        $typeLabel = $layer.msdyn_solutioncomponentname
        $nameLabel = $layer.msdyn_name
        Write-Host " diff [$typeLabel] $nameLabel" -ForegroundColor White
        Write-Host (" " + ('=' * 60)) -ForegroundColor DarkGray
        Write-Host (" --- $baseSol") -ForegroundColor Red
        Write-Host (" +++ Unmanaged (Active) publisher: $($layer.msdyn_publishername)") -ForegroundColor Green
        Write-Host (" " + ('-' * 60)) -ForegroundColor DarkGray

        foreach ($change in $changes) {
            $key     = $change.Attribute
            $newVal  = if ($null -ne $change.Value) { $change.Value } else { '(null)' }
            $oldVal  = if ($baseAttrs.ContainsKey($key)) {
                           if ($null -ne $baseAttrs[$key]) { $baseAttrs[$key] } else { '(null)' }
                       } else { '(unknown)' }

            Write-Host (" - {0,-45} {1}" -f $key, $oldVal) -ForegroundColor Red
            Write-Host (" + {0,-45} {1}" -f $key, $newVal) -ForegroundColor Green
            Write-Host ''
        }
    }
}

function Show-Results {
    param([object[]]$Layers, [string]$SolutionDisplayName)
    Write-Section "Results -- $SolutionDisplayName"

    if ($Layers.Count -eq 0) {
        Write-Ok 'No unmanaged layers found. The solution is clean.'
        Write-Host ''
        return
    }

    Write-Host (" Total found: {0} unmanaged layer(s)`n" -f $Layers.Count) -ForegroundColor Yellow

    $table = $Layers |
        Sort-Object msdyn_solutioncomponentname, msdyn_name |
        Select-Object `
            @{ N = 'Type';        E = { $_.msdyn_solutioncomponentname } },
            @{ N = 'Name';        E = { $_.msdyn_name } },
            @{ N = 'Publisher';   E = { $_.msdyn_publishername } },
            @{ N = 'LayerOrder';  E = { $_.msdyn_order } },
            @{ N = 'Changes'; E = { (Get-ChangedAttributes $_.msdyn_changes).Count } }

    $table | Format-Table -AutoSize | Out-String | ForEach-Object { Write-Host $_ }

    # Samenvatting per componenttype
    Write-Host ' Summary by type:' -ForegroundColor DarkCyan
    $Layers |
        Group-Object msdyn_solutioncomponentname |
        Sort-Object Count -Descending |
        ForEach-Object {
            Write-Host (" {0,-38} {1,4} layer(s)" -f $_.Name, $_.Count) -ForegroundColor Gray
        }
    Write-Host ''

    # Diff
    Write-Host ' Show diff? ' -ForegroundColor White -NoNewline
    $showDiff = Read-Host '[Y/N]'
    if ($showDiff -match '^[YyJj]') { Show-Diff -Layers $Layers }
}

function Export-ToCsv {
    param([object[]]$Layers, [string]$SolutionUniqueName)
    if ($Layers.Count -eq 0) { return }

    Write-Host ''
    Write-Host ' Export to CSV? ' -ForegroundColor White -NoNewline
    $answer = Read-Host '[Y/N]'
    if ($answer -notmatch '^[YyJj1]') { return }

    $stamp   = Get-Date -Format 'yyyyMMdd-HHmm'
    $safeName = $SolutionUniqueName -replace '[^a-zA-Z0-9\-_]', '_'
    $default  = "unmanaged-layers_${safeName}_${stamp}.csv"

    Write-Host " File name [$default]: " -NoNewline -ForegroundColor White
    $fileName = Read-Host
    if ([string]::IsNullOrWhiteSpace($fileName)) { $fileName = $default }

    $pad = Join-Path (Get-Location) $fileName
    $Layers |
        Select-Object `
            @{ N = 'ComponentType';       E = { $_.msdyn_solutioncomponentname } },
            @{ N = 'ComponentName';       E = { $_.msdyn_name } },
            @{ N = 'ComponentId';         E = { $_.msdyn_componentid } },
            @{ N = 'Publisher';           E = { $_.msdyn_publishername } },
            @{ N = 'LayerOrder';          E = { $_.msdyn_order } },
            @{ N = 'SolutionName';        E = { $_.msdyn_solutionname } },
            @{ N = 'ChangedAttributes';   E = {
                (Get-ChangedAttributes $_.msdyn_changes | ForEach-Object { "$($_.Attribute)=$($_.Value)" }) -join '; '
            }} |
        Export-Csv -Path $pad -NoTypeInformation -Encoding UTF8

    Write-Ok "Exported to: $pad"
}

function Remove-UnmanagedLayers {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    <#
    .SYNOPSIS
        Removes unmanaged layers via the Dataverse action RemoveActiveCustomization.
        Requires double confirmation and displays a clear warning.
    #>

    param([string]$OrgUrl, [object[]]$Layers, [string]$Token, [string]$SolutionDisplayName)

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

    Write-Host ''
    Write-Host ' +---------------------------------------------------------+' -ForegroundColor Yellow
    Write-Host ' | WARNING: destructive action |' -ForegroundColor Yellow
    Write-Host ' | This permanently removes the unmanaged layers. |' -ForegroundColor Yellow
    Write-Host ' | Do NOT run this in production without approval. |' -ForegroundColor Yellow
    Write-Host ' +---------------------------------------------------------+' -ForegroundColor Yellow
    Write-Host ''
    Write-Host " $($Layers.Count) layer(s) will be removed from solution: $SolutionDisplayName" -ForegroundColor Yellow
    Write-Host ''
    Write-Host " Remove all unmanaged layers? " -ForegroundColor White -NoNewline
    $check1 = Read-Host '[Y/N]'
    if ($check1 -notmatch '^[YyJj]') {
        Write-Ok 'Removal cancelled.'
        return
    }

    Write-Host ''
    Write-Host " Confirm by typing the solution name exactly:" -ForegroundColor Red
    Write-Host " > $SolutionDisplayName" -ForegroundColor White
    $confirmed = $false
    for ($attempt = 1; $attempt -le 3; $attempt++) {
        Write-Host " [$attempt/3]: " -ForegroundColor Red -NoNewline
        $check2 = Read-Host
        if ($check2 -eq $SolutionDisplayName) { $confirmed = $true; break }
        if ($attempt -lt 3) { Write-Warning2 "Name does not match, please try again." }
    }
    if (-not $confirmed) {
        Write-Ok 'Removal cancelled (solution name not entered correctly).'
        return
    }

    Write-Section 'Removing unmanaged layers'

    $postHeaders = @{
        Authorization      = "Bearer $Token"
        'Content-Type'     = 'application/json'
        'OData-MaxVersion' = '4.0'
        'OData-Version'    = '4.0'
    }
    $actionUrl = "$OrgUrl/api/data/$script:DATAVERSE_VERSION/RemoveActiveCustomization"

    $total   = $Layers.Count
    $done    = 0
    $success = 0
    $failed  = 0

    foreach ($layer in $Layers) {
        $done++
        $compName = $layer.msdyn_solutioncomponentname
        $compId   = $layer.msdyn_componentid
        $compDisp = $layer.msdyn_name

        $body = @{
            LogicalName = $compName.ToLower()
            Id          = $compId
        } | ConvertTo-Json

        try {
            $target = "$compName / $compDisp / $compId"
            if (-not $PSCmdlet.ShouldProcess($target, 'Remove unmanaged active layer')) {
                continue
            }

            Invoke-RestMethod -Uri $actionUrl -Headers $postHeaders -Method Post -Body $body -ErrorAction Stop
            $success++
        }
        catch {
            $failed++
            Write-Warning2 "Failed ($compName / $compDisp): $($_.ErrorDetails.Message ?? $_.Exception.Message)"
        }

        $pct = [Math]::Round(($done / $total) * 100)
        Write-Progress -Activity 'Removing layers...' `
            -Status "$done / $total (OK: $success Failed: $failed)" `
            -PercentComplete $pct
    }
    Write-Progress -Activity 'Removing layers...' -Completed

    Write-Host ''
    Write-Ok   "Removed: $success layer(s)"
    if ($failed -gt 0) { Write-Warning2 "Failed: $failed layer(s) (see warnings above)" }

    if ($success -gt 0) {
        Write-Host ''
        Write-Host ' Publish the environment now? (recommended after removal)' -ForegroundColor White -NoNewline
        $pub = Read-Host ' [Y/N]'
        if ($pub -match '^[YyJj]') {
            Write-Step 'Publishing...'
            try {
                $pubPayload = @{ ParameterXml = '' } | ConvertTo-Json
                Invoke-RestMethod -Uri "$OrgUrl/api/data/$script:DATAVERSE_VERSION/PublishAllXml" `
                    -Headers $postHeaders -Method Post -Body $pubPayload -ErrorAction Stop
                Write-Ok 'Published.'
            }
            catch { Write-Warning2 "Publish failed: $($_.ErrorDetails.Message ?? $_.Exception.Message)" }
        }
    }
}

#endregion

#region ── Main ───────────────────────────────────────────────────────────────

function Invoke-Main {
    Write-Banner

    # ── Step 1: Azure CLI available and logged in
    Write-Step 'Checking Azure CLI...'
    Assert-AzureCli

    # ── Step 2: Token for Power Platform Admin API
    Write-Step 'Retrieving token for Power Platform Admin API...'
    $adminToken = Get-AccessToken -Resource $script:ADMIN_API_RESOURCE
    Write-Ok 'Token received.'

    # ── Step 3: Retrieve and select environment
    $envs = Get-Environments -Token $adminToken
    if ($envs.Count -eq 0) {
        Write-Warning2 'No environments found. Check your permissions in the Power Platform Admin portal.'
        return
    }
    Write-Ok "$($envs.Count) environment(s) found."

    $selectedEnv = Select-Environment -Envs $envs
    Write-Ok "Environment: $($selectedEnv.DisplayName)"
    Write-Host " $($selectedEnv.OrgUrl)" -ForegroundColor DarkGray

    # ── Step 4: Dataverse token for selected environment
    Write-Step "Retrieving Dataverse token for $($selectedEnv.DisplayName)..."
    $dataverseToken = Get-AccessToken -Resource $selectedEnv.OrgUrl
    Write-Ok 'Dataverse token received.'

    # ── Step 5: Retrieve and select solution
    $solutions = Get-Solutions -OrgUrl $selectedEnv.OrgUrl -Token $dataverseToken
    if ($solutions.Count -eq 0) {
        Write-Warning2 'No managed solutions found in this environment.'
        return
    }
    Write-Ok "$($solutions.Count) solution(s) found."

    $selectedSol = Select-Solution -Solutions $solutions
    Write-Ok "Solution: $($selectedSol.friendlyname) (v$($selectedSol.version))"

    # ── Step 6: Retrieve components
    $components = Get-SolutionComponentIds `
        -OrgUrl     $selectedEnv.OrgUrl `
        -SolutionId $selectedSol.solutionid `
        -Token      $dataverseToken

    if ($components.Count -eq 0) {
        Write-Warning2 'Solution contains no components.'
        return
    }

    # ── Step 7: Detect unmanaged layers
    Write-Step "Detecting unmanaged layers for $($components.Count) component(s)..."
    $layers = @(Get-UnmanagedLayers -OrgUrl $selectedEnv.OrgUrl -Components $components -Token $dataverseToken)
    Write-Ok "$($layers.Count) unmanaged layer(s) detected."

    # ── Step 8: Display and export
    Show-Results  -Layers $layers -SolutionDisplayName $selectedSol.friendlyname
    Export-ToCsv  -Layers $layers -SolutionUniqueName  $selectedSol.uniquename

    # ── Step 9: Optional removal
    Remove-UnmanagedLayers -OrgUrl $selectedEnv.OrgUrl -Layers $layers -Token $dataverseToken -SolutionDisplayName $selectedSol.friendlyname
}

try {
    Invoke-Main
}
catch {
    Write-Host ''
    Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
    if ($_.ScriptStackTrace) {
        Write-Host " $($_.ScriptStackTrace)" -ForegroundColor DarkRed
    }
    exit 1
}

#endregion