# -- param must be first statement for PS5 compatibility --
param([switch]$SkipUpgrade)

# ============================================================
# Entra Group Manager - Orchestrator Launcher
# Author      : Satish Singhi
# Description : PS5-compatible launcher. Installs/upgrades
#               PowerShell 7 via winget (user scope, no admin)
#               then relaunches self in PS7 to show the GUI.
#
# Distribute alongside: Orchestrator_GUI.ps1
# Users run this file only. Orchestrator_GUI.ps1 is copied
# to a staging directory and launched via PS7.
#
# -- DEVELOPER CONFIG -----------------------------------------
# ============================================================

# ============================================================
#region ORCHESTRATOR CONFIG
# ============================================================
$OrcConfig = @{
    StagingRoot  = "C:\Logs\MDM-ODA"
    StagingPath  = "C:\Logs\MDM-ODA\Staging"
    LogPath      = "C:\Logs\MDM-ODA\Logs"
    ToolFileName = "MDM-ODA.ps1"
}
# Ensure all folders exist upfront
foreach ($dir in @($OrcConfig.StagingRoot, $OrcConfig.StagingPath, $OrcConfig.LogPath)) {
    if (-not (Test-Path $dir)) {
        try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
    }
}
#endregion ORCHESTRATOR CONFIG
# ============================================================

# ============================================================
# PS5 SECTION  -  Everything below here runs in PS5.
# PS7 code begins after the exit statement further down.
# PS5 exits before reaching the PS7 section so PS7-only
# syntax there never causes a parse error in PS5.
# ============================================================

if ($PSVersionTable.PSVersion.Major -lt 7) {

    Write-Host ""
    Write-Host "  MDM ODA Tool" -ForegroundColor Cyan
    Write-Host "  Checking PowerShell 7..." -ForegroundColor Cyan
    Write-Host ""

    # -- Verify winget is available -------------------------------------------
    $winget = Get-Command winget -ErrorAction SilentlyContinue
    if (-not $winget) {
        Write-Host "  winget is not available on this device." -ForegroundColor Red
        Write-Host "  Please install PowerShell 7 from:" -ForegroundColor Yellow
        Write-Host "  https://github.com/PowerShell/PowerShell/releases/latest" -ForegroundColor Cyan
        Write-Host ""
        Read-Host "  Press Enter to exit"
        exit 1
    }

    # -- PS7 detection: validate real executable (not a Windows Store stub) -----
    Write-Host "  Checking PowerShell 7..." -ForegroundColor Gray

    # Windows 11/10 places a tiny stub at WindowsApps\pwsh.exe even when PS7 is NOT
    # installed — Test-Path returns $true but running it just opens the Store.
    # Filter stubs out by requiring file size > 50 KB (real pwsh.exe is ~200 KB).
    function Test-RealPwsh {
        param([string]$Path)
        if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
        if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { return $false }
        $item = Get-Item $Path -ErrorAction SilentlyContinue
        if (-not $item) { return $false }
        if ($item.Length -lt 51200) {
            Write-Host "  Skipping stub at: $Path (size $($item.Length) bytes)" -ForegroundColor DarkGray
            return $false
        }
        return $true
    }

    # Probe in priority order:
    #   1. System-wide MSI  (most reliable on corporate devices)
    #   2. User-scope winget non-Store
    #   3. PATH (covers custom/Chocolatey/SCCM installs)
    #   4. WindowsApps  (Store install — validated last because stubs live here)
    $probePaths = [System.Collections.Generic.List[string]]::new()
    $probePaths.Add('C:\Program Files\PowerShell\7\pwsh.exe')                              # system MSI
    $probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\PowerShell\7\pwsh.exe'))       # winget user-scope
    $pwshCmd = Get-Command pwsh -All -ErrorAction SilentlyContinue
    if ($pwshCmd) {
        foreach ($p in ($pwshCmd | Where-Object { $_.Source -notlike '*WindowsApps*' })) {
            if (-not $probePaths.Contains($p.Source)) { $probePaths.Add($p.Source) }
        }
    }
    $probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe'))        # Store (checked LAST; stubs are filtered by size)

    $pwsh7Detected = $null
    foreach ($p in $probePaths) {
        if (Test-RealPwsh $p) { $pwsh7Detected = $p; break }
    }
    $ps7Installed = $null -ne $pwsh7Detected

    if (-not $ps7Installed) {
        Write-Host "  PowerShell 7 not found. Installing (user-scope, no admin required)..." -ForegroundColor Yellow
        Write-Host "  This may take 1-2 minutes. Please wait." -ForegroundColor Gray
        & winget install --id Microsoft.PowerShell --exact --scope user --source winget `
            --accept-source-agreements --accept-package-agreements --silent
        Start-Sleep -Seconds 3
        $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" +
                    [System.Environment]::GetEnvironmentVariable("Path","Machine")
        # Re-probe after install (using same stub-safe validator)
        foreach ($p in $probePaths) {
            if (Test-RealPwsh $p) { $pwsh7Detected = $p; break }
        }
        $ps7Installed = $null -ne $pwsh7Detected
        if ($ps7Installed) {
            Write-Host "  PowerShell 7 installed at: $pwsh7Detected" -ForegroundColor Green
        } else {
            Write-Host "  Installation may have succeeded but path not yet visible." -ForegroundColor Yellow
            Write-Host "  Please close this window and open a new terminal, then run the script again." -ForegroundColor Yellow
            Read-Host "  Press Enter to exit"
            exit 1
        }
    } else {
        $scopeLabel = if ($pwsh7Detected -like "*LocalAppData*") { "(user-scope)" } else { "(system-wide)" }
        Write-Host "  PowerShell 7 detected $scopeLabel : $pwsh7Detected" -ForegroundColor Green
    }

    # -- Upgrade to latest stable every launch (skip previews) -------------------
    if (-not $SkipUpgrade) {
        Write-Host "  Checking for updates (stable only)..." -ForegroundColor Gray

        $showOutput = & winget show --id Microsoft.PowerShell --exact --source winget 2>&1
        $verLine    = $showOutput | Where-Object { $_ -match '^\s*Version\s*:' } | Select-Object -First 1

        $latestStable = $null
        if ($verLine -and $verLine -match ':\s*([\d\.]+)') {
            $rawVer = $Matches[1]
            if ($rawVer -notmatch 'preview|rc|beta|alpha') { $latestStable = $rawVer }
        }

        if ($latestStable) {
            Write-Host "  Latest stable PS7: $latestStable" -ForegroundColor Gray
            $upgradeOut = & winget upgrade --id Microsoft.PowerShell --exact --scope user `
                --source winget --accept-source-agreements --accept-package-agreements --silent 2>&1
            $done = $upgradeOut | Where-Object {
                $_ -match 'No applicable upgrade' -or
                $_ -match 'already installed'     -or
                $_ -match 'Successfully installed'
            }
            if ($done) {
                Write-Host "  PowerShell 7 is up to date (v$latestStable)." -ForegroundColor Green
            } else {
                Write-Host "  Update applied." -ForegroundColor Green
            }
        }
    }

    # -- Find pwsh.exe and relaunch in PS7 ---------------------------------------
    $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" +
                [System.Environment]::GetEnvironmentVariable("Path","Machine")

    # Use already-detected path; fall back to re-probe if needed
    $pwsh7 = $pwsh7Detected
    if (-not $pwsh7) {
        $pwshAll = Get-Command pwsh -All -ErrorAction SilentlyContinue
        if ($pwshAll) {
            foreach ($p in ($pwshAll | Where-Object { $_.Source -notlike '*WindowsApps*' })) {
                if (Test-Path $p.Source -ErrorAction SilentlyContinue) { $pwsh7 = $p.Source; break }
            }
        }
    }

    if ($pwsh7) {
        Write-Host "  Staging and launching in PowerShell 7 ($pwsh7)..." -ForegroundColor Green
        # Hide this console window before handing off
        try {
            Add-Type -TypeDefinition @"
using System.Runtime.InteropServices;
public class OrcLaunchConsole {
    [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
    [DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();
}
"@ -ErrorAction SilentlyContinue
            $null = [OrcLaunchConsole]::ShowWindow([OrcLaunchConsole]::GetConsoleWindow(), 0)
        } catch {}

        # Extract PS7 payload from the block-comment section embedded in this script.
        # PS5 never compiled that code — it was just a comment token to the PS5 tokenizer.
        $scriptText  = [System.IO.File]::ReadAllText($PSCommandPath, [System.Text.Encoding]::UTF8)
        $startTag    = '<#__PS7' + '_PAYLOAD_START__'
        $endTag      = '__PS7_PAYLOAD' + '_END__#>'
        $startOffset = $scriptText.IndexOf($startTag)
        $endOffset   = $scriptText.IndexOf($endTag)

        if ($startOffset -lt 0 -or $endOffset -le $startOffset) {
            Write-Host "  ERROR: PS7 payload boundary markers not found in script." -ForegroundColor Red
            Read-Host "  Press Enter to exit"
            exit 1
        }

        $payloadText   = $scriptText.Substring($startOffset + $startTag.Length, $endOffset - ($startOffset + $startTag.Length)).TrimStart("`r", "`n")
        $stagingScript = Join-Path $OrcConfig.StagingPath $OrcConfig.ToolFileName

        # Ensure staging folders exist
        foreach ($dir in @($OrcConfig.StagingRoot, $OrcConfig.StagingPath, $OrcConfig.LogPath)) {
            if (-not (Test-Path $dir)) {
                try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
            }
        }

        # Write PS7 payload to staging directory as MDM-ODA.ps1
        [System.IO.File]::WriteAllText($stagingScript, $payloadText, [System.Text.Encoding]::UTF8)
        Write-Host "  Staged: $stagingScript" -ForegroundColor Green

        # Launch staged PS7 script — hidden console window, wait for exit
        Start-Process -FilePath $pwsh7 `
            -ArgumentList "-ExecutionPolicy Bypass -STA -File `"$stagingScript`"" `
            -WindowStyle Hidden `
            -Wait
        exit 0
    } else {
        Write-Host ""
        Write-Host "  Could not locate pwsh.exe." -ForegroundColor Red
        Write-Host "  Please close this window, open a new terminal and run the script again." -ForegroundColor Yellow
        Write-Host ""
        Read-Host "  Press Enter to exit"
        exit 1
    }
}

# ============================================================
# PS7 PAYLOAD — embedded as a PS5 block comment so PS5 never
#   compiles this code. The PS5 launcher reads this file at
#   runtime, extracts the payload between the sentinel tags,
#   stages it as MDM-ODA.ps1, and launches it with pwsh.exe.
#   DO NOT edit or remove the sentinel tags below.
# ============================================================
<#__PS7_PAYLOAD_START__
# ── Orchestrator config ─────────────────────────────────────────────────────
# Defined here so MDM-ODA.ps1 is self-contained when staged and run directly.
# Values must match the $OrcConfig in the PS5 launcher section above.
$OrcConfig = @{
    StagingRoot  = 'C:\Logs\MDM-ODA'
    StagingPath  = 'C:\Logs\MDM-ODA\Staging'
    LogPath      = 'C:\Logs\MDM-ODA\Logs'
    ToolFileName = 'MDM-ODA.ps1'
}

# ── Startup diagnostic log (captures fatal errors before the form appears) ─
$script:OrcDiagLog = Join-Path $OrcConfig.LogPath ("OrcDiag_" + (Get-Date -f 'yyyyMMdd_HHmmss') + ".log")
function Write-OrcDiag { param([string]$Msg)
    try { [System.IO.Directory]::CreateDirectory($OrcConfig.LogPath)|Out-Null
          [System.IO.File]::AppendAllText($script:OrcDiagLog,"[$(Get-Date -f 'HH:mm:ss')] $Msg`n",[System.Text.Encoding]::UTF8) } catch {} }
trap { Write-OrcDiag "FATAL: $($_.Exception.Message)`n$($_.ScriptStackTrace)"; break }
Write-OrcDiag "MDM-ODA.ps1 started. PSCommandPath=$PSCommandPath"

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

# -- Read WindowTitle from the embedded GUI section ----------------------------
$toolTitle = "Launching..."
try {
    $selfContent = [System.IO.File]::ReadAllText($PSCommandPath, [System.Text.Encoding]::UTF8)
    if ($selfContent -match 'WindowTitle\s*=\s*"([^"]+)"') { $toolTitle = $Matches[1] }
} catch {}

# -- Colours ------------------------------------------------------------------
$C = @{
    Header   = [System.Drawing.ColorTranslator]::FromHtml('#007A00')
    HeaderFg = [System.Drawing.Color]::White
    BtnGreen = [System.Drawing.ColorTranslator]::FromHtml('#007A00')
    BtnFg    = [System.Drawing.Color]::White
    Bg       = [System.Drawing.ColorTranslator]::FromHtml('#F8FBF8')
    LogBg    = [System.Drawing.ColorTranslator]::FromHtml('#1A1F2E')
    LogFg    = [System.Drawing.ColorTranslator]::FromHtml('#9DB8D8')
    Success  = [System.Drawing.ColorTranslator]::FromHtml('#5DC98B')
    Warning  = [System.Drawing.ColorTranslator]::FromHtml('#F5A623')
    Error    = [System.Drawing.ColorTranslator]::FromHtml('#F06C6C')
    Info     = [System.Drawing.ColorTranslator]::FromHtml('#C8DEF5')
}

# -- Build form ----------------------------------------------------------------
$form                 = [System.Windows.Forms.Form]::new()
$form.Text            = $toolTitle
$form.Width           = 580
$form.Height          = 380
$form.MinimumSize     = [System.Drawing.Size]::new(480, 300)
$form.StartPosition   = 'CenterScreen'
$form.BackColor       = $C.Bg
$form.Font            = [System.Drawing.Font]::new('Segoe UI', 10)
$form.FormBorderStyle = 'FixedDialog'
$form.MaximizeBox     = $false

$header              = [System.Windows.Forms.Panel]::new()
$header.Dock         = 'Top'
$header.Height       = 52
$header.BackColor    = $C.Header
$headerLbl           = [System.Windows.Forms.Label]::new()
$headerLbl.Text      = $toolTitle
$headerLbl.ForeColor = $C.HeaderFg
$headerLbl.Font      = [System.Drawing.Font]::new('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
$headerLbl.AutoSize  = $true
$headerLbl.Location  = [System.Drawing.Point]::new(16, 13)
$header.Controls.Add($headerLbl)

$content              = [System.Windows.Forms.TableLayoutPanel]::new()
$content.Dock         = 'Fill'
$content.Padding      = [System.Windows.Forms.Padding]::new(14, 10, 14, 10)
$content.BackColor    = $C.Bg
$content.ColumnCount  = 1
$content.RowCount     = 3
$null = $content.ColumnStyles.Add([System.Windows.Forms.ColumnStyle]::new([System.Windows.Forms.SizeType]::Percent, 100))
$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 30))
$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 100))
$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 44))
$form.Controls.Add($content)
$form.Controls.Add($header)

$statusLbl           = [System.Windows.Forms.Label]::new()
$statusLbl.Text      = 'Initialising...'
$statusLbl.Dock      = 'Fill'
$statusLbl.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
$statusLbl.ForeColor = [System.Drawing.ColorTranslator]::FromHtml('#1A1F2E')
$content.Controls.Add($statusLbl, 0, 0)

$logBox              = [System.Windows.Forms.RichTextBox]::new()
$logBox.ReadOnly     = $true
$logBox.BackColor    = $C.LogBg
$logBox.ForeColor    = $C.LogFg
$logBox.Font         = [System.Drawing.Font]::new('Consolas', 9)
$logBox.BorderStyle  = 'None'
$logBox.Dock         = 'Fill'
$content.Controls.Add($logBox, 0, 1)

$btnPanel            = [System.Windows.Forms.Panel]::new()
$btnPanel.Dock       = 'Fill'
$btnPanel.BackColor  = $C.Bg
$content.Controls.Add($btnPanel, 0, 2)

$btnClose            = [System.Windows.Forms.Button]::new()
$btnClose.Text       = 'Close'
$btnClose.Width      = 100
$btnClose.Height     = 32
$btnClose.Anchor     = 'Right,Bottom'
$btnClose.Location   = [System.Drawing.Point]::new(440, 6)
$btnClose.BackColor  = $C.BtnGreen
$btnClose.ForeColor  = $C.BtnFg
$btnClose.FlatStyle  = 'Flat'
$btnClose.FlatAppearance.BorderSize = 0
$btnClose.Visible    = $false
$btnClose.Add_Click({ $form.Close() })
$btnPanel.Controls.Add($btnClose)

function OLog {
    param([string]$Line, [string]$Level = 'Info')
    $col = switch ($Level) {
        'Success' { $C.Success } 'Warning' { $C.Warning }
        'Error'   { $C.Error   } 'Action'  { $C.Info    }
        default   { $C.LogFg }
    }
    $logBox.SelectionStart  = $logBox.TextLength
    $logBox.SelectionLength = 0
    $logBox.SelectionColor  = $col
    $logBox.AppendText("[$((Get-Date).ToString('HH:mm:ss'))] $Line`n")
    $logBox.ScrollToCaret()
    $form.Refresh()
}
function OStatus { param([string]$T) $statusLbl.Text = $T; $form.Refresh() }

$form.Add_Shown({

    # -- Extract GUI tool content FIRST (before clearing staging) ----------------
    # MDM-ODA.ps1 runs FROM the staging directory — read it before deleting it.
    OStatus 'Extracting GUI tool...'
    $toolPath  = Join-Path $OrcConfig.StagingPath $OrcConfig.ToolFileName
    $beginMark = '#' + '=' * 10 + ' BEGIN_GUI_TOOL_CONTENT ' + '=' * 10
    $endMark   = '#' + '=' * 10 + ' END_GUI_TOOL_CONTENT '   + '=' * 11
    $guiLines  = $null
    try {
        $selfLines = [System.IO.File]::ReadAllLines($PSCommandPath, [System.Text.Encoding]::UTF8)
        $inBlock   = $false
        $guiLines  = [System.Collections.Generic.List[string]]::new()
        foreach ($line in $selfLines) {
            if ($line -eq $beginMark) { $inBlock = $true;  continue }
            if ($line -eq $endMark)   { $inBlock = $false; break }
            if ($inBlock)              { $guiLines.Add($line) }
        }
        if ($guiLines.Count -eq 0) { throw 'GUI tool section not found in orchestrator.' }
        OLog ('GUI tool read (' + $guiLines.Count + ' lines).') 'Success'
        Write-OrcDiag "GUI tool read: $($guiLines.Count) lines"
    } catch {
        OLog "Extraction failed: $($_.Exception.Message)" 'Error'
        OStatus 'Error - see log.'
        Write-OrcDiag "Extraction failed: $($_.Exception.Message)"
        $btnClose.Visible = $true; return
    }

    # -- Prepare staging (AFTER reading self — Remove-Item deletes PSCommandPath) --
    OStatus 'Preparing staging...'
    OLog "Staging: $($OrcConfig.StagingPath)" 'Info'
    try {
        if (Test-Path $OrcConfig.StagingPath) {
            Remove-Item $OrcConfig.StagingPath -Recurse -Force -ErrorAction Stop
        }
        New-Item -ItemType Directory -Path $OrcConfig.StagingPath -Force | Out-Null
        OLog 'Staging ready.' 'Success'
    } catch {
        OLog "Staging failed: $($_.Exception.Message)" 'Error'
        OStatus 'Error - see log.'
        $btnClose.Visible = $true; return
    }

    # -- Write GUI tool to staging ---------------------------------------------
    try {
        [System.IO.File]::WriteAllText($toolPath, ($guiLines -join "`n"), [System.Text.Encoding]::UTF8)
        OLog ('GUI tool staged (' + $guiLines.Count + ' lines).') 'Success'
        Write-OrcDiag "GUI tool written to: $toolPath"
    } catch {
        OLog "Write to staging failed: $($_.Exception.Message)" 'Error'
        OStatus 'Error - see log.'
        $btnClose.Visible = $true; return
    }


    # -- Launch GUI tool -------------------------------------------------------
    OStatus "Launching $toolTitle..."
    $pwsh7 = (Get-Process -Id $PID).MainModule.FileName
    OLog "Launching via: $pwsh7" 'Action'

    # Write orchestrator launch log for diagnostics
    $orcLogPath = Join-Path $OrcConfig.LogPath ("OrcLaunch_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
    Set-Content -Path $orcLogPath -Value "Orchestrator Launch Log" -Encoding UTF8
    Add-Content -Path $orcLogPath -Value "========================" -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("Timestamp  : " + (Get-Date)) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("Host       : " + $env:COMPUTERNAME) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("User       : " + $env:USERNAME) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("PS7 Path   : " + $pwsh7) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("Tool Path  : " + $toolPath) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("Tool Lines : " + $guiLines.Count) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("Staging    : " + $OrcConfig.StagingPath) -Encoding UTF8
    Add-Content -Path $orcLogPath -Value ("Log Dir    : " + $OrcConfig.LogPath) -Encoding UTF8
    OLog "Launch log: $orcLogPath" 'Info'

    # Transcript path for the GUI tool startup diagnostics
    $transcriptPath = Join-Path $OrcConfig.LogPath ("EGM_Transcript_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))

    # Pass log paths to GUI tool via environment variables.
    # Note: param() blocks in mid-file cause PS5 parse errors, so env vars are used.
    $env:EGM_LogFolder  = $OrcConfig.LogPath
    $env:EGM_Transcript = $transcriptPath

    $launchArgs = "-ExecutionPolicy Bypass -STA -File `"$toolPath`""
    try {
        # WindowStyle Normal (visible) so startup crashes surface in a console window.
        # Switch to Hidden once the tool is confirmed stable.
        $proc = Start-Process $pwsh7 -ArgumentList $launchArgs -WindowStyle Hidden -PassThru
        Add-Content -Path $orcLogPath -Value ("Process ID : " + $proc.Id) -Encoding UTF8
        OLog ("Tool launched. PID=" + $proc.Id + " Transcript=" + $transcriptPath) 'Success'
        OStatus ($toolTitle + " launched.")
        # Hide orchestrator console window now that the GUI tool is running
        try {
            if (-not ([System.Management.Automation.PSTypeName]'OrcConsole').Type) {
                Add-Type -TypeDefinition @"
using System.Runtime.InteropServices;
public class OrcConsole {
    [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
    [DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();
}
"@
            }
            $null = [OrcConsole]::ShowWindow([OrcConsole]::GetConsoleWindow(), 0)
        } catch {}
        Start-Sleep -Milliseconds 800
        $form.Close()
    } catch {
        $errMsg = "Launch failed: " + $_.Exception.Message
        Add-Content -Path $orcLogPath -Value ("ERROR: " + $errMsg) -Encoding UTF8
        OLog $errMsg 'Error'
        OStatus 'Launch failed. Check log.'
        $btnClose.Visible = $true
    }
})

$form.ShowDialog() | Out-Null
exit 0

#========== BEGIN_GUI_TOOL_CONTENT ==========
# Note: #Requires omitted - orchestrator guarantees PS7
# Module requirements checked at runtime by the prereq window (see #region PREREQ CHECK)
# Values passed from orchestrator via environment variables (no param block  - 
# a param() in the middle of the orchestrator file causes PS5 parse errors)
$OverrideLogFolder = if ($env:EGM_LogFolder)    { $env:EGM_LogFolder }    else { "" }
$TranscriptPath    = if ($env:EGM_Transcript)   { $env:EGM_Transcript }   else { "" }

# -- Early-crash diagnostic: written to TEMP before DevConfig initialised --
$_earlyLog = Join-Path $env:TEMP ("MDM-ODA_Early_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
function Write-EarlyLog { param([string]$Msg) try { Add-Content -Path $_earlyLog -Value "[$(Get-Date -f 'HH:mm:ss')] $Msg" -Encoding UTF8 } catch {} }
Write-EarlyLog "GUI tool started. PS=$($PSVersionTable.PSVersion) User=$($env:USERNAME)"


# .SYNOPSIS
    # MDM On-Demand Actions (MDM-ODA) - PowerShell 7 & WPF tool for Entra ID and Intune on-demand operations.
#
# .DESCRIPTION
    # A single-file PowerShell 7+ WPF GUI application for Entra ID and Intune on-demand
    # operations with an embedded WPF interface. Features include:
      # - Group Management: Search, list members, create, rename, compare, bulk owners,
      #   add user devices, find common/distinct, dynamic membership, object membership
      # - Device Management: Device info, Intune policy assignments across all policy types
      # - Productivity: Session notes, keyword filter, verbose logging, XLSX export
      # - Security: OAuth 2.0 delegated auth, read-only API scopes by default,
      #   validation-before-commit for all write operations, least privilege enforcement
    # All write operations require mandatory Entra validation + user confirmation before execution.
    # Real-time verbose logging in the lower pane, simultaneously written to a local log file.
#
# .NOTES
    # Author      : Satish Singhi
    # Version     : 0.66
    # Requires    : PowerShell 7+, Microsoft.Graph SDK
    # Auth        : Azure App Registration - Delegated permissions (interactive browser)
                  # Connect-MgGraph is called WITHOUT -Scopes parameter
    # Permissions : User.Read, User.Read.All, Group.Read.All, GroupMember.Read.All,
                   # Directory.Read.All, Device.Read.All, DeviceManagementConfiguration.Read.All,
                   # DeviceManagementManagedDevices.Read.All, DeviceManagementRBAC.Read.All, offline_access
#
    # ===================== DEVELOPER CONFIG  -  EDIT THIS SECTION =====================
    # All developer-facing settings are in the #region DEVELOPER CONFIG block below.
    # This includes: theme colours, background image, logo, log path, window size, etc.
    # ================================================================================


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

# ============================================================
#region DEVELOPER CONFIG
# ============================================================

$DevConfig = [ordered]@{

    # ---- Azure App Registration ----
    TenantId = ""
    ClientId = ""

    # ---- Local log file path ----
    # A timestamped log file will be created here each session
    LogFolder = "C:\Logs\MDM-ODA\Logs"

    # ---- Window ----
    WindowTitle  = "MDM On-Demand Actions  v0.66"
    WindowWidth  = 1100
    WindowHeight = 780
    MinWidth     = 900
    MinHeight    = 600

    # ---- Verbose pane ----
    VerbosePaneDefaultHeight = 200   # pixels; user can drag the splitter

    # ---- Background image ----
    # Local file path takes priority over URL. Set both to "" to use solid colour.
    BackgroundImagePath    = ""      # e.g. "C:\Assets\bg.jpg"  (local file)
    BackgroundImageUrl     = ""      # e.g. "https://intranet/assets/bg.jpg"  (web URL)
    BackgroundImageOpacity = 0.08   # 0.0 (invisible) to 1.0 (fully opaque)

    # ---- Logo ----
    # Local file path takes priority over URL. Set both to "" to hide the logo.
    LogoImagePath   = ""            # e.g. "C:\Assets\logo.png"  (local file)
    LogoImageUrl    = ""            # e.g. "https://intranet/assets/logo.png"  (web URL)
    LogoWidth       = 240
    LogoHeight      = 80
    LogoBase64      = ""            # Base64-encoded image (PNG/JPG/ICO). Takes priority over Path/URL.
    LogoTitleSpacing = 12              # Pixels between logo and title text
    # To encode: [Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\logo.png"))
    # To encode from URL: [Convert]::ToBase64String((Invoke-WebRequest "https://...").Content)

    # ---- Colour theme ----
    # Primary colour used for header bar, buttons, accents
    ColorPrimary        = "#007A00"   # Primary green
    ColorPrimaryLight   = "#009900"   # Primary green (hover)
    ColorPrimaryDark    = "#006400"   # Primary green (pressed)
    ColorAccent         = "#007A00"   # Accent green (unified with primary)
    ColorAccentLight    = "#009900"
    ColorBackground     = "#F8FBF8"   # Page background
    ColorSurface        = "#FFFFFF"   # Card / panel background
    ColorBorder         = "#B0D0B0"
    ColorText           = "#1A1F2E"
    ColorTextMuted      = "#5A6480"
    ColorSuccess        = "#1E7A4A"
    ColorWarning        = "#B45B00"
    ColorError          = "#C0392B"
    ColorInfo           = "#1A5FA8"

    # ---- Verbose pane log colours (foreground hex) ----
    LogColorInfo    = "#9DB8D8"
    LogColorSuccess = "#5DC98B"
    LogColorWarning = "#F5A623"
    LogColorError   = "#F06C6C"
    LogColorAction  = "#C8DEF5"

    # ---- Font ----
    FontFamily   = "Segoe UI"
    FontSizeBase = 13

    # ---- Max objects per batch Graph request ----
    GraphBatchSize = 20

    # ---- PIM role auto-refresh interval (minutes). Set to 0 to disable.
    PimRefreshIntervalMinutes = 5

    # ---- Feedback URL (opened when Feedback button is clicked) ----
    # Set to the URL of your feedback form, Teams channel, or any web page.
    # Leave empty to hide the Feedback button.
    FeedbackUrl = ""              # e.g. "https://forms.office.com/yourformid"

    # ---- M365 Group Mail Domain ----
    # Domain suffix shown next to the mail nickname field when creating M365 groups.
    # e.g. "contoso.onmicrosoft.com" or "contoso.com"
    # Leave empty to show only "@" (tenant assigns the domain automatically).
    M365MailDomain = ""           # e.g. "contoso.onmicrosoft.com"
}

#endregion DEVELOPER CONFIG
# ============================================================


# ============================================================
#region BOOTSTRAP  -  STA check & assemblies
# ============================================================

# ── Start transcript immediately for startup diagnostics ─────────────────────
$script:TranscriptStarted = $false
try {
    $tsPath = if ($TranscriptPath -and $TranscriptPath -ne "") {
        $TranscriptPath
    } else {
        Join-Path $env:TEMP ("EGM_Transcript_{0}.log" -f (Get-Date -f "yyyyMMdd_HHmmss"))
    }
    $tsDir = Split-Path $tsPath -Parent
    if (-not (Test-Path $tsDir)) { New-Item -ItemType Directory -Path $tsDir -Force | Out-Null }
    Start-Transcript -Path $tsPath -Append -ErrorAction Stop
    $script:TranscriptStarted = $true
    Write-Host "[STARTUP] Transcript: $tsPath"
} catch {
    Write-Host "[STARTUP] Transcript failed: $($_.Exception.Message)"
}

Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName System.Windows.Forms

# ── WPF Application singleton ─────────────────────────────────────────────────
# Required for transparent/custom-chrome windows (WindowStyle=None).
# ShutdownMode=OnExplicitShutdown keeps the dispatcher alive across
# multiple windows (prereq -> main). Each window uses DispatcherFrame
# to pump its own message loop without calling Application.Run().
if (-not [System.Windows.Application]::Current) {
    $script:WpfApp = [System.Windows.Application]::new()
    $script:WpfApp.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown
} else {
    [System.Windows.Application]::Current.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown
}

# ── DPI awareness  -  must be set before any WPF window is created ──
# Per-Monitor v2 tells WPF to render at native DPI on each monitor,
# preventing the window from being clipped or mis-sized on high-DPI displays.
try {
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class DpiAware {
    [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
    // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
    public static void SetPerMonitorV2() { SetProcessDpiAwarenessContext(new IntPtr(-4)); }
}
'@ -ErrorAction SilentlyContinue
    [DpiAware]::SetPerMonitorV2()
} catch {}

# Ensure we are on an STA thread (required for WPF)
# The orchestrator launcher guarantees -STA; this is a safety net for direct launches.
if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') {
    Write-Error "This script must run on an STA thread. Use the orchestrator launcher, or run: pwsh -STA -File '$PSCommandPath'"
    exit 1
}

# Override LogFolder from orchestrator if provided
if (-not [string]::IsNullOrWhiteSpace($OverrideLogFolder)) {
    $DevConfig.LogFolder = $OverrideLogFolder
    Write-Host "[STARTUP] LogFolder overridden to: $OverrideLogFolder"
}

# Ensure log folder exists
if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder)) {
    if (-not (Test-Path $DevConfig.LogFolder)) {
        New-Item -ItemType Directory -Path $DevConfig.LogFolder -Force | Out-Null
    }
}

$script:SessionStamp = (Get-Date).ToString("yyyyMMdd_HHmmss")
$script:LogFile = if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder)) {
    Join-Path $DevConfig.LogFolder ("MDM-ODA_{0}.log" -f $script:SessionStamp)
} else { $null }

#endregion


# ============================================================
#region PREREQ CHECK WINDOW
# ============================================================

function Start-PrereqCheck {

    # Prereq window  -  runs checks on a background runspace.
    # Results are posted to a ConcurrentQueue drained by a DispatcherTimer.
    # No STA thread blocking. Find-Module (network) runs in the same runspace.


    $requiredModules = @(
        [PSCustomObject]@{ Name = 'Microsoft.Graph.Authentication';               Label = 'Graph . Authentication' }
        [PSCustomObject]@{ Name = 'Microsoft.Graph.Groups';                       Label = 'Graph . Groups' }
        [PSCustomObject]@{ Name = 'Microsoft.Graph.Users';                        Label = 'Graph . Users' }
        [PSCustomObject]@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; Label = 'Graph . Directory Management' }
    )
    $optionalModules = @(
        [PSCustomObject]@{ Name = 'ImportExcel'; Label = 'ImportExcel (XLSX export)' }
    )

    # ── Shared queue between runspace and UI ──
    Write-Host "[PREREQ] Entering Start-PrereqCheck"
    $queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
    Write-Host "[PREREQ] Queue created"

    # ── Build XAML (simple Grid rows  -  no data binding) ──
    [xml]$prereqXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="$($DevConfig.WindowTitle)  -  System Check"
        Width="640" Height="560" MinWidth="520" MinHeight="420"
        WindowStartupLocation="CenterScreen" ResizeMode="CanResize"
        Background="#F2FAF2" FontFamily="Segoe UI" FontSize="13"
        UseLayoutRounding="True"
        SnapsToDevicePixels="True"
        TextOptions.TextFormattingMode="Display"
        TextOptions.TextRenderingMode="ClearType"
        RenderOptions.ClearTypeHint="Enabled">
  <Window.Resources>
    <ControlTemplate x:Key="PrereqBtnTpl" TargetType="Button">
      <Grid>
        <Border x:Name="BtnBd"
                Background="{TemplateBinding Background}"
                CornerRadius="9"
                Padding="{TemplateBinding Padding}"
                SnapsToDevicePixels="True">
          <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
        </Border>
        <Border CornerRadius="9,9,0,0" Height="14" VerticalAlignment="Top"
                IsHitTestVisible="False" Margin="1,1,1,0"
                SnapsToDevicePixels="True"
                RenderOptions.ClearTypeHint="Enabled">
          <Border.Background>
            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
              <GradientStop Color="#33FFFFFF" Offset="0"/>
              <GradientStop Color="#0AFFFFFF" Offset="0.6"/>
              <GradientStop Color="#00FFFFFF" Offset="1"/>
            </LinearGradientBrush>
          </Border.Background>
        </Border>
      </Grid>
      <ControlTemplate.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
          <Setter TargetName="BtnBd" Property="Opacity" Value="0.88"/>
        </Trigger>
        <Trigger Property="IsPressed" Value="True">
          <Setter TargetName="BtnBd" Property="Opacity" Value="0.72"/>
        </Trigger>
        <Trigger Property="IsEnabled" Value="False">
          <Setter TargetName="BtnBd" Property="Opacity" Value="0.4"/>
        </Trigger>
      </ControlTemplate.Triggers>
    </ControlTemplate>
  </Window.Resources>
  <DockPanel>
    <!-- Green gradient header bar (matches main tool) -->
    <Border DockPanel.Dock="Top" Padding="14,12">
      <Border.Background>
        <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
          <GradientStop Color="#006400" Offset="0"/>
          <GradientStop Color="#009900" Offset="1"/>
        </LinearGradientBrush>
      </Border.Background>
      <Border.BorderBrush>
        <SolidColorBrush Color="#0FFFFFFF"/>
      </Border.BorderBrush>
      <Border.BorderThickness>0,0,0,1</Border.BorderThickness>
      <StackPanel HorizontalAlignment="Center" RenderOptions.ClearTypeHint="Enabled">
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
          <Image x:Name="PrereqLogoImg" Height="32" MaxWidth="120"
                 Stretch="Uniform" VerticalAlignment="Center" Visibility="Collapsed"
                 Margin="0,0,10,0"/>
          <TextBlock Text="System Prerequisites Check" FontSize="16" FontWeight="ExtraBold"
                     Foreground="#F1F9F1" VerticalAlignment="Center"/>
        </StackPanel>
        <TextBlock Text="Verifying all requirements before launching the tool."
                   FontSize="11" Foreground="#CCFFCC" HorizontalAlignment="Center" Margin="0,3,0,0"/>
      </StackPanel>
    </Border>

    <!-- Content area -->
    <Grid Margin="20,14,20,20">
      <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
        <RowDefinition Height="130"/>
        <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>

      <Border Grid.Row="0" Background="White" BorderBrush="#B0D0B0" BorderThickness="1" SnapsToDevicePixels="True"
              CornerRadius="12" Margin="0,0,0,10">
        <Border.Effect>
          <DropShadowEffect BlurRadius="16" ShadowDepth="3" Direction="270" Color="#000000" Opacity="0.10"/>
        </Border.Effect>
        <ScrollViewer VerticalScrollBarVisibility="Auto">
          <StackPanel x:Name="SpChecks" Margin="4,4,4,4"/>
        </ScrollViewer>
      </Border>

      <Border Grid.Row="1" Background="#0D1824" CornerRadius="8" Margin="0,0,0,10" SnapsToDevicePixels="True">
        <RichTextBox x:Name="RtbPrereqLog" Background="Transparent" BorderThickness="0"
                     IsReadOnly="True" FontFamily="Consolas" FontSize="10.5"
                     Foreground="#9DB8D8" Padding="8,5"
                     VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"/>
      </Border>

      <Grid Grid.Row="2">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="*"/>
          <ColumnDefinition Width="Auto"/>
          <ColumnDefinition Width="8"/>
          <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>
        <TextBlock x:Name="TxtPrereqSummary" Grid.Column="0" FontSize="11"
                   Foreground="#5A6480" VerticalAlignment="Center" TextWrapping="Wrap"/>
        <Button x:Name="BtnPrereqInstall" Grid.Column="1" Content="Install Missing"
                Foreground="White" BorderThickness="0"
                Padding="14,8" FontWeight="SemiBold" Cursor="Hand" Visibility="Collapsed"
                Template="{StaticResource PrereqBtnTpl}">
          <Button.Background>
            <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
              <GradientStop Color="#006400" Offset="0"/>
              <GradientStop Color="#009900" Offset="1"/>
            </LinearGradientBrush>
          </Button.Background>
        </Button>
        <Button x:Name="BtnPrereqContinue" Grid.Column="3" Content="Continue &#x2192;"
                Foreground="White" BorderThickness="0"
                Padding="14,8" FontWeight="SemiBold" Cursor="Hand" IsEnabled="False"
                Template="{StaticResource PrereqBtnTpl}">
          <Button.Background>
            <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
              <GradientStop Color="#006400" Offset="0"/>
              <GradientStop Color="#009900" Offset="1"/>
            </LinearGradientBrush>
          </Button.Background>
        </Button>
      </Grid>
    </Grid>
  </DockPanel>
</Window>
"@

    Write-Host "[PREREQ] Parsing XAML..."
    $reader    = [System.Xml.XmlNodeReader]::new($prereqXaml)
    Write-Host "[PREREQ] XmlNodeReader ready"
    $prereqWin = [Windows.Markup.XamlReader]::Load($reader)
    Write-Host "[PREREQ] Window loaded: $($prereqWin -ne $null)"
    Write-Host "[PREREQ] Wiring elements..."
    $spChecks     = $prereqWin.FindName('SpChecks')
    $rtbLog       = $prereqWin.FindName('RtbPrereqLog')
    $btnInstall   = $prereqWin.FindName('BtnPrereqInstall')
    $btnContinue  = $prereqWin.FindName('BtnPrereqContinue')
    $txtSummary   = $prereqWin.FindName('TxtPrereqSummary')
    $prereqLogo   = $prereqWin.FindName('PrereqLogoImg')

    # ── Load logo into prereq header (same DevConfig sources as main tool) ──
    try {
        $logoSrc = $null
        # Priority 1: Base64
        if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogoBase64)) {
            $bytes = [Convert]::FromBase64String($DevConfig.LogoBase64.Trim())
            $ms    = [System.IO.MemoryStream]::new($bytes)
            $bmp   = [System.Windows.Media.Imaging.BitmapImage]::new()
            $bmp.BeginInit()
            $bmp.StreamSource = $ms
            $bmp.CacheOption  = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
            $bmp.EndInit(); $null = $bmp.Freeze()
            $logoSrc = $bmp
        }
        # Priority 2: Local path
        if (-not $logoSrc -and -not [string]::IsNullOrWhiteSpace($DevConfig.LogoImagePath) -and (Test-Path $DevConfig.LogoImagePath)) {
            $logoSrc = [System.Windows.Media.Imaging.BitmapFrame]::Create([Uri]::new((Resolve-Path $DevConfig.LogoImagePath).Path))
        }
        # Priority 3: URL
        if (-not $logoSrc -and -not [string]::IsNullOrWhiteSpace($DevConfig.LogoImageUrl)) {
            $bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
            $bmp.BeginInit()
            $bmp.UriSource   = [Uri]::new($DevConfig.LogoImageUrl)
            $bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
            $bmp.EndInit()
            $logoSrc = $bmp
        }
        if ($logoSrc) {
            $prereqLogo.Source     = $logoSrc
            $prereqLogo.Visibility = 'Visible'
            Write-Host "[PREREQ] Logo loaded into header"
        }
    } catch { Write-Host "[PREREQ] Logo load skipped: $($_.Exception.Message)" }
    Write-Host "[PREREQ] Elements wired ok"

    $script:PrereqResult     = $false
    $script:MissingMandatory = [System.Collections.Generic.List[string]]::new()
    $script:MissingOptional  = [System.Collections.Generic.List[string]]::new()
    $script:UpdateAvailable  = [System.Collections.Generic.List[string]]::new()

    # Row registry: key = row-id string, value = hashtable of TextBlock refs
    $rowRegistry = [hashtable]::Synchronized(@{})

    # ── UI helpers (run on dispatcher) ──────────────────────────────────────
    function Add-CheckRow {
        param([string]$RowId, [string]$Label, [string]$Detail)
        $prereqWin.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
            $outer = [System.Windows.Controls.Grid]::new()
            $outer.Margin = [System.Windows.Thickness]::new(8,5,8,5)
            $c0 = [System.Windows.Controls.ColumnDefinition]::new(); $c0.Width = [System.Windows.GridLength]::new(22)
            $c1 = [System.Windows.Controls.ColumnDefinition]::new(); $c1.Width = [System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Star)
            $c2 = [System.Windows.Controls.ColumnDefinition]::new(); $c2.Width = [System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Auto)
            $outer.ColumnDefinitions.Add($c0); $outer.ColumnDefinitions.Add($c1); $outer.ColumnDefinitions.Add($c2)

            # Icon — hourglass U+231B, rendered in Segoe UI Symbol for reliable glyph
            $tbIcon = [System.Windows.Controls.TextBlock]::new()
            $tbIcon.Text = [string][char]0x231B; $tbIcon.FontSize = 13
            $tbIcon.FontFamily = [System.Windows.Media.FontFamily]::new("Segoe UI Symbol, Segoe UI Emoji, Segoe UI")
            $tbIcon.VerticalAlignment = 'Center'
            $tbIcon.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#999999'))
            $tbIcon.Opacity = 0.45
            # Opacity pulse animation (pre-built, started on demand)
            $pulseAnim = [System.Windows.Media.Animation.DoubleAnimation]::new()
            $pulseAnim.From = 0.35; $pulseAnim.To = 1.0
            $pulseAnim.Duration = [System.Windows.Duration]::new([TimeSpan]::FromMilliseconds(700))
            $pulseAnim.AutoReverse = $true
            $pulseAnim.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever
            [System.Windows.Controls.Grid]::SetColumn($tbIcon, 0)

            $inner = [System.Windows.Controls.StackPanel]::new()
            $inner.Margin = [System.Windows.Thickness]::new(6,0,0,0)
            $inner.VerticalAlignment = 'Center'
            $tbLabel = [System.Windows.Controls.TextBlock]::new()
            $tbLabel.Text = $Label; $tbLabel.FontSize = 12; $tbLabel.FontWeight = 'SemiBold'
            $tbLabel.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#888888'))
            $tbDetail = [System.Windows.Controls.TextBlock]::new()
            $tbDetail.Text = $Detail; $tbDetail.FontSize = 10.5; $tbDetail.TextWrapping = 'Wrap'
            $tbDetail.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#AAAAAA'))
            $tbDetail.Margin = [System.Windows.Thickness]::new(0,1,0,0)
            $inner.Children.Add($tbLabel) | Out-Null
            $inner.Children.Add($tbDetail) | Out-Null
            [System.Windows.Controls.Grid]::SetColumn($inner, 1)

            $tbStatus = [System.Windows.Controls.TextBlock]::new()
            $tbStatus.Text = 'Queued'; $tbStatus.FontSize = 11; $tbStatus.FontWeight = 'SemiBold'
            $tbStatus.VerticalAlignment = 'Center'
            $tbStatus.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#AAAAAA'))
            [System.Windows.Controls.Grid]::SetColumn($tbStatus, 2)

            $outer.Children.Add($tbIcon)   | Out-Null
            $outer.Children.Add($inner)    | Out-Null
            $outer.Children.Add($tbStatus) | Out-Null
            $spChecks.Children.Add($outer) | Out-Null

            $rowRegistry[$RowId] = @{ Icon = $tbIcon; Label = $tbLabel; Detail = $tbDetail; Status = $tbStatus; Outer = $outer; PulseAnimation = $pulseAnim }
        })
    }

    function Apply-RowUpdate {
        param([hashtable]$Msg)
        $rid = $Msg['RowId']
        if (-not $rowRegistry.ContainsKey($rid)) { return }
        $row = $rowRegistry[$rid]
        # Stop opacity pulse animation
        $row['Icon'].BeginAnimation([System.Windows.UIElement]::OpacityProperty, $null)
        $row['Icon'].Opacity = 1.0
        # Reset icon font to default for result glyphs (tick/cross render fine in Segoe UI)
        $row['Icon'].FontFamily = [System.Windows.Media.FontFamily]::new("Segoe UI")
        # Clear row highlight background
        if ($row['Outer']) { $row['Outer'].Background = $null }
        # Apply field updates
        if ($Msg['Icon'])        { $row['Icon'].Text = $Msg['Icon'] }
        if ($Msg['Status'])      { $row['Status'].Text = $Msg['Status'] }
        if ($Msg['Detail'])      { $row['Detail'].Text = $Msg['Detail'] }
        if ($Msg['LabelColor'])  { $row['Label'].Foreground  = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['LabelColor'])) }
        if ($Msg['StatusColor']) { $row['Status'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['StatusColor'])) }
        if ($Msg['IconColor'])   { $row['Icon'].Foreground   = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['IconColor'])) }
    }

    function Append-LogLine {
        param([string]$Line, [string]$Color = '#9DB8D8')
        $para = [System.Windows.Documents.Paragraph]::new()
        $run  = [System.Windows.Documents.Run]::new($Line)
        $run.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Color))
        $para.Margin = [System.Windows.Thickness]::new(0)
        $para.Inlines.Add($run)
        $rtbLog.Document.Blocks.Add($para)
        $rtbLog.ScrollToEnd()
    }

    # ── Pre-create all rows before runspace starts ───────────────────────────
    Add-CheckRow 'ps'     'PowerShell 7+'              'Required for WPF and modern syntax'
    Add-CheckRow 'os'     'Windows OS + WPF'           'WPF requires Windows'
    Add-CheckRow 'ep'     'Execution Policy'           'Must allow script execution'
    foreach ($m in $requiredModules) { Add-CheckRow $m.Name $m.Label "Required module  -  $($m.Name)" }
    foreach ($m in $optionalModules) { Add-CheckRow $m.Name $m.Label "Optional  -  $($m.Name)" }

    # ── Launch background runspace ───────────────────────────────────────────
    $rs = [runspacefactory]::CreateRunspace()
    $rs.ApartmentState = 'MTA'
    $rs.ThreadOptions  = 'ReuseThread'
    $rs.Open()
    $rs.SessionStateProxy.SetVariable('Queue',           $queue)
    $rs.SessionStateProxy.SetVariable('RequiredModules', $requiredModules)
    $rs.SessionStateProxy.SetVariable('OptionalModules', $optionalModules)

    $ps = [powershell]::Create()
    $ps.Runspace = $rs

    $null = $ps.AddScript({
        function Q {
            param([hashtable]$Msg)
            $Queue.Enqueue($Msg)
        }
        function TS { (Get-Date).ToString('HH:mm:ss') }

        Q @{ Type='log'; Line="[$(TS)] Starting prerequisite checks..."; Color='#C8DEF5' }
        Start-Sleep -Milliseconds 200

        # 1. PowerShell version
        Q @{ Type='checking'; RowId='ps' }
        Start-Sleep -Milliseconds 250
        Q @{ Type='log'; Line="[$(TS)]   Checking PowerShell version..."; Color='#9DB8D8' }
        $psv = $PSVersionTable.PSVersion
        if ($psv.Major -ge 7) {
            Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2713) PowerShell $($psv.ToString())"; Color='#5DC98B'; RowId='ps'; Icon="$([char]0x2713)"; Status="OK  v$($psv.Major).$($psv.Minor).$($psv.Build)"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D' }
        } else {
            Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2717) PowerShell $($psv.ToString())  -  need 7+"; Color='#F06C6C'; RowId='ps'; Icon="$([char]0x2717)"; Status="FAIL  v$($psv.ToString())"; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000' }
            Q @{ Type='mandatory'; Item='PowerShell 7+   -   https://github.com/PowerShell/PowerShell/releases' }
        }

        # 2. Windows OS
        Q @{ Type='checking'; RowId='os' }
        Start-Sleep -Milliseconds 250
        Q @{ Type='log'; Line="[$(TS)]   Checking OS..."; Color='#9DB8D8' }
        if ($IsWindows) {
            Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2713) Windows  -  WPF available"; Color='#5DC98B'; RowId='os'; Icon="$([char]0x2713)"; Status="OK  Windows"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail=[System.Environment]::OSVersion.VersionString }
        } else {
            Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2717) Not Windows  -  tool is Windows-only"; Color='#F06C6C'; RowId='os'; Icon="$([char]0x2717)"; Status='NOT WINDOWS'; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000' }
            Q @{ Type='mandatory'; Item='Windows OS   -   WPF is Windows-only' }
        }

        # 3. Execution Policy
        Q @{ Type='checking'; RowId='ep' }
        Start-Sleep -Milliseconds 250
        Q @{ Type='log'; Line="[$(TS)]   Checking execution policy..."; Color='#9DB8D8' }
        $ep  = Get-ExecutionPolicy -Scope CurrentUser
        $epm = Get-ExecutionPolicy -Scope LocalMachine
        $epOk = $ep -in @('RemoteSigned','Unrestricted','Bypass') -or $epm -in @('RemoteSigned','Unrestricted','Bypass')
        if ($epOk) {
            Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2713) Execution policy: $ep"; Color='#5DC98B'; RowId='ep'; Icon="$([char]0x2713)"; Status="OK  ($ep)"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D' }
        } else {
            Q @{ Type='rowlog'; Line="[$(TS)]   [!] Policy '$ep' may block scripts"; Color='#F5A623'; RowId='ep'; Icon='[!]'; Status="$ep"; StatusColor='#B45B00' }
            Q @{ Type='mandatory'; Item="ExecutionPolicy   -   run: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" }
        }

        # 4. Required modules (local check only, fast)
        foreach ($mod in $RequiredModules) {
            Q @{ Type='checking'; RowId=$mod.Name }
            Start-Sleep -Milliseconds 250
            Q @{ Type='log'; Line="[$(TS)]   Checking $($mod.Name)..."; Color='#9DB8D8' }
            $inst = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue |
                    Sort-Object Version -Descending | Select-Object -First 1
            if ($inst) {
                $v = $inst.Version.ToString()
                Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2713) $($mod.Name)  v$v"; Color='#5DC98B'; RowId=$mod.Name; Icon="$([char]0x2713)"; Status="OK  v$v"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail="Installed: v$v" }
                Q @{ Type='installed'; ModName=$mod.Name; Version=$v }
            } else {
                Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2717) $($mod.Name)  -  NOT INSTALLED"; Color='#F06C6C'; RowId=$mod.Name; Icon="$([char]0x2717)"; Status='NOT INSTALLED'; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000'; Detail="Missing" }
                Q @{ Type='mandatory'; Item=$mod.Name }
            }
        }

        # 5. Optional modules
        foreach ($mod in $OptionalModules) {
            Q @{ Type='checking'; RowId=$mod.Name }
            Start-Sleep -Milliseconds 250
            Q @{ Type='log'; Line="[$(TS)]   Checking $($mod.Name) (optional)..."; Color='#9DB8D8' }
            $inst = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue |
                    Sort-Object Version -Descending | Select-Object -First 1
            if ($inst) {
                $v = $inst.Version.ToString()
                Q @{ Type='rowlog'; Line="[$(TS)]   $([char]0x2713) $($mod.Name)  v$v"; Color='#5DC98B'; RowId=$mod.Name; Icon="$([char]0x2713)"; Status="OK  v$v"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail="Installed: v$v" }
                Q @{ Type='installed'; ModName=$mod.Name; Version=$v }
            } else {
                Q @{ Type='rowlog'; Line="[$(TS)]   o $($mod.Name) not installed (optional)"; Color='#F5A623'; RowId=$mod.Name; Icon='o'; Status='Not installed'; StatusColor='#B45B00'; Detail='Optional  -  export falls back to CSV' }
                Q @{ Type='optional'; Item=$mod.Name }
            }
        }

        Q @{ Type='log'; Line="[$(TS)] ---------------------------------"; Color='#333D4D' }
        Q @{ Type='log'; Line="[$(TS)]   Local checks complete. Checking for module updates online..."; Color='#9DB8D8' }

        # 6. Online update check (slow  -  runs after local checks)
        $allModNames = ($RequiredModules + $OptionalModules).Name
        foreach ($modName in $allModNames) {
            $inst = Get-Module -ListAvailable -Name $modName -ErrorAction SilentlyContinue |
                    Sort-Object Version -Descending | Select-Object -First 1
            if (-not $inst) { continue }
            try {
                Q @{ Type='log'; Line="[$(TS)]   Checking online: $modName..."; Color='#9DB8D8' }
                $gallery = Find-Module -Name $modName -ErrorAction SilentlyContinue
                if ($gallery -and [version]$gallery.Version -gt [version]$inst.Version) {
                    Q @{ Type='rowlog'; Line="[$(TS)]   ^ Update: $modName  $($inst.Version) -> $($gallery.Version)"; Color='#9DB8D8'; RowId=$modName; Icon='^'; Status="Update $($gallery.Version)"; StatusColor='#1A5FA8'; Detail="Installed: v$($inst.Version)  ->  Available: v$($gallery.Version)" }
                    Q @{ Type='update'; Item=$modName }
                } else {
                    Q @{ Type='log'; Line="[$(TS)]   $([char]0x2713) $modName up to date"; Color='#5DC98B' }
                }
            } catch {
                Q @{ Type='log'; Line="[$(TS)]   [!] Could not check online for $modName"; Color='#F5A623' }
            }
        }

        Q @{ Type='log'; Line="[$(TS)] ---------------------------------"; Color='#333D4D' }
        Q @{ Type='done' }
    })

    $bgHandle = $ps.BeginInvoke()

    # ── DispatcherTimer  -  drains queue on UI thread ──────────────────────────
    $timer = [System.Windows.Threading.DispatcherTimer]::new()
    $timer.Interval = [TimeSpan]::FromMilliseconds(120)

    $timer.Add_Tick({
        try {
        $msg = $null
        while ($queue.TryDequeue([ref]$msg)) {
            switch ($msg['Type']) {
                'log' {
                    Append-LogLine -Line $msg['Line'] -Color $msg['Color']
                }
                'checking' {
                    $rid = $msg['RowId']
                    if ($rowRegistry.ContainsKey($rid)) {
                        $row = $rowRegistry[$rid]
                        # Activate: pulsing hourglass, teal colour, row highlight
                        $row['Icon'].Text = [string][char]0x231B
                        $row['Icon'].FontFamily = [System.Windows.Media.FontFamily]::new('Segoe UI Symbol, Segoe UI Emoji, Segoe UI')
                        $row['Icon'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#0A9396'))
                        $row['Status'].Text = 'Checking...'
                        $row['Status'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#0A9396'))
                        $row['Label'].Foreground  = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#1A1F2E'))
                        $row['Detail'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#5A6480'))
                        $row['Outer'].Background  = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#F0FAF0'))
                        # Start opacity pulse animation
                        $row['Icon'].BeginAnimation([System.Windows.UIElement]::OpacityProperty, $row['PulseAnimation'])
                    }
                }
                'row' {
                    Apply-RowUpdate -Msg $msg
                }
                'rowlog' {
                    # Atomic: render log line THEN row update in same dispatcher callback
                    Append-LogLine -Line $msg['Line'] -Color $msg['Color']
                    Apply-RowUpdate -Msg $msg
                }
                'mandatory' {
                    $script:MissingMandatory.Add($msg['Item'])
                }
                'optional' {
                    $script:MissingOptional.Add($msg['Item'])
                }
                'update' {
                    $script:UpdateAvailable.Add($msg['Item'])
                }
                'done' {
                    $timer.Stop()
                    $ps.EndInvoke($bgHandle)
                    $rs.Close(); $ps.Dispose()

                    $hasMand  = $script:MissingMandatory.Count -gt 0
                    $hasOpt   = $script:MissingOptional.Count -gt 0
                    $hasUpd   = $script:UpdateAvailable.Count -gt 0

                    # Summary
                    $parts = @()
                    if ($hasMand) { $parts += "$($script:MissingMandatory.Count) required missing" }
                    if ($hasOpt)  { $parts += "$($script:MissingOptional.Count) optional missing" }
                    if ($hasUpd)  { $parts += "$($script:UpdateAvailable.Count) update(s) available" }
                    if ($parts.Count -eq 0) {
                        $txtSummary.Text = "$([char]0x2713)  All checks passed."
                        Append-LogLine "[$(Get-Date -f 'HH:mm:ss')]   $([char]0x2713) All checks passed  -  ready to launch." '#5DC98B'
                    } else {
                        $txtSummary.Text = $parts -join '   |   '
                        if ($hasMand) { Append-LogLine "[$(Get-Date -f 'HH:mm:ss')]   $([char]0x2717) Mandatory items missing." '#F06C6C' }
                    }

                    # Install button
                    $installable = [System.Collections.Generic.List[string]]::new()
                    $allModNames = ($requiredModules + $optionalModules).Name
                    foreach ($item in ($script:MissingMandatory + $script:MissingOptional + $script:UpdateAvailable)) {
                        if ($allModNames -contains $item) { $installable.Add($item) }
                    }
                    if ($installable.Count -gt 0) {
                        $btnInstall.Visibility = 'Visible'
                        if   ($hasMand -and $hasUpd) { $btnInstall.Content = 'Install Missing + Update' }
                        elseif ($hasMand)             { $btnInstall.Content = 'Install Missing' }
                        elseif ($hasUpd)              { $btnInstall.Content = 'Update Modules' }
                        else                          { $btnInstall.Content = 'Install Optional' }
                    }

                     if (-not $hasMand) {
                         $btnContinue.IsEnabled = $true
                         $txtSummary.Text = "✓  All checks passed  -  click Continue to proceed"
                     }
                }
            }
        }
        } catch {
            Write-ErrorLog "PrereqTimer error: $($_.Exception.Message)"
        }
    })

    $timer.Start()

    # ── Native WPF confirm dialog (replaces MessageBox) ──────────────────────
    function Show-PrereqConfirm {
        param([string]$Title, [string]$Message, [bool]$IsWarning = $false)
        # Title and Message set in code  -  never interpolated into XML (avoids
        # XML-special chars: &, <, >, newlines breaking the document parse)
        [xml]$dlgXml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Confirm" Width="480" SizeToContent="Height"
        WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
        Background="#F8FBF8" FontFamily="Segoe UI" FontSize="13"
        UseLayoutRounding="True" SnapsToDevicePixels="True"
        TextOptions.TextFormattingMode="Display"
        TextOptions.TextRenderingMode="ClearType">
  <Grid Margin="22,18,22,18">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="14"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <TextBlock x:Name="TxtMsg" Grid.Row="0" TextWrapping="Wrap" LineHeight="20"
               Foreground="#1A1F2E" FontSize="12"/>
    <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
      <Button x:Name="BtnNo" Content="Cancel" Width="90" Padding="0,8" Margin="0,0,10,0"
              Background="Transparent" BorderBrush="#B0D0B0" BorderThickness="1"
              Cursor="Hand" FontSize="12">
        <Button.Template>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="5" Padding="{TemplateBinding Padding}">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Background" Value="#E8F5E8"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Button.Template>
      </Button>
      <Button x:Name="BtnYes" Content="Proceed" Width="100" Padding="0,8"
              Background="#007A00" BorderThickness="0" Cursor="Hand"
              FontSize="12" FontWeight="SemiBold" Foreground="White">
        <Button.Template>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}" CornerRadius="5"
                    Padding="{TemplateBinding Padding}">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Background" Value="#009900"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Button.Template>
      </Button>
    </StackPanel>
  </Grid>
</Window>
'@
        $dlgReader = [System.Xml.XmlNodeReader]::new($dlgXml)
        $dlg       = [Windows.Markup.XamlReader]::Load($dlgReader)
        $dlg.Owner = $prereqWin
        $dlg.Title = $Title
        $dlg.FindName('TxtMsg').Text = $Message

        # Colour the Proceed button  -  evaluate colour before passing to method
        $yesBtn    = $dlg.FindName('BtnYes')
        $btnColor  = if ($IsWarning) { '#C0392B' } else { '#007A00' }
        $yesBtn.Background = [System.Windows.Media.SolidColorBrush]::new(
            [System.Windows.Media.ColorConverter]::ConvertFromString($btnColor))

        $script:PrereqDlgResult = $false
        $dlg.FindName('BtnNo').Add_Click({ $script:PrereqDlgResult = $false; $dlg.Close() })
        $yesBtn.Add_Click({ $script:PrereqDlgResult = $true; $dlg.Close() })
        $dlg.ShowDialog() | Out-Null
        return $script:PrereqDlgResult
    }

    # ── Install button ────────────────────────────────────────────────────────
    $btnInstall.Add_Click({
        $allModNames = ($requiredModules + $optionalModules).Name
        $toInstall   = [System.Collections.Generic.List[string]]::new()
        foreach ($item in ($script:MissingMandatory + $script:MissingOptional + $script:UpdateAvailable)) {
            if ($allModNames -contains $item -and -not $toInstall.Contains($item)) {
                $toInstall.Add($item)
            }
        }
        if ($toInstall.Count -eq 0) {
            $null = Show-PrereqConfirm -Title "Nothing to Install" `
                -Message "No installable modules found.`nPS version, OS and execution policy issues must be resolved manually."
            return
        }

        $listTxt = ($toInstall | ForEach-Object { "  - $_" }) -join "`n"

        # Install using CurrentUser scope  -  no admin rights required
        $confirmMsg  = "The following modules will be installed or updated:`n`n$listTxt`n`n"
        $confirmMsg += "Modules will be installed in CurrentUser scope (no admin required)."
        if (-not (Show-PrereqConfirm -Title "Install Modules" -Message $confirmMsg)) { return }

            $btnInstall.IsEnabled = $false
            $btnContinue.IsEnabled = $false
            $btnInstall.Content   = "[wait] Installing..."
            Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] -- Installing modules (elevated session) --" '#C8DEF5'

            # Use a runspace so Install-Module output streams to our queue live
            # All vars promoted to script scope  -  closure capture is unreliable in WPF handlers
            $script:InstallQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
            $script:InstallRs = [runspacefactory]::CreateRunspace()
            $script:InstallRs.ApartmentState = 'MTA'
            $script:InstallRs.ThreadOptions  = 'ReuseThread'
            $script:InstallRs.Open()
            $script:InstallRs.SessionStateProxy.SetVariable('InstallQueue', $script:InstallQueue)
            $script:InstallRs.SessionStateProxy.SetVariable('ModulesToInstall', $toInstall)

            $script:InstallPs = [powershell]::Create()
            $script:InstallPs.Runspace = $script:InstallRs
            $null = $script:InstallPs.AddScript({
                function IQ { param([hashtable]$M) $InstallQueue.Enqueue($M) }
                function TS  { (Get-Date).ToString('HH:mm:ss') }
                $allOk = $true
                foreach ($modName in $ModulesToInstall) {
                    IQ @{ Type='log'; Line="[$(TS)]   Installing $modName ..."; Color='#C8DEF5' }
                    try {
                        # Redirect verbose/progress into variable to avoid console output
                        $null = Install-Module $modName -Scope CurrentUser -Force -AllowClobber `
                            -ErrorAction Stop -Verbose 4>&1 | ForEach-Object {
                                IQ @{ Type='log'; Line="[$(TS)]     $_"; Color='#9DB8D8' }
                            }
                        IQ @{ Type='log'; Line="[$(TS)]   $([char]0x2713) $modName  -  done."; Color='#5DC98B' }
                    } catch {
                        IQ @{ Type='log'; Line="[$(TS)]   $([char]0x2717) $modName  -  failed: $($_.Exception.Message)"; Color='#F06C6C' }
                        $allOk = $false
                    }
                }
                IQ @{ Type='done'; AllOk=$allOk }
            })
            $script:InstallHandle = $script:InstallPs.BeginInvoke()

            # Drain install queue via a separate timer
            $script:InstallTimer = [System.Windows.Threading.DispatcherTimer]::new()
            $script:InstallTimer.Interval = [TimeSpan]::FromMilliseconds(150)
            $script:InstallTimer.Add_Tick({
                $m = $null
                while ($script:InstallQueue.TryDequeue([ref]$m)) {
                    if ($m['Type'] -eq 'log') {
                        Append-LogLine $m['Line'] $m['Color']
                    } elseif ($m['Type'] -eq 'done') {
                        $script:InstallTimer.Stop()
                        $script:InstallPs.EndInvoke($script:InstallHandle)
                        $script:InstallRs.Close(); $script:InstallPs.Dispose()

                        if ($m['AllOk']) {
                            Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2713) All modules installed successfully." '#5DC98B'
                            Append-LogLine "[$(Get-Date -f 'HH:mm:ss')]   Note: A PowerShell session restart may be needed" '#F5A623'
                            Append-LogLine "[$(Get-Date -f 'HH:mm:ss')]   for newly installed modules to load correctly." '#F5A623'
                        } else {
                            Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] [!] Some modules failed  -  see log above." '#F5A623'
                        }

                        # Show native restart prompt
                        $restartMsg  = "Modules installed successfully.`n`n"
                        $restartMsg += "PowerShell modules load at session start  -  to ensure the`n"
                        $restartMsg += "tool uses the updated versions, it needs to relaunch in a`n"
                        $restartMsg += "fresh session.`n`nRelaunch now?"

                        $btnInstall.Content    = "Install Missing"
                        $btnInstall.IsEnabled  = $true

                        if (Show-PrereqConfirm -Title "Relaunch Required" -Message $restartMsg) {
                            Append-LogLine "[$(Get-Date -f 'HH:mm:ss')]   Relaunching tool in new session..." '#C8DEF5'
                            $scriptPath = $PSCommandPath
                            Start-Process pwsh -ArgumentList "-STA -File `"$scriptPath`""
                            $prereqWin.Close()
                            [System.Environment]::Exit(0)
                        } else {
                            # User declined relaunch  -  re-run checks so they see current state
                            $script:RerunPrereq = $true
                            $script:PrereqResult = $false
                            $prereqWin.Close()
                        }
                    }
                }
            })
            $script:InstallTimer.Start()
            return
    })

    # ── Continue / Close ──────────────────────────────────────────────────────
    $btnContinue.Add_Click({
        $script:PrereqResult = $true
        # Show feedback before window closes - main window takes ~20s to build
        $btnContinue.IsEnabled = $false
        $btnContinue.Content   = 'Loading...'
        $btnInstall.IsEnabled  = $false
        if ($txtSummary) { $txtSummary.Text = 'Building main window, please wait...' }
        $prereqWin.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [Action]{})
        $prereqWin.Close()
    })

    $prereqWin.Add_Closed({
        $timer.Stop()
        if (-not $script:PrereqResult) { exit 0 }
    })

    $prereqWin.ShowDialog() | Out-Null
    return $script:PrereqResult
}



# ── Error log + trap defined BEFORE prereq so the trap can log errors ──
# ── Diagnostic: log any unhandled errors to a temp file ──────────────────────
# Visible error log so we can diagnose failures when the console is hidden.
# Remove once stable.
$_errDir = if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder) -and
             (Test-Path $DevConfig.LogFolder)) { $DevConfig.LogFolder } else { $env:TEMP }
$script:ErrorLogPath = Join-Path $_errDir ("EGM_Error_{0}.log" -f $script:SessionStamp)
try { Remove-Item $script:ErrorLogPath -Force -ErrorAction SilentlyContinue } catch {}
Write-Host "[STARTUP] Error log: $script:ErrorLogPath"
function Write-ErrorLog {
    param([string]$Msg)
    try {
        $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
        Add-Content -Path $script:ErrorLogPath -Value "[$ts] $Msg" -Encoding UTF8
    } catch {}
}
Write-ErrorLog "Prereq passed. Building main window..."
Write-ErrorLog "PS Version  : $($PSVersionTable.PSVersion)"
Write-ErrorLog "OS Version  : $([System.Environment]::OSVersion.VersionString)"
Write-ErrorLog "User        : $($env:USERNAME)@$($env:USERDOMAIN)"
Write-ErrorLog "LogFolder   : $($DevConfig.LogFolder)"
Write-ErrorLog "Error log   : $script:ErrorLogPath"
Write-ErrorLog "Transcript  : $(if ($script:TranscriptStarted) { $tsPath } else { 'NOT STARTED' })"
$Error.Clear()
Write-EarlyLog "Checkpoint 1: Starting XAML definition..."
Write-ErrorLog "Checkpoint 1: Starting XAML definition..."

# ── Catch-all trap  -  logs any terminating error to the error log ─────────────
trap {
    $errMsg = "TRAP at line $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.Message)"
    $errStack = $_.ScriptStackTrace
    Write-ErrorLog $errMsg
    Write-ErrorLog "Stack: $errStack"
    try {
        Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
        [System.Windows.Forms.MessageBox]::Show(
            "$errMsg`n`nSee: $script:ErrorLogPath",
            "Entra Group Manager - Error",
            [System.Windows.Forms.MessageBoxButtons]::OK,
            [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null
    } catch {}
    break
}

# Run the prereq check  -  loop supports in-session reinstall when already admin
$script:RerunPrereq = $false
do {
    $script:RerunPrereq = $false
    if (-not (Start-PrereqCheck)) { exit 0 }
} while ($script:RerunPrereq)

#endregion PREREQ CHECK




# ============================================================
#region XAML  -  UI Definition
# ============================================================

# ── Compute window size from available screen working area ──
# Uses SystemParameters (WPF logical pixels already DPI-adjusted) so the
# window fits on any screen size / scaling factor without clipping.
$_screen = [System.Windows.SystemParameters]
$_workW  = [int]$_screen::WorkArea.Width
$_workH  = [int]$_screen::WorkArea.Height
# Target 92% of working area, clamped to config defaults as ceiling
$_winW   = [int][Math]::Min([Math]::Floor($_workW * 0.92), $DevConfig.WindowWidth)
$_winH   = [int][Math]::Min([Math]::Floor($_workH * 0.92), $DevConfig.WindowHeight)
# Never go below the minimum sizes
$_winW   = [Math]::Max($_winW, $DevConfig.MinWidth)
$_winH   = [Math]::Max($_winH, $DevConfig.MinHeight)

[xml]$Xaml = @"
<Window
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="$($DevConfig.WindowTitle) &#x26A1;"
    Width="$_winW"
    Height="$_winH"
    MinWidth="$($DevConfig.MinWidth)"
    MinHeight="$($DevConfig.MinHeight)"
    WindowStartupLocation="CenterScreen"
    Background="#F2FAF2"
    FontFamily="$($DevConfig.FontFamily)"
    FontSize="$($DevConfig.FontSizeBase)"
    UseLayoutRounding="True"
    SnapsToDevicePixels="True"
    TextOptions.TextFormattingMode="Display"
    TextOptions.TextRenderingMode="ClearType"
    RenderOptions.ClearTypeHint="Enabled">

  <Window.Resources>

    <!-- Glassy button base template -->
    <ControlTemplate x:Key="GlassyBtnTemplate" TargetType="Button">
      <Grid>
        <Border x:Name="BtnBorder"
                Background="{TemplateBinding Background}"
                CornerRadius="9"
                Padding="{TemplateBinding Padding}"
                SnapsToDevicePixels="True">
          <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
        </Border>
        <Border CornerRadius="9,9,0,0" Height="14" VerticalAlignment="Top"
                IsHitTestVisible="False" Margin="1,1,1,0"
                SnapsToDevicePixels="True"
                RenderOptions.ClearTypeHint="Enabled">
          <Border.Background>
            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
              <GradientStop Color="#33FFFFFF" Offset="0"/>
              <GradientStop Color="#0AFFFFFF" Offset="0.6"/>
              <GradientStop Color="#00FFFFFF" Offset="1"/>
            </LinearGradientBrush>
          </Border.Background>
        </Border>
      </Grid>
      <ControlTemplate.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
          <Setter TargetName="BtnBorder" Property="Opacity" Value="0.88"/>
        </Trigger>
        <Trigger Property="IsPressed" Value="True">
          <Setter TargetName="BtnBorder" Property="Opacity" Value="0.72"/>
        </Trigger>
        <Trigger Property="IsEnabled" Value="False">
          <Setter TargetName="BtnBorder" Property="Opacity" Value="0.4"/>
        </Trigger>
      </ControlTemplate.Triggers>
    </ControlTemplate>

    <!-- Primary button (aqua blue) -->
    <Style x:Key="PrimaryBtn" TargetType="Button">
      <Setter Property="Foreground" Value="White"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="Padding" Value="14,9"/>
      <Setter Property="FontSize" Value="12"/>
      <Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template" Value="{StaticResource GlassyBtnTemplate}"/>
      <Setter Property="Background">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
            <GradientStop Color="#005F73" Offset="0"/>
            <GradientStop Color="#0A9396" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsEnabled" Value="False">
          <Setter Property="Opacity" Value="0.45"/>
        </Trigger>
      </Style.Triggers>
    </Style>

    <!-- Accent button (Connect - aqua blue with glow) -->
    <Style x:Key="AccentBtn" TargetType="Button" BasedOn="{StaticResource PrimaryBtn}">
      <Setter Property="Effect">
        <Setter.Value>
          <DropShadowEffect BlurRadius="18" ShadowDepth="0" Color="#0A9396" Opacity="0.30"/>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- Disconnect button (amber) -->
    <Style x:Key="AmberBtn" TargetType="Button">
      <Setter Property="Foreground" Value="White"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="Padding" Value="14,9"/>
      <Setter Property="FontSize" Value="12"/>
      <Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template" Value="{StaticResource GlassyBtnTemplate}"/>
      <Setter Property="Effect">
        <Setter.Value>
          <DropShadowEffect BlurRadius="14" ShadowDepth="0" Color="#F59E0B" Opacity="0.22"/>
        </Setter.Value>
      </Setter>
      <Setter Property="Background">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
            <GradientStop Color="#78350F" Offset="0"/>
            <GradientStop Color="#D97706" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsEnabled" Value="False">
          <Setter Property="Opacity" Value="0.45"/>
        </Trigger>
      </Style.Triggers>
    </Style>

    <!-- Stop button (blood red) -->
    <Style x:Key="StopBtn" TargetType="Button">
      <Setter Property="Foreground" Value="White"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="Padding" Value="12,9"/>
      <Setter Property="FontSize" Value="12"/>
      <Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template" Value="{StaticResource GlassyBtnTemplate}"/>
      <Setter Property="Effect">
        <Setter.Value>
          <DropShadowEffect BlurRadius="16" ShadowDepth="0" Color="#DC2626" Opacity="0.30"/>
        </Setter.Value>
      </Setter>
      <Setter Property="Background">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
            <GradientStop Color="#7F1D1D" Offset="0"/>
            <GradientStop Color="#DC2626" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- Ghost / icon button -->
    <Style x:Key="GhostBtn" TargetType="Button">
      <Setter Property="Background" Value="Transparent"/>
      <Setter Property="Foreground" Value="#4A7060"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border x:Name="Bd" Background="{TemplateBinding Background}"
                    CornerRadius="7" Padding="{TemplateBinding Padding}">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="Bd" Property="Background" Value="#E8F5E8"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- Outline button -->
    <Style x:Key="OutlineBtn" TargetType="Button">
      <Setter Property="Background" Value="Transparent"/>
      <Setter Property="Foreground" Value="#22C55E"/>
      <Setter Property="BorderBrush" Value="#22C55E"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="12,7"/>
      <Setter Property="FontSize" Value="12"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="7" Padding="{TemplateBinding Padding}">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Background" Value="#D4EDDA"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- Op tile button (sidebar) -->
    <Style x:Key="OpTileBtn" TargetType="Button">
      <Setter Property="Background" Value="Transparent"/>
      <Setter Property="Foreground" Value="#374151"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="Padding" Value="0"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Grid>
              <Border x:Name="AccentBar" Width="3" HorizontalAlignment="Left"
                      VerticalAlignment="Stretch" CornerRadius="0,2,2,0" Opacity="0"/>
              <Border x:Name="TileBg" Background="{TemplateBinding Background}"
                      BorderBrush="#B0D0B0" BorderThickness="1"
                      CornerRadius="8" Margin="0,1,0,1" Padding="10,9,10,9">
                <ContentPresenter HorizontalAlignment="Stretch"/>
              </Border>
            </Grid>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="TileBg" Property="Background" Value="#E8F5E8"/>
                <Setter TargetName="TileBg" Property="BorderBrush" Value="#B0D0B0"/>
                <Setter TargetName="AccentBar" Property="Opacity" Value="0.6"/>
                <Setter TargetName="AccentBar" Property="Background" Value="#22C55E"/>
              </Trigger>
              <Trigger Property="IsPressed" Value="True">
                <Setter TargetName="TileBg" Property="Background" Value="#D4EDDA"/>
                <Setter TargetName="TileBg" Property="BorderBrush" Value="#86C18A"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- TextBox -->
    <Style x:Key="InputBox" TargetType="TextBox">
      <Setter Property="Background" Value="#FFFFFF"/>
      <Setter Property="Foreground" Value="#374151"/>
      <Setter Property="BorderBrush" Value="#B0D0B0"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="10,8"/>
      <Setter Property="FontSize" Value="12"/>
      <Setter Property="CaretBrush" Value="#22C55E"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="TextBox">
            <Border x:Name="Bd" Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="8">
              <Grid TextElement.Foreground="{TemplateBinding Foreground}">
                <ScrollViewer x:Name="PART_ContentHost" TextElement.Foreground="{TemplateBinding Foreground}" Margin="{TemplateBinding Padding}"
                              VerticalAlignment="Center"/>
                <TextBlock x:Name="Placeholder" Text="{TemplateBinding Tag}"
                           Foreground="#9CA3AF" FontSize="{TemplateBinding FontSize}"
                           Margin="{TemplateBinding Padding}" VerticalAlignment="Top"
                           IsHitTestVisible="False" TextWrapping="Wrap" Visibility="Collapsed"/>
              </Grid>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsFocused" Value="True">
                <Setter TargetName="Bd" Property="BorderBrush" Value="#4022C55E"/>
                <Setter Property="Foreground" Value="#1A1F2E"/>
              </Trigger>
              <MultiTrigger>
                <MultiTrigger.Conditions>
                  <Condition Property="Text" Value=""/>
                  <Condition Property="IsFocused" Value="False"/>
                </MultiTrigger.Conditions>
                <Setter TargetName="Placeholder" Property="Visibility" Value="Visible"/>
              </MultiTrigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- ComboBox -->
    <Style x:Key="StyledCombo" TargetType="ComboBox">
      <Setter Property="Background" Value="#FFFFFF"/>
      <Setter Property="Foreground" Value="#374151"/>
      <Setter Property="BorderBrush" Value="#B0D0B0"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="10,8"/>
      <Setter Property="FontSize" Value="12"/>
    </Style>

    <!-- Field label -->
    <Style x:Key="FieldLabel" TargetType="TextBlock">
      <Setter Property="Foreground" Value="#14532D"/>
      <Setter Property="FontSize" Value="10"/>
      <Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="Margin" Value="0,0,0,5"/>
    </Style>

    <!-- Card panel -->
    <Style x:Key="Card" TargetType="Border">
      <Setter Property="Background" Value="#FFFFFF"/>
      <Setter Property="BorderBrush" Value="#B0D0B0"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="CornerRadius" Value="12"/>
      <Setter Property="Padding" Value="18"/>
      <Setter Property="Effect">
        <Setter.Value>
          <DropShadowEffect BlurRadius="20" ShadowDepth="4" Direction="270"
                            Color="#000000" Opacity="0.35"/>
        </Setter.Value>
      </Setter>
    </Style>

  </Window.Resources>

  <Grid>

    <!-- Background image layer -->
    <Image x:Name="BgImage" Stretch="UniformToFill"
           Opacity="$($DevConfig.BackgroundImageOpacity)"
           HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
           IsHitTestVisible="False" SnapsToDevicePixels="True"/>

    <DockPanel>

      <!-- HEADER BAR -->
      <Border x:Name="HeaderBorder" DockPanel.Dock="Top" Padding="18,14" SnapsToDevicePixels="True" RenderOptions.ClearTypeHint="Enabled">
        <Border.Background>
          <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
            <GradientStop Color="#006400" Offset="0"/>
            <GradientStop Color="#009900" Offset="1"/>
          </LinearGradientBrush>
        </Border.Background>
        <Border.BorderBrush>
          <SolidColorBrush Color="#0FFFFFFF"/>
        </Border.BorderBrush>
        <Border.BorderThickness>0,0,0,1</Border.BorderThickness>
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
          </Grid.ColumnDefinitions>

           <Border Grid.Column="0" Width="Auto" MaxWidth="$($DevConfig.LogoWidth)"
                   Height="$($DevConfig.LogoHeight)" Margin="0,0,0,0"
                   VerticalAlignment="Center" ClipToBounds="True" Background="Transparent">
             <Image x:Name="LogoImg" Height="$($DevConfig.LogoHeight)" MaxWidth="$($DevConfig.LogoWidth)"
                    Stretch="Uniform" HorizontalAlignment="Left"
                    VerticalAlignment="Center" Visibility="Collapsed"/>
           </Border>

          <!-- Title + bolt + status -->
          <StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="$($DevConfig.LogoTitleSpacing),0,0,0">
            <StackPanel Orientation="Horizontal">
              <TextBlock Text="$($DevConfig.WindowTitle)" Foreground="#F1F9F1"
                         FontSize="17" FontWeight="Black" VerticalAlignment="Center"/>
              <TextBlock Text=" &#x26A1;" Foreground="#FFD700" FontSize="15"
                         VerticalAlignment="Center"/>
            </StackPanel>
            <TextBlock x:Name="TxtStatusBar" Text="Not connected"
                       Foreground="#CCFFCC" FontSize="11" Margin="0,2,0,0"/>
          </StackPanel>

              <Border Grid.Column="2"/>

          <!-- Button cluster -->
          <StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
            <Button x:Name="BtnConnect" Content="Connect" Style="{StaticResource AccentBtn}"
                    Width="90" Margin="0,0,7,0"/>
            <Button x:Name="BtnClearInputs" Content="x  Clear" Visibility="Collapsed"
                    Padding="10,9" FontSize="12" FontWeight="Bold" Cursor="Hand"
                    Width="80" Margin="0,0,7,0" Foreground="White" BorderThickness="0">
              <Button.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
                  <GradientStop Color="#374151" Offset="0"/>
                  <GradientStop Color="#6B7280" Offset="1"/>
                </LinearGradientBrush>
              </Button.Background>
              <Button.Template>
                <ControlTemplate TargetType="Button">
                  <Border Background="{TemplateBinding Background}" CornerRadius="9"
                          Padding="{TemplateBinding Padding}">
                    <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                  </Border>
                  <ControlTemplate.Triggers>
                    <Trigger Property="IsMouseOver" Value="True">
                      <Setter Property="Opacity" Value="0.85"/>
                    </Trigger>
                  </ControlTemplate.Triggers>
                </ControlTemplate>
              </Button.Template>
            </Button>
            <Button x:Name="BtnStop" Style="{StaticResource StopBtn}"
                    Visibility="Collapsed" Width="88" Margin="0,0,7,0">
              <Button.Content>
                <StackPanel Orientation="Horizontal">
                  <TextBlock Text="&#xE71A;" FontFamily="Segoe MDL2 Assets" FontSize="12"
                             VerticalAlignment="Center" Margin="0,0,5,0" Foreground="White"/>
                  <TextBlock Text="Stop" VerticalAlignment="Center"/>
                </StackPanel>
              </Button.Content>
            </Button>
            <Button x:Name="BtnDisconnect" Content="Disconnect" Style="{StaticResource AmberBtn}"
                    Width="106" IsEnabled="False" Margin="0,0,7,0"/>
            <Button x:Name="BtnFeedback"
                    Padding="10,9" FontSize="11" FontWeight="SemiBold" Cursor="Hand"
                    Width="96" Margin="0,0,7,0" Foreground="#1C1C00" BorderThickness="1"
                    BorderBrush="#D4A017" ToolTip="Open feedback link">
               <Button.Content>
                 <StackPanel Orientation="Horizontal">
                   <TextBlock Text="&#xE72D;" FontFamily="Segoe MDL2 Assets" FontSize="12"
                              VerticalAlignment="Center" Margin="0,0,5,0"
                              Foreground="#1C1C00"/>
                   <TextBlock Text="Feedback" VerticalAlignment="Center"/>
                 </StackPanel>
               </Button.Content>
              <Button.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                  <GradientStop Color="#FFCE3A" Offset="0"/>
                  <GradientStop Color="#F5A800" Offset="1"/>
                </LinearGradientBrush>
              </Button.Background>
              <Button.Template>
                <ControlTemplate TargetType="Button">
                  <Border x:Name="FbBg" Background="{TemplateBinding Background}"
                          BorderBrush="{TemplateBinding BorderBrush}"
                          BorderThickness="{TemplateBinding BorderThickness}"
                          CornerRadius="9" Padding="{TemplateBinding Padding}">
                    <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                  </Border>
                  <ControlTemplate.Triggers>
                    <Trigger Property="IsMouseOver" Value="True">
                      <Setter TargetName="FbBg" Property="Background" Value="#EDBA00"/>
                      <Setter TargetName="FbBg" Property="BorderBrush" Value="#B8860B"/>
                    </Trigger>
                    <Trigger Property="IsPressed" Value="True">
                      <Setter TargetName="FbBg" Property="Background" Value="#CC9900"/>
                    </Trigger>
                  </ControlTemplate.Triggers>
                </ControlTemplate>
              </Button.Template>
            </Button>
            <Button x:Name="BtnSessionNotes"
                    Width="30" Height="30" Margin="2,0,4,0"
                    Background="Transparent" BorderThickness="0" Cursor="Hand"
                    VerticalAlignment="Center"
                    ToolTip="Session Notes  (in-memory only — cleared on close)">
              <TextBlock Text="&#x1F5D2;" FontSize="15" Foreground="#B3FFB3"
                         VerticalAlignment="Center" HorizontalAlignment="Center"/>
            </Button>
            <Button x:Name="BtnAuthCancel" Style="{StaticResource StopBtn}"
                    Visibility="Collapsed" Width="114" FontSize="11">
              <Button.Content>
                <StackPanel Orientation="Horizontal">
                  <TextBlock Text="&#xE71A;" FontFamily="Segoe MDL2 Assets" FontSize="12"
                             VerticalAlignment="Center" Margin="0,0,5,0" Foreground="White"/>
                  <TextBlock Text="Cancel Auth" VerticalAlignment="Center"/>
                </StackPanel>
              </Button.Content>
            </Button>
          </StackPanel>


        </Grid>
      </Border>

      <!-- Author attribution strip -->
      <Border DockPanel.Dock="Top" Padding="16,5" Background="#F8FAFC">
        <Border.BorderBrush><SolidColorBrush Color="#20000000"/></Border.BorderBrush>
        <Border.BorderThickness>0,0,0,1</Border.BorderThickness>
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
          <TextBlock Text="Author: " FontSize="10.5" Foreground="#6B7280" VerticalAlignment="Center" FontWeight="Normal"/>
          <TextBlock x:Name="TxtAuthorLink" Text="Satish Singhi" FontSize="10.5" Foreground="#0A66C2" FontWeight="SemiBold"
                     VerticalAlignment="Center" Cursor="Hand" TextDecorations="Underline"
                     ToolTip="https://www.linkedin.com/in/satish-singhi-791163167/"/>
          <TextBlock Text="  &#x1F517;" FontSize="10" Foreground="#0A66C2" VerticalAlignment="Center"/>
        </StackPanel>
      </Border>

      <!-- Notification strip -->
      <Border x:Name="NotifStrip" DockPanel.Dock="Top" Visibility="Collapsed"
              Padding="16,8" Background="#FFFBEB">
        <Border.BorderBrush><SolidColorBrush Color="#40F59E0B"/></Border.BorderBrush>
        <Border.BorderThickness>0,0,0,1</Border.BorderThickness>
        <TextBlock x:Name="TxtNotif" Foreground="#F59E0B" FontSize="12" TextWrapping="Wrap"/>
      </Border>

      <!-- PIM role strip -->
      <Border x:Name="PimStrip" DockPanel.Dock="Top" Visibility="Collapsed"
              Padding="18,0" Background="#EFF7EF">
        <Border.BorderBrush><SolidColorBrush Color="#0FFFFFFF"/></Border.BorderBrush>
        <Border.BorderThickness>0,0,0,1</Border.BorderThickness>
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="Auto"/>
          </Grid.ColumnDefinitions>
          <TextBlock Grid.Column="0" Text="&#x1F511;  Active PIM Roles"
                     Foreground="#14532D" FontSize="10" FontWeight="Bold"
                     VerticalAlignment="Center" Margin="0,0,12,0"/>
          <ScrollViewer Grid.Column="1" HorizontalScrollBarVisibility="Auto"
                        VerticalScrollBarVisibility="Disabled" Margin="0,5,0,5">
            <StackPanel x:Name="SpPimRoles" Orientation="Horizontal"/>
          </ScrollViewer>
          <Button x:Name="BtnPimRefresh" Grid.Column="2" Content="&#x21BB;"
                  Style="{StaticResource GhostBtn}"
                  Foreground="#14532D" FontSize="14" Padding="6,4"
                  ToolTip="Refresh PIM roles"/>
        </Grid>
      </Border>

      <!-- LOG PANE (bottom docked, resizable) -->
      <Grid DockPanel.Dock="Bottom">
        <Grid.RowDefinitions>
          <RowDefinition Height="Auto"/>
          <RowDefinition Height="Auto" MinHeight="0"/>
        </Grid.RowDefinitions>

        <Border Grid.Row="0" Padding="12,5" Cursor="SizeNS">
          <Border.Background>
            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
              <GradientStop Color="#D4EDDA" Offset="0"/>
              <GradientStop Color="#C8E6C9" Offset="1"/>
            </LinearGradientBrush>
          </Border.Background>
          <Border.BorderBrush><SolidColorBrush Color="#80B0D0B0"/></Border.BorderBrush>
          <Border.BorderThickness>0,1,0,0</Border.BorderThickness>
          <Grid>
            <Grid.ColumnDefinitions>
              <ColumnDefinition Width="Auto"/>
              <ColumnDefinition Width="*"/>
              <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
              <Border Width="6" Height="6" CornerRadius="3" Background="#166534"
                      Margin="0,0,8,0" VerticalAlignment="Center">
                <Border.Effect>
                  <DropShadowEffect BlurRadius="4" ShadowDepth="0" Color="#22C55E" Opacity="0.8"/>
                </Border.Effect>
              </Border>
              <TextBlock Text="VERBOSE LOG" Foreground="#132210" FontSize="10"
                         FontWeight="Bold" FontFamily="Consolas" VerticalAlignment="Center"/>
            </StackPanel>
            <StackPanel Grid.Column="2" Orientation="Horizontal">
              <Button x:Name="BtnLogClear" Content="Clear" Style="{StaticResource GhostBtn}"
                      Foreground="#132210" FontSize="10" Padding="6,3" Margin="0,0,4,0"/>
              <Button x:Name="BtnLogToggle" Content="&#x25BC;  Hide"
                      Style="{StaticResource GhostBtn}"
                      Foreground="#132210" FontSize="10" Padding="6,3"/>
            </StackPanel>
          </Grid>
        </Border>

        <Border Grid.Row="1" x:Name="LogPane" Background="#0D1824"
                Height="$($DevConfig.VerbosePaneDefaultHeight)">
          <RichTextBox x:Name="RtbLog" Background="Transparent" BorderThickness="0"
                       IsReadOnly="True" FontFamily="Consolas" FontSize="11"
                       Foreground="#9DB8D8" Padding="14,8"
                       VerticalScrollBarVisibility="Auto"
                       HorizontalScrollBarVisibility="Disabled"
                       IsDocumentEnabled="True"/>
        </Border>

        <GridSplitter Grid.Row="0" Height="5" HorizontalAlignment="Stretch"
                      Background="Transparent" Cursor="SizeNS" VerticalAlignment="Bottom"
                      ResizeDirection="Rows" ResizeBehavior="PreviousAndNext"/>
      </Grid>

      <!-- MAIN CONTENT AREA -->
      <Grid Margin="16,12,16,12">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="224"/>
          <ColumnDefinition Width="12"/>
          <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <!-- Left nav -->
        <ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto"
                      HorizontalScrollBarVisibility="Disabled" PanningMode="VerticalOnly">
          <StackPanel>
            <TextBlock Text="OPERATIONS" Foreground="#0F1F14" FontSize="9.5"
                       FontWeight="Black" Margin="10,0,0,8"/>

            <!-- Group Management blade -->
            <Border Background="#F5FBF5" BorderBrush="#B0D0B0"
                    BorderThickness="1" CornerRadius="10" Margin="0,0,0,6">
              <StackPanel>
                <Button x:Name="BtnBladeGroupMgmt" Background="#E8F5E8"
                        BorderThickness="0" Cursor="Hand" Padding="10,10">
                  <Button.Template>
                    <ControlTemplate TargetType="Button">
                      <Border Background="{TemplateBinding Background}"
                              CornerRadius="9,9,0,0" Padding="{TemplateBinding Padding}">
                        <ContentPresenter/>
                      </Border>
                      <ControlTemplate.Triggers>
                        <Trigger Property="IsMouseOver" Value="True">
                          <Setter Property="Background" Value="#D4EDDA"/>
                        </Trigger>
                      </ControlTemplate.Triggers>
                    </ControlTemplate>
                  </Button.Template>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <StackPanel Grid.Column="0" Orientation="Horizontal">
                      <TextBlock Text="&#x1F5C2;" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,0"/>
                      <TextBlock Text="Group Management" FontWeight="Bold" FontSize="12"
                                 Foreground="#166534" VerticalAlignment="Center"/>
                    </StackPanel>
                    <TextBlock x:Name="TxtBladeArrow" Grid.Column="1" Text="&#x2212;"
                               FontSize="15" FontWeight="Bold" Foreground="#2A7A4A" VerticalAlignment="Center"/>
                  </Grid>
                </Button>
                <Border x:Name="BladeDivider" Height="1"><Border.Background><SolidColorBrush Color="#B0D0B0"/></Border.Background>
                </Border>
                <StackPanel x:Name="BladeGroupMgmtContent" Margin="6,5,6,6">

                  <Button x:Name="BtnOpSearch" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F50D;" FontSize="13" Foreground="#0EA5E9" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Search Entra Objects" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Users · Devices · Groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpListMembers" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F4CB;" FontSize="13" Foreground="#8B5CF6" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="List Group Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Users · Devices · Groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>


                  <Button x:Name="BtnOpObjectMembership" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F517;" FontSize="13" Foreground="#D97706" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Object Membership" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Transitive group memberships" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>
                  <Button x:Name="BtnOpFindGroupsByOwners" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F464;" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Find Groups by Owners" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Groups owned by specified UPNs" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>
                  <Button x:Name="BtnOpCreate" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2295;" FontSize="14" Foreground="#22C55E" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Create Group" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Security or M365" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpAdd" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2295;" FontSize="14" Foreground="#3B82F6" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Add Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Users &#xB7; Groups &#xB7; Devices" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpRemove" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2296;" FontSize="14" Foreground="#EF4444" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Remove Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Users &#xB7; Groups &#xB7; Devices" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpExport" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2191;" FontSize="15" Foreground="#F59E0B" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Export Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="To XLSX file" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpDynamic" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x26A1;" FontSize="13" Foreground="#8B5CF6" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Set Dynamic Query" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="MembershipRule string" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpRename" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x270F;" FontSize="13" Foreground="#F59E0B" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Rename Group" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Change display name" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpOwner" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2299;" FontSize="14" Foreground="#3B82F6" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Set Group Owner" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Assign owner UPN(s)" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpUserDevices" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x25A3;" FontSize="13" Foreground="#6366F1" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Add User Devices" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="By UPN &#xB7; Platform &#xB7; Ownership" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpFindCommon" Style="{StaticResource OpTileBtn}">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2229;" FontSize="15" Foreground="#0EA5E9" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Find Common Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Groups shared by all inputs" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpFindDistinct" Style="{StaticResource OpTileBtn}">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2296;" FontSize="15" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Find Distinct Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Groups unique to each input" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpCompareGroups" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x21D4;" FontSize="13" Foreground="#D97706" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Compare Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Membership across groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                  <Button x:Name="BtnOpRemoveGroups" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F5D1;" FontSize="13" Foreground="#DC2626" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Remove Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Delete multiple Entra groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>

                </StackPanel>
              </StackPanel>
            </Border>

            <!-- Device Management blade -->
            <Border Background="#F5FBF5" BorderBrush="#B0D0B0"
                    BorderThickness="1" CornerRadius="10" Margin="0,0,0,6">
              <StackPanel>
                <Button x:Name="BtnBladeDevMgmt" Background="#E8F5E8"
                        BorderThickness="0" Cursor="Hand" Padding="10,10">
                  <Button.Template>
                    <ControlTemplate TargetType="Button">
                      <Border Background="{TemplateBinding Background}"
                              CornerRadius="9,9,0,0" Padding="{TemplateBinding Padding}">
                        <ContentPresenter/>
                      </Border>
                      <ControlTemplate.Triggers>
                        <Trigger Property="IsMouseOver" Value="True">
                          <Setter Property="Background" Value="#D4EDDA"/>
                        </Trigger>
                      </ControlTemplate.Triggers>
                    </ControlTemplate>
                  </Button.Template>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <StackPanel Grid.Column="0" Orientation="Horizontal">
                      <TextBlock Text="&#x1F4BB;" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,0"/>
                      <TextBlock Text="Device Management" FontWeight="Bold" FontSize="12"
                                 Foreground="#166534" VerticalAlignment="Center"/>
                    </StackPanel>
                    <TextBlock x:Name="TxtBladeDevArrow" Grid.Column="1" Text="&#x2212;"
                               FontSize="15" FontWeight="Bold" Foreground="#2A7A4A" VerticalAlignment="Center"/>
                  </Grid>
                </Button>
                <Border x:Name="BladeDevDivider" Height="1"><Border.Background><SolidColorBrush Color="#B0D0B0"/></Border.Background>
                </Border>
                <StackPanel x:Name="BladeDevMgmtContent" Margin="6,5,6,6">
                  <Button x:Name="BtnOpGetPolicyAssignments" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F4CB;" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Get Policy Assignments" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Intune policy assignment report" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>
                  <Button x:Name="BtnOpGetDeviceInfo" Style="{StaticResource OpTileBtn}">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x2139;" FontSize="14" Foreground="#14B8A6" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Get Device Info" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Entra + Intune combined report" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>
                  <Button x:Name="BtnOpGetDiscoveredApps" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
                    <StackPanel>
                      <StackPanel Orientation="Horizontal">
                        <TextBlock Text="&#x1F4E6;" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
                        <TextBlock Text="Get Discovered Apps" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
                      </StackPanel>
                      <TextBlock Text="Apps detected on managed devices" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
                    </StackPanel>
                  </Button>
                </StackPanel>
              </StackPanel>
            </Border>

          </StackPanel>
        </ScrollViewer>

        <!-- Separator -->
        <Border Grid.Column="1" Width="1" HorizontalAlignment="Center">
          <Border.Background><SolidColorBrush Color="#D0D8D0"/></Border.Background>
        </Border>

        <!-- Right content: op panels -->
        <ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto"
                      HorizontalScrollBarVisibility="Disabled">
          <Grid>

            <!-- === WELCOME panel === -->
            <Border x:Name="PanelWelcome" Style="{StaticResource Card}" Visibility="Visible">
              <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Margin="0,40">
                <TextBlock Text="Welcome to $($DevConfig.WindowTitle)"
                           FontSize="18" FontWeight="SemiBold"
                           Foreground="$($DevConfig.ColorPrimary)"
                           HorizontalAlignment="Center"/>
                <TextBlock Text="Connect to Microsoft Graph, then select an operation from the left panel."
                           FontSize="13" Foreground="$($DevConfig.ColorTextMuted)"
                           HorizontalAlignment="Center" Margin="0,8,0,0" TextWrapping="Wrap"/>
              </StackPanel>
            </Border>

            <!-- === SEARCH ENTRA OBJECTS panel === -->
            <Border x:Name="PanelSearch" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Search Entra Objects" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Search for Users, Devices, Security Groups and M365 Groups by display name."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="SEARCH KEYWORD *" Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtSearchKeyword" Grid.Column="0" Style="{StaticResource InputBox}"
                             Tag="Display name, UPN, email address or device name"/>
                    <Button x:Name="BtnEntraSearch" Grid.Column="2"
                            Content="&#x1F50D;  Search"
                            Style="{StaticResource PrimaryBtn}" Width="110"/>
                  </Grid>
                </StackPanel>
                <StackPanel Margin="0,0,0,16">
                  <TextBlock Text="OBJECT TYPE FILTER  (none checked = all types)" Style="{StaticResource FieldLabel}"/>
                  <WrapPanel Margin="0,6,0,0">
                    <CheckBox x:Name="ChkSrchUsers"   Content="Users"           IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
                    <CheckBox x:Name="ChkSrchDevices" Content="Devices"         IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
                    <CheckBox x:Name="ChkSrchSG"      Content="Security Groups" IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
                    <CheckBox x:Name="ChkSrchM365"    Content="M365 Groups"     IsChecked="True" Margin="0,0,0,4"  FontSize="12"/>
                  </WrapPanel>
                    <CheckBox x:Name="ChkSrchExactMatch" Content="Exact Match only" IsChecked="False" Margin="0,8,0,0" FontSize="12"/>
                </StackPanel>
                <Border x:Name="PnlSearchResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtSearchCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnSearchFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtSearchFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnSearchFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnSearchCopyValue"
                                Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnSearchCopyRow"
                                Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnSearchCopyAll"
                                Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnSearchExportXlsx"
                                Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgSearchResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5FBF5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Object Name" Binding="{Binding DisplayName}" Width="*"   MinWidth="160"/>
                        <DataGridTextColumn Header="Details"     Binding="{Binding Detail}"      Width="220"/>
                        <DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}"  Width="140"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtSearchNoResults" Visibility="Collapsed"
                           Text="No matching objects found." FontSize="12"
                           Foreground="#6B7280" Margin="0,10,0,0"/>
              </StackPanel>
            </Border>

            <!-- === LIST GROUP MEMBERS panel === -->
            <Border x:Name="PanelListMembers" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="List Group Members" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Query members from one or more groups. Enter display names or Object IDs, one per line."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>

                <!-- Group input -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="GROUPS  *  (one per line  —  display name or Object ID)" Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtLMGroupInput" Grid.Column="0" Style="{StaticResource InputBox}"
                             Height="80" TextWrapping="Wrap" AcceptsReturn="True"
                             VerticalScrollBarVisibility="Auto"
                             Tag="SG-Marketing-All&#10;a1b2c3d4-0000-0000-0000-000000000000&#10;IT-Devices"/>
                    <Button x:Name="BtnLMQuery" Grid.Column="2"
                            Content="&#x1F4CB;  List Members"
                            Style="{StaticResource PrimaryBtn}" Width="130" VerticalAlignment="Top"/>
                  </Grid>
                </StackPanel>

                <!-- Output type filter -->
                <StackPanel Margin="0,0,0,16">
                  <TextBlock Text="OUTPUT TYPE FILTER  (none checked = all types)" Style="{StaticResource FieldLabel}"/>
                  <WrapPanel Margin="0,6,0,0">
                    <CheckBox x:Name="ChkLMUsers"   Content="Users"                     IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
                    <CheckBox x:Name="ChkLMDevices" Content="Devices"                   IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
                    <CheckBox x:Name="ChkLMGroups"  Content="Security &amp; M365 Groups" IsChecked="True" Margin="0,0,0,4"  FontSize="12"/>
                  </WrapPanel>
                </StackPanel>

                <!-- Progress overlay (shown while querying) -->
                <Border x:Name="PnlLMProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtLMProgressMsg" Text="Querying groups..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbLMProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtLMProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results area -->
                <Border x:Name="PnlLMResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtLMCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnLMFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtLMFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnLMFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnLMCopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnLMCopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnLMCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnLMExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgLMResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5FBF5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <!-- Columns built dynamically in code-behind based on result schema -->
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtLMNoResults" Visibility="Collapsed"
                           Text="No members found matching the current filters."
                           FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
              </StackPanel>
            </Border>


            <!-- === OBJECT MEMBERSHIP panel === -->
            <Border x:Name="PanelObjectMembership" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Object Membership" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Resolve transitive group memberships for users, devices, or groups. Enter identifiers one per line."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>

                <!-- Object type -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="OBJECT TYPE" Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbOMObjectType" Style="{StaticResource StyledCombo}"
                            Width="260" HorizontalAlignment="Left" Margin="0,4,0,0">
                    <ComboBoxItem Content="Users (UPN)" IsSelected="True"/>
                    <ComboBoxItem Content="Devices (Name)"/>
                    <ComboBoxItem Content="Devices (ID)"/>
                    <ComboBoxItem Content="Security Groups"/>
                    <ComboBoxItem Content="M365 Groups"/>
                  </ComboBox>
                </StackPanel>

                <!-- Input list -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock x:Name="TxtOMInputLabel" Text="USER UPNs  -  one per line"
                             Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtOMInputList" Grid.Column="0" Style="{StaticResource InputBox}"
                             Height="110" TextWrapping="Wrap" AcceptsReturn="True"
                             VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
                    <Button x:Name="BtnOMRun" Grid.Column="2"
                            Content="&#x25B6;  Get Memberships"
                            Style="{StaticResource PrimaryBtn}" Width="180" VerticalAlignment="Top"/>
                  </Grid>
                </StackPanel>

                <!-- Progress -->
                <Border x:Name="PnlOMProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtOMProgressMsg" Text="Resolving objects..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbOMProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtOMProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results -->
                <Border x:Name="PnlOMResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtOMCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnOMFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtOMFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnOMFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnOMCopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnOMCopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnOMCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnOMExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgOMResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5FBF5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Input Object"     Binding="{Binding InputObject}"    Width="2*" MinWidth="150"/>
                        <DataGridTextColumn Header="Object Type"      Binding="{Binding ObjectType}"     Width="*"  MinWidth="80"/>
                        <DataGridTextColumn Header="Group Name"       Binding="{Binding GroupName}"      Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group ID"         Binding="{Binding GroupId}"        Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Group Type"       Binding="{Binding GroupType}"      Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Description"      Binding="{Binding Description}"    Width="2*" MinWidth="150"/>
                        <DataGridTextColumn Header="Membership Type"  Binding="{Binding MembershipType}" Width="*"  MinWidth="100"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtOMNoResults" Visibility="Collapsed"
                           Text="No group memberships found for the provided input."
                           FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
              </StackPanel>
            </Border>


            <!-- === FIND GROUPS BY OWNERS panel === -->
            <Border x:Name="PanelFindGroupsByOwners" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Find Groups by Owners" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Enter owner UPNs (one per line) to find all groups they own."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>

                <!-- Input list -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="OWNER UPNs  -  one per line" Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtFGOInputList" Grid.Column="0" Style="{StaticResource InputBox}"
                             Height="110" TextWrapping="Wrap" AcceptsReturn="True"
                             VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
                    <Button x:Name="BtnFGORun" Grid.Column="2"
                            Content="&#x25B6;  Find Groups"
                            Style="{StaticResource PrimaryBtn}" Width="180" VerticalAlignment="Top"/>
                  </Grid>
                </StackPanel>

                <!-- Progress -->
                <Border x:Name="PnlFGOProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtFGOProgressMsg" Text="Resolving owners..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbFGOProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtFGOProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results -->
                <Border x:Name="PnlFGOResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtFGOCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnFGOFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtFGOFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnFGOFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnFGOCopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFGOCopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFGOCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFGOExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgFGOResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5FBF5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Owner"             Binding="{Binding Owner}"          Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group Name"        Binding="{Binding GroupName}"       Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group ID"          Binding="{Binding GroupId}"         Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Group Type"        Binding="{Binding GroupType}"       Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Membership Type"   Binding="{Binding MembershipType}"  Width="*"  MinWidth="110"/>
                        <DataGridTextColumn Header="Member Count"      Binding="{Binding MemberCount}"     Width="*"  MinWidth="90"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtFGONoResults" Visibility="Collapsed"
                           Text="No owned groups found for the provided UPNs."
                           FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
              </StackPanel>
            </Border>

            <!-- === CREATE GROUP panel === -->
            <Border x:Name="PanelCreate" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Create Group" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Group type -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="GROUP TYPE" Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbCreateType" Style="{StaticResource StyledCombo}" Width="220"
                            HorizontalAlignment="Left">
                    <ComboBoxItem Content="Security" IsSelected="True"/>
                    <ComboBoxItem Content="Microsoft 365 (M365)"/>
                  </ComboBox>
                </StackPanel>

                <!-- Display name -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="DISPLAY NAME *" Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtCreateName" Style="{StaticResource InputBox}"
                           Tag="e.g. SG-Marketing-All"/>
                </StackPanel>

                <!-- M365-only fields (email address + sensitivity label) -->
                <StackPanel x:Name="PnlMailNick" Margin="0,0,0,0">

                  <!-- Group Email Address -->
                  <StackPanel Margin="0,0,0,12">
                    <TextBlock Text="GROUP EMAIL ADDRESS *" Style="{StaticResource FieldLabel}"/>
                    <Grid>
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBox x:Name="TxtCreateMailNick" Grid.Column="0"
                               Style="{StaticResource InputBox}"
                               Tag="mail-nickname (local part only)"/>
                      <Border Grid.Column="1" Background="$($DevConfig.ColorBackground)"
                              BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="0,1,1,1"
                              CornerRadius="0,4,4,0" Padding="10,0,10,0">
                        <TextBlock x:Name="TxtMailDomainSuffix" VerticalAlignment="Center"
                                   FontSize="13" Foreground="$($DevConfig.ColorTextMuted)"
                                   Text="@"/>
                      </Border>
                    </Grid>
                  </StackPanel>

                  <!-- Sensitivity Label (populated at runtime; hidden if tenant has none) -->
                  <StackPanel x:Name="PnlSensitivityLabel" Margin="0,0,0,12" Visibility="Collapsed">
                    <TextBlock Text="SENSITIVITY LABEL (optional)" Style="{StaticResource FieldLabel}"/>
                    <ComboBox x:Name="CmbSensitivityLabel" Style="{StaticResource StyledCombo}"
                              HorizontalAlignment="Left" Width="320">
                      <ComboBoxItem Content="(None)" IsSelected="True" Tag=""/>
                    </ComboBox>
                  </StackPanel>

                </StackPanel>

                <!-- Description -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="DESCRIPTION" Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtCreateDesc" Style="{StaticResource InputBox}"
                           Height="60" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="Optional description"/>
                </StackPanel>

                <!-- Initial owner UPNs -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="INITIAL OWNER UPNs (optional  -  one per line)"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtCreateOwner" Style="{StaticResource InputBox}"
                           Height="60" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="owner1@domain.com&#10;owner2@domain.com"/>
                </StackPanel>

                <!-- Initial members -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="INITIAL MEMBERS (optional  -  one per line, UPNs / Group names or IDs / Device names or IDs)"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtCreateMembers" Style="{StaticResource InputBox}"
                           Height="80" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="user@domain.com&#10;GroupName or GroupObjectID&#10;DeviceName or DeviceObjectID"/>
                </StackPanel>

                <!-- Dynamic query -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="DYNAMIC MEMBERSHIP RULE (optional  -  converts group to dynamic)"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtCreateDynamic" Style="{StaticResource InputBox}"
                           Tag='e.g. (user.department -eq "Marketing")'/>
                </StackPanel>

                <Button Margin="4,4,0,0" x:Name="BtnCreateValidate" Content="Validate &amp; Preview &#x2192;"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
              </StackPanel>
            </Border>

            <!-- === ADD / REMOVE MEMBERS panel (shared) === -->
            <Border x:Name="PanelMembership" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock x:Name="TxtMembershipTitle" Text="Add Members"
                           FontSize="15" FontWeight="SemiBold"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Group picker -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtGroupSearch" Grid.Column="0" Style="{StaticResource InputBox}"
                             Tag="Type group name, paste Object ID, or enter display name"/>
                    <Button x:Name="BtnGroupSearch" Grid.Column="2" Content="Search"
                            Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
                    <Button x:Name="BtnGroupById" Grid.Column="4" Content="Use as ID"
                            Style="{StaticResource OutlineBtn}" Width="80"/>
                  </Grid>

                  <!-- Search results -->
                  <Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
                          BorderThickness="1" CornerRadius="5"
                          x:Name="GroupSearchResults" Visibility="Collapsed" MaxHeight="150">
                    <ListBox x:Name="LstGroupResults" Background="$($DevConfig.ColorSurface)"
                             BorderThickness="0" FontSize="12"/>
                  </Border>

                  <!-- Selected group display -->
                  <Border x:Name="SelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
                          Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
                          BorderThickness="1" CornerRadius="5" Padding="10,6">
                    <StackPanel Orientation="Horizontal">
                      <TextBlock Text="Selected:" FontSize="11"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtSelectedGroupName" FontSize="12" FontWeight="SemiBold"
                                 Foreground="$($DevConfig.ColorPrimary)"/>
                      <TextBlock x:Name="TxtSelectedGroupId" FontSize="10"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtSelectedGroupMemCount" FontSize="10"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                    </StackPanel>
                  </Border>
                </StackPanel>

                <!-- Member input -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock x:Name="TxtMemberInputLabel"
                             Text="MEMBERS  -  one per line (User UPNs / Group names or IDs / Device names or IDs)"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtMemberList" Style="{StaticResource InputBox}"
                           Height="140" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="user@domain.com&#10;GroupName or GroupObjectID&#10;DeviceName or DeviceObjectID"/>
                </StackPanel>

                <Button Margin="4,4,0,0" x:Name="BtnMemberValidate" Content="Validate &amp; Preview &#x2192;"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
              </StackPanel>
            </Border>

            <!-- === EXPORT panel === -->
            <Border x:Name="PanelExport" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Export Group Members to XLSX"
                           FontSize="15" FontWeight="SemiBold"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Group picker (same pattern) -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtExportGroupSearch" Grid.Column="0"
                             Style="{StaticResource InputBox}"
                             Tag="Type group name, paste Object ID, or enter display name"/>
                    <Button x:Name="BtnExportGroupSearch" Grid.Column="2" Content="Search"
                            Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
                    <Button x:Name="BtnExportGroupById" Grid.Column="4" Content="Use as ID"
                            Style="{StaticResource OutlineBtn}" Width="80"/>
                  </Grid>
                  <Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
                          BorderThickness="1" CornerRadius="5"
                          x:Name="ExportGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
                    <ListBox x:Name="LstExportGroupResults" Background="$($DevConfig.ColorSurface)"
                             BorderThickness="0" FontSize="12"/>
                  </Border>
                  <Border x:Name="ExportSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
                          Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
                          BorderThickness="1" CornerRadius="5" Padding="10,6">
                    <StackPanel Orientation="Horizontal">
                      <TextBlock Text="Selected:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtExportGroupName" FontSize="12" FontWeight="SemiBold"
                                 Foreground="$($DevConfig.ColorPrimary)"/>
                      <TextBlock x:Name="TxtExportGroupId" FontSize="10"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                    </StackPanel>
                  </Border>
                </StackPanel>

                <!-- Output path -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="OUTPUT XLSX PATH" Style="{StaticResource FieldLabel}"/>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtExportPath" Grid.Column="0"
                             Style="{StaticResource InputBox}"
                             Tag="C:\Reports\GroupMembers.xlsx"/>
                    <Button x:Name="BtnExportBrowse" Grid.Column="2" Content="Browse&#x2026;"
                            Style="{StaticResource OutlineBtn}" Width="80"/>
                  </Grid>
                </StackPanel>

                <Button Margin="4,4,0,0" x:Name="BtnRunExport" Content="Export Members &#x2192;"
                        Style="{StaticResource AccentBtn}" HorizontalAlignment="Left" Width="160"/>
              </StackPanel>
            </Border>

            <!-- === DYNAMIC QUERY panel === -->
            <Border x:Name="PanelDynamic" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Set Dynamic Membership Query"
                           FontSize="15" FontWeight="SemiBold"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Group picker -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtDynGroupSearch" Grid.Column="0"
                             Style="{StaticResource InputBox}"
                             Tag="Type group name, paste Object ID, or enter display name"/>
                    <Button x:Name="BtnDynGroupSearch" Grid.Column="2" Content="Search"
                            Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
                    <Button x:Name="BtnDynGroupById" Grid.Column="4" Content="Use as ID"
                            Style="{StaticResource OutlineBtn}" Width="80"/>
                  </Grid>
                  <Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
                          BorderThickness="1" CornerRadius="5"
                          x:Name="DynGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
                    <ListBox x:Name="LstDynGroupResults" Background="$($DevConfig.ColorSurface)"
                             BorderThickness="0" FontSize="12"/>
                  </Border>
                  <Border x:Name="DynSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
                          Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
                          BorderThickness="1" CornerRadius="5" Padding="10,6">
                    <StackPanel Orientation="Horizontal">
                      <TextBlock Text="Selected:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtDynGroupName" FontSize="12" FontWeight="SemiBold"
                                 Foreground="$($DevConfig.ColorPrimary)"/>
                      <TextBlock x:Name="TxtDynGroupId" FontSize="10"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtDynCurrentRule" FontSize="10" FontStyle="Italic"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                    </StackPanel>
                  </Border>
                </StackPanel>

                <!-- Rule input -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="MEMBERSHIP RULE" Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtDynRule" Style="{StaticResource InputBox}"
                           Height="80" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag='e.g. (user.department -eq "Marketing") -and (user.accountEnabled -eq true)'/>
                </StackPanel>

                <TextBlock Margin="0,0,0,12" Text="&#x26A0; Setting a dynamic rule will convert a static group to dynamic. This removes all current members."
                           FontSize="11" Foreground="$($DevConfig.ColorWarning)"
                           TextWrapping="Wrap"/>

                <Button Margin="4,4,0,0" x:Name="BtnDynValidate" Content="Validate Rule &amp; Preview &#x2192;"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="200"/>
              </StackPanel>
            </Border>

            <!-- === RENAME panel === -->
            <Border x:Name="PanelRename" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Rename Group" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Group picker -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtRenGroupSearch" Grid.Column="0"
                             Style="{StaticResource InputBox}"
                             Tag="Type group name, paste Object ID, or enter display name"/>
                    <Button x:Name="BtnRenGroupSearch" Grid.Column="2" Content="Search"
                            Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
                    <Button x:Name="BtnRenGroupById" Grid.Column="4" Content="Use as ID"
                            Style="{StaticResource OutlineBtn}" Width="80"/>
                  </Grid>
                  <Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
                          BorderThickness="1" CornerRadius="5"
                          x:Name="RenGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
                    <ListBox x:Name="LstRenGroupResults" Background="$($DevConfig.ColorSurface)"
                             BorderThickness="0" FontSize="12"/>
                  </Border>
                  <Border x:Name="RenSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
                          Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
                          BorderThickness="1" CornerRadius="5" Padding="10,6">
                    <StackPanel Orientation="Horizontal">
                      <TextBlock Text="Current name:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtRenGroupCurrentName" FontSize="12" FontWeight="SemiBold"
                                 Foreground="$($DevConfig.ColorPrimary)"/>
                      <TextBlock x:Name="TxtRenGroupId" FontSize="10"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                    </StackPanel>
                  </Border>
                </StackPanel>

                <!-- New name -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="NEW DISPLAY NAME *" Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtNewGroupName" Style="{StaticResource InputBox}"
                           Tag="Enter new display name"/>
                </StackPanel>

                <Button Margin="4,4,0,0" x:Name="BtnRenValidate" Content="Validate &amp; Preview &#x2192;"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
              </StackPanel>
            </Border>

            <!-- === SET OWNER panel === -->
            <Border x:Name="PanelOwner" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Set Group Owner" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Group list input -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="TARGET GROUPS  -  one per line (display name or Object ID)"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtOwnerGroupList" Style="{StaticResource InputBox}"
                           Height="90" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="Group-Finance-APAC&#10;Group-Finance-EMEA&#10;b3f1a2c0-..."/>
                </StackPanel>

                <!-- Owner UPNs -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="OWNER UPNs  -  one per line"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtOwnerList" Style="{StaticResource InputBox}"
                           Height="100" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="owner1@domain.com&#10;owner2@domain.com"/>
                </StackPanel>

                <Button Margin="4,4,0,0" x:Name="BtnOwnerValidate" Content="Validate &amp; Preview &#x2192;"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
              </StackPanel>
            </Border>



            <!-- === ADD USER DEVICES TO GROUP panel === -->
            <Border x:Name="PanelUserDevices" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Add User Devices to Group" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
                           Foreground="$($DevConfig.ColorPrimary)"/>

                <!-- Target group picker -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
                  <Grid>
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtUDGroupSearch" Grid.Column="0"
                             Style="{StaticResource InputBox}"
                             Tag="Type group name, paste Object ID, or enter display name"/>
                    <Button x:Name="BtnUDGroupSearch" Grid.Column="2" Content="Search"
                            Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
                    <Button x:Name="BtnUDGroupById" Grid.Column="4" Content="Use as ID"
                            Style="{StaticResource OutlineBtn}" Width="80"/>
                  </Grid>
                  <Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
                          BorderThickness="1" CornerRadius="5"
                          x:Name="UDGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
                    <ListBox x:Name="LstUDGroupResults" Background="$($DevConfig.ColorSurface)"
                             BorderThickness="0" FontSize="12"/>
                  </Border>
                  <Border x:Name="UDSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
                          Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
                          BorderThickness="1" CornerRadius="5" Padding="10,6">
                    <StackPanel Orientation="Horizontal">
                      <TextBlock Text="Selected:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
                      <TextBlock x:Name="TxtUDGroupName" FontSize="12" FontWeight="SemiBold"
                                 Foreground="$($DevConfig.ColorPrimary)"/>
                      <TextBlock x:Name="TxtUDGroupId" FontSize="10"
                                 Foreground="$($DevConfig.ColorTextMuted)"/>
                    </StackPanel>
                  </Border>
                </StackPanel>

                <!-- User UPNs -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="USER UPNs  -  one per line (devices owned by these users will be evaluated)"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtUDUpnList" Style="{StaticResource InputBox}"
                           Height="100" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="user1@domain.com&#10;user2@domain.com"/>
                </StackPanel>

                <!-- Filters -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="DEVICE PLATFORM FILTER  -  only devices matching a checked platform will be added"
                             Style="{StaticResource FieldLabel}"/>
                  <WrapPanel Margin="0,4,0,0">
                    <CheckBox x:Name="ChkUDWindows" Content="Windows"  IsChecked="True"  Margin="0,0,16,4" FontSize="12"/>
                    <CheckBox x:Name="ChkUDAndroid" Content="Android"  IsChecked="True"  Margin="0,0,16,4" FontSize="12"/>
                    <CheckBox x:Name="ChkUDiOS"     Content="iOS"      IsChecked="True"  Margin="0,0,16,4" FontSize="12"/>
                    <CheckBox x:Name="ChkUDMacOS"   Content="macOS"    IsChecked="True"  Margin="0,0,0,4"  FontSize="12"/>
                  </WrapPanel>
                </StackPanel>

                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="DEVICE OWNERSHIP FILTER"
                             Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbUDOwnership" Style="{StaticResource StyledCombo}"
                            Width="200" HorizontalAlignment="Left" Margin="0,4,0,0">
                    <ComboBoxItem Content="All ownership types" IsSelected="True"/>
                    <ComboBoxItem Content="Company only"/>
                    <ComboBoxItem Content="Personal only"/>
                  </ComboBox>
                </StackPanel>

                <StackPanel Margin="0,0,0,16">
                  <TextBlock Text="INTUNE ENROLLMENT FILTER"
                             Style="{StaticResource FieldLabel}"/>
                  <CheckBox x:Name="ChkUDIntuneOnly" Content="Intune enrolled devices only"
                            IsChecked="False" FontSize="12" Margin="0,4,0,0"
                            ToolTip="When checked, only devices managed by Intune (managementType = MDM) are included"/>
                </StackPanel>

                <Button Margin="4,4,0,0" x:Name="BtnUDValidate" Content="Validate &amp; Preview &#x2192;"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
              </StackPanel>
            </Border>

            <!-- === FIND COMMON GROUPS panel === -->
            <Border x:Name="PanelFindCommon" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Find Common Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Finds groups that every input object is a member of."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14"
                           TextWrapping="Wrap"/>

                <!-- Input type selector -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
                  <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
                    <RadioButton x:Name="RbFCUsers"   Content="Users"   GroupName="FCType"
                                 IsChecked="True" FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
                    <RadioButton x:Name="RbFCDevices" Content="Devices" GroupName="FCType"
                                 FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
                    <RadioButton x:Name="RbFCGroups"  Content="Groups"  GroupName="FCType"
                                 FontSize="12" VerticalContentAlignment="Center"/>
                  </StackPanel>
                </StackPanel>

                <!-- Input list + inline Find button -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock x:Name="TxtFCInputLabel"
                             Text="USER UPNs  -  one per line"
                             Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtFCInputList" Grid.Column="0" Style="{StaticResource InputBox}"
                             Height="130" TextWrapping="Wrap" AcceptsReturn="True"
                             VerticalScrollBarVisibility="Auto"
                             Tag="Enter one identifier per line"/>
                    <Button Grid.Column="2" x:Name="BtnFCFind" Content="&#x1F50D;  Find Common Groups"
                            Style="{StaticResource PrimaryBtn}" Width="160" VerticalAlignment="Top"/>
                  </Grid>
                </StackPanel>

                <!-- Progress overlay -->
                <Border x:Name="PnlFCProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtFCProgressMsg" Text="Resolving objects..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbFCProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtFCProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results area -->
                <Border x:Name="PnlFCResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtFCCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnFCFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtFCFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnFCFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnFCCopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFCCopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFCCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFCExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgFCResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5FBF5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Group Name"      Binding="{Binding GroupName}"      Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group ID"        Binding="{Binding GroupID}"        Width="2*" MinWidth="120"/>
                        <DataGridTextColumn Header="Group Type"      Binding="{Binding GroupType}"      Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Membership Type" Binding="{Binding MembershipType}" Width="*"  MinWidth="120"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtFCNoResults" Visibility="Collapsed"
                           Text="No common groups found for the specified objects."
                           FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
              </StackPanel>
            </Border>

            <!-- === FIND DISTINCT GROUPS panel === -->
            <Border x:Name="PanelFindDistinct" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Find Distinct Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Finds groups that are NOT shared by all input objects (union minus intersection)."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14"
                           TextWrapping="Wrap"/>

                <!-- Input type selector -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
                  <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
                    <RadioButton x:Name="RbFDUsers"   Content="Users"   GroupName="FDType"
                                 IsChecked="True" FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
                    <RadioButton x:Name="RbFDDevices" Content="Devices" GroupName="FDType"
                                 FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
                    <RadioButton x:Name="RbFDGroups"  Content="Groups"  GroupName="FDType"
                                 FontSize="12" VerticalContentAlignment="Center"/>
                  </StackPanel>
                </StackPanel>

                <!-- Input list + inline Find button -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock x:Name="TxtFDInputLabel"
                             Text="USER UPNs  -  one per line"
                             Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtFDInputList" Grid.Column="0" Style="{StaticResource InputBox}"
                             Height="130" TextWrapping="Wrap" AcceptsReturn="True"
                             VerticalScrollBarVisibility="Auto"
                             Tag="Enter one identifier per line"/>
                    <Button Grid.Column="2" x:Name="BtnFDFind" Content="&#x1F50D;  Find Distinct Groups"
                            Style="{StaticResource PrimaryBtn}" Width="160" VerticalAlignment="Top"/>
                  </Grid>
                </StackPanel>

                <!-- Progress overlay -->
                <Border x:Name="PnlFDProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtFDProgressMsg" Text="Resolving objects..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbFDProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtFDProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results area -->
                <Border x:Name="PnlFDResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtFDCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnFDFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtFDFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnFDFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnFDCopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFDCopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFDCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnFDExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgFDResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#EDF5FF"
                              BorderBrush="#A0C0E0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#DBEAFE"/>
                          <Setter Property="Foreground" Value="#1E3A5F"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Group Name"      Binding="{Binding GroupName}"      Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group ID"        Binding="{Binding GroupID}"        Width="2*" MinWidth="120"/>
                        <DataGridTextColumn Header="Group Type"      Binding="{Binding GroupType}"      Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Membership Type" Binding="{Binding MembershipType}" Width="*"  MinWidth="120"/>
                        <DataGridTextColumn Header="Member"        Binding="{Binding MemberOf}"       Width="2*" MinWidth="160"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtFDNoResults" Visibility="Collapsed"
                           Text="No distinct groups found for the specified objects."
                           FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
              </StackPanel>
            </Border>

            <!-- === GET DEVICE INFO panel === -->
            <Border x:Name="PanelGetDeviceInfo" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Get Device Info" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Fetches combined Entra and Intune device data and exports to XLSX."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>

                <!-- Input type -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbGDIInputType" Style="{StaticResource StyledCombo}"
                            Width="260" HorizontalAlignment="Left" Margin="0,4,0,0">
                    <ComboBoxItem Content="User UPNs (resolve owned devices)" IsSelected="True"/>
                    <ComboBoxItem Content="Device names"/>
                    <ComboBoxItem Content="Device serial numbers"/>
                    <ComboBoxItem Content="Entra Device Object IDs"/>
                    <ComboBoxItem Content="Intune Device IDs"/>
                    <ComboBoxItem Content="Groups (Users and Devices)"/>
                  </ComboBox>
                </StackPanel>

                <!-- Input list -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock x:Name="TxtGDIInputLabel" Text="USER UPNs  -  one per line"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtGDIInputList" Style="{StaticResource InputBox}"
                           Height="110" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
                </StackPanel>

                <!-- Platform filter -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="PLATFORM FILTER (leave all unchecked for all platforms)"
                             Style="{StaticResource FieldLabel}"/>
                  <WrapPanel Margin="0,4,0,0">
                    <CheckBox x:Name="ChkGDIWindows" Content="Windows" Margin="0,0,16,4" FontSize="12"/>
                    <CheckBox x:Name="ChkGDIAndroid" Content="Android" Margin="0,0,16,4" FontSize="12"/>
                    <CheckBox x:Name="ChkGDIiOS"     Content="iOS"     Margin="0,0,16,4" FontSize="12"/>
                    <CheckBox x:Name="ChkGDIMacOS"   Content="macOS"   Margin="0,0,0,4"  FontSize="12"/>
                  </WrapPanel>
                </StackPanel>

                <!-- Ownership filter -->
                <StackPanel Margin="0,0,0,8">
                  <TextBlock Text="OWNERSHIP FILTER" Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbGDIOwnership" Style="{StaticResource StyledCombo}"
                            Width="200" HorizontalAlignment="Left" Margin="0,4,0,0">
                    <ComboBoxItem Content="All ownership types" IsSelected="True"/>
                    <ComboBoxItem Content="Company only"/>
                    <ComboBoxItem Content="Personal only"/>
                  </ComboBox>
                </StackPanel>

                <!-- Run buttons -->
                <StackPanel Orientation="Horizontal" Margin="0,0,0,16">
                  <Button x:Name="BtnGDIRunAll" Content="&#x25B6;  Get All Device Info"
                          Style="{StaticResource PrimaryBtn}" Width="200" HorizontalAlignment="Left" Margin="0,4,0,0"/>
                  <Button x:Name="BtnGDIRun" Content="&#x25B6;  Get Device Info"
                          Style="{StaticResource PrimaryBtn}" Width="180" HorizontalAlignment="Left" Margin="12,4,0,0"/>
                </StackPanel>

                <!-- Progress -->
                <Border x:Name="PnlGDIProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtGDIProgressMsg" Text="Resolving devices..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbGDIProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtGDIProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results -->
                <Border x:Name="PnlGDIResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtGDICount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnGDIFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtGDIFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnGDIFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnGDICopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnGDICopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnGDICopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnGDIExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgGDIResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5F5FF"
                              BorderBrush="#B0B0D0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8EAF6"/>
                          <Setter Property="Foreground" Value="#1A237E"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Device Name"         Binding="{Binding Entra_DeviceName}"          Width="2*" MinWidth="140"/>
                        <DataGridTextColumn Header="Device Type"         Binding="{Binding DeviceType}"                Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Join Type"           Binding="{Binding Entra_JoinType}"            Width="*"  MinWidth="110"/>
                        <DataGridTextColumn Header="Entra Ownership"     Binding="{Binding Entra_Ownership}"           Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Intune Ownership"    Binding="{Binding Intune_Ownership}"          Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Entra OS"            Binding="{Binding Entra_OSPlatform}"          Width="*"  MinWidth="80"/>
                        <DataGridTextColumn Header="Intune OS"           Binding="{Binding Intune_OSPlatform}"         Width="*"  MinWidth="80"/>
                        <DataGridTextColumn Header="Entra OS Version"    Binding="{Binding Entra_OSVersion}"           Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Intune OS Version"   Binding="{Binding Intune_OSVersion}"          Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Windows Release"     Binding="{Binding Windows_Release}"           Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Device Enabled"      Binding="{Binding Entra_DeviceEnabled}"       Width="*"  MinWidth="90"/>
                        <DataGridTextColumn Header="Entra Last Sign-In"  Binding="{Binding Entra_LastSignIn}"          Width="*"  MinWidth="110"/>
                        <DataGridTextColumn Header="Entra Activity"      Binding="{Binding Entra_ActivityRange}"       Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Intune Check-In"     Binding="{Binding Intune_LastCheckIn}"        Width="*"  MinWidth="110"/>
                        <DataGridTextColumn Header="Intune Activity"     Binding="{Binding Intune_ActivityRange}"      Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="OEM"                 Binding="{Binding Intune_OEM}"                Width="*"  MinWidth="80"/>
                        <DataGridTextColumn Header="Model"               Binding="{Binding Intune_Model}"              Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Serial Number"       Binding="{Binding Intune_SerialNumber}"       Width="*"  MinWidth="120"/>
                        <DataGridTextColumn Header="Enrolled Date"       Binding="{Binding Intune_EnrolledDate}"       Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Compliance State"   Binding="{Binding Intune_ComplianceState}"    Width="*"  MinWidth="110"/>
                        <DataGridTextColumn Header="Entra Device ID"     Binding="{Binding Entra_DeviceID}"            Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Entra Object ID"     Binding="{Binding Entra_ObjectID}"            Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Intune Device ID"    Binding="{Binding Intune_DeviceID}"           Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Device Owner UPN"    Binding="{Binding Entra_DeviceOwner}"         Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Primary User UPN"    Binding="{Binding Entra_PrimaryUserUPN}"      Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="User Enabled"        Binding="{Binding Entra_UserAccountEnabled}"  Width="*"  MinWidth="90"/>
                        <DataGridTextColumn Header="User Country"        Binding="{Binding Entra_UserCountry}"         Width="*"  MinWidth="90"/>
                        <DataGridTextColumn Header="User City"           Binding="{Binding Entra_UserCity}"            Width="*"  MinWidth="80"/>
                        <DataGridTextColumn Header="User Job Title"      Binding="{Binding Entra_UserJobTitle}"        Width="*"  MinWidth="110"/>
                        <DataGridTextColumn Header="AP Group Tag"        Binding="{Binding Autopilot_GroupTag}"        Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="AP Profile Status"   Binding="{Binding Autopilot_ProfileStatus}"   Width="*"  MinWidth="120"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtGDINoResults" Visibility="Collapsed"
                           Text="No devices matched the input and filters."
                           FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
              </StackPanel>
            </Border>




            <!-- === GET DISCOVERED APPS panel === -->
            <Border x:Name="PanelGetDiscoveredApps" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Get Discovered Apps" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Fetches apps discovered on Intune managed devices. Supports multiple input types and filters."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>

                <!-- Input type -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbDAInputType" Style="{StaticResource StyledCombo}"
                            Width="300" HorizontalAlignment="Left" Margin="0,4,0,0">
                    <ComboBoxItem Content="User UPNs (resolve owned devices)" IsSelected="True"/>
                    <ComboBoxItem Content="Device names"/>
                    <ComboBoxItem Content="Device IDs (Entra)"/>
                    <ComboBoxItem Content="Serial numbers"/>
                    <ComboBoxItem Content="Groups (names or IDs)"/>
                  </ComboBox>
                </StackPanel>

                <!-- Input list -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock x:Name="TxtDAInputLabel" Text="USER UPNs  -  one per line"
                             Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtDAInputList" Style="{StaticResource InputBox}"
                           Height="110" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
                </StackPanel>

                <!-- Platform filter + Keyword filter (side by side) -->
                <Grid Margin="0,0,0,2">
                  <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="40"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="*"/>
                  </Grid.ColumnDefinitions>
                  <StackPanel Grid.Column="0">
                    <TextBlock Text="PLATFORM FILTER (leave all unchecked for all platforms)"
                               Style="{StaticResource FieldLabel}"/>
                    <WrapPanel Margin="0,4,0,0">
                      <CheckBox x:Name="ChkDAWindows" Content="Windows" Margin="0,0,16,4" FontSize="12"/>
                      <CheckBox x:Name="ChkDAAndroid" Content="Android" Margin="0,0,16,4" FontSize="12"/>
                      <CheckBox x:Name="ChkDAiOS"     Content="iOS"     Margin="0,0,16,4" FontSize="12"/>
                      <CheckBox x:Name="ChkDAMacOS"   Content="macOS"   Margin="0,0,0,4"  FontSize="12"/>
                    </WrapPanel>
                  </StackPanel>
                  <StackPanel Grid.Column="2" VerticalAlignment="Top">
                    <TextBlock Text="APP KEYWORD FILTER (optional)" Style="{StaticResource FieldLabel}"/>
                    <TextBox x:Name="TxtDAKeyword" Style="{StaticResource InputBox}"
                             Width="250" HorizontalAlignment="Left" Margin="0,4,0,0"
                             Tag="e.g. Chrome, Zoom, Office"/>
                  </StackPanel>
                </Grid>

                <!-- Ownership filter -->
                <StackPanel Margin="0,0,0,4">
                  <TextBlock Text="OWNERSHIP FILTER" Style="{StaticResource FieldLabel}"/>
                  <ComboBox x:Name="CmbDAOwnership" Style="{StaticResource StyledCombo}"
                            Width="200" HorizontalAlignment="Left" Margin="0,4,0,0">
                    <ComboBoxItem Content="All ownership types" IsSelected="True"/>
                    <ComboBoxItem Content="Company only"/>
                    <ComboBoxItem Content="Personal only"/>
                  </ComboBox>
                </StackPanel>

                <!-- Run buttons -->
                <StackPanel Orientation="Horizontal" Margin="0,0,0,16">
                  <Button x:Name="BtnDARun" Content="&#x25B6;  Get Discovered Apps"
                          Style="{StaticResource PrimaryBtn}" Width="220" HorizontalAlignment="Left" Margin="0,4,0,0"/>
                  <Button x:Name="BtnDARunAll" Content="&#x25B6;  Get Discovered Apps from All Devices"
                          Style="{StaticResource PrimaryBtn}" Width="310" HorizontalAlignment="Left" Margin="12,4,0,0"/>
                </StackPanel>

                <!-- Progress -->
                <Border x:Name="PnlDAProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtDAProgressMsg" Text="Resolving devices..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbDAProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtDAProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results -->
                <Border x:Name="PnlDAResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtDACount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnDAFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtDAFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnDAFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnDACopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnDACopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnDACopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnDAExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgDAResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5F5FF"
                              BorderBrush="#B0B0D0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8EAF6"/>
                          <Setter Property="Foreground" Value="#1A237E"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Device Name"       Binding="{Binding DeviceName}"      Width="2*" MinWidth="140"/>
                        <DataGridTextColumn Header="Entra Device ID"   Binding="{Binding EntraDeviceId}"   Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Primary User UPN"  Binding="{Binding PrimaryUserUPN}"  Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="OS Version"        Binding="{Binding OSVersion}"       Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Discovered App"    Binding="{Binding DiscoveredApp}"   Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="App Version"       Binding="{Binding AppVersion}"      Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="App Publisher"     Binding="{Binding AppPublisher}"    Width="2*" MinWidth="130"/>
                        <DataGridTextColumn Header="Collected At"      Binding="{Binding CollectedAt}"     Width="*"  MinWidth="120"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtDANoResults" Visibility="Collapsed"
                           Text="No discovered apps matched the input and filters."
                           FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
              </StackPanel>
            </Border>


            <!-- === GET POLICY ASSIGNMENTS panel === -->
            <Border x:Name="PanelGetPolicyAssignments" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Get Policy Assignments" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Query Intune policy assignments across all policy types. Use filters to narrow results, or Get All Assignments to retrieve everything."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>


                <!-- Filters + action buttons -->
                <Grid Margin="0,0,0,14">
                  <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="16"/>
                    <ColumnDefinition Width="Auto"/>
                  </Grid.ColumnDefinitions>

                  <!-- Filter stack (vertical) -->
                  <StackPanel Grid.Column="0">

                    <!-- Policy Name keyword -->
                    <StackPanel Margin="0,0,0,10">
                      <TextBlock Text="POLICY NAME  (keyword &#x2014; blank for all)" Style="{StaticResource FieldLabel}"/>
                      <Border Background="#FFFFFF" BorderBrush="#B0D0B0" BorderThickness="1"
                              CornerRadius="8" Height="36" Margin="0,4,0,0">
                        <TextBox x:Name="TxtGPANameFilter"
                                 Background="Transparent" Foreground="#374151" CaretBrush="#22C55E"
                                 BorderThickness="0" Padding="10,0" FontSize="12"
                                 VerticalContentAlignment="Center"/>
                      </Border>
                    </StackPanel>

                    <!-- Policy Type -->
                    <StackPanel Margin="0,0,0,10">
                      <TextBlock Text="POLICY TYPE" Style="{StaticResource FieldLabel}"/>
                      <ComboBox x:Name="CmbGPAType" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
                        <ComboBoxItem Content="(All types)" IsSelected="True"/>
                        <ComboBoxItem Content="Device Configuration"/>
                        <ComboBoxItem Content="Configuration Policy"/>
                        <ComboBoxItem Content="Autopilot Deployment Profile"/>
                        <ComboBoxItem Content="Platform Script (Windows PowerShell)"/>
                        <ComboBoxItem Content="Detection/Remediation Script"/>
                        <ComboBoxItem Content="Administrative Templates"/>
                      </ComboBox>
                    </StackPanel>

                    <!-- Policy Sub-Type -->
                    <StackPanel Margin="0,0,0,10">
                      <TextBlock Text="POLICY SUB-TYPE" Style="{StaticResource FieldLabel}"/>
                      <ComboBox x:Name="CmbGPASubType" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
                        <ComboBoxItem Content="(All sub-types)" IsSelected="True"/>
                        <ComboBoxItem Content="Administrative Templates"/>
                        <ComboBoxItem Content="App Control for Business"/>
                        <ComboBoxItem Content="Attack Surface Reduction Rules"/>
                        <ComboBoxItem Content="BitLocker"/>
                        <ComboBoxItem Content="Built-in"/>
                        <ComboBoxItem Content="Certificate"/>
                        <ComboBoxItem Content="Custom"/>
                        <ComboBoxItem Content="Defender Update Controls"/>
                        <ComboBoxItem Content="Device Features"/>
                        <ComboBoxItem Content="Device Preparation"/>
                        <ComboBoxItem Content="Edition Upgrade"/>
                        <ComboBoxItem Content="Endpoint Protection"/>
                        <ComboBoxItem Content="Exploit Protection"/>
                        <ComboBoxItem Content="Extensions"/>
                        <ComboBoxItem Content="General Device"/>
                        <ComboBoxItem Content="Health Monitoring"/>
                        <ComboBoxItem Content="Identity Protection"/>
                        <ComboBoxItem Content="Kiosk"/>
                        <ComboBoxItem Content="Local Admin Password Solution (Windows LAPS)"/>
                        <ComboBoxItem Content="Local User Group Membership"/>
                        <ComboBoxItem Content="MDE Onboarding Policy"/>
                        <ComboBoxItem Content="Microsoft Defender Antivirus"/>
                        <ComboBoxItem Content="Microsoft Defender Antivirus Exclusions"/>
                        <ComboBoxItem Content="PowerShell"/>
                        <ComboBoxItem Content="Remediation"/>
                        <ComboBoxItem Content="SCEP Certificate"/>
                        <ComboBoxItem Content="Settings Catalog"/>
                        <ComboBoxItem Content="Shared PC"/>
                        <ComboBoxItem Content="Software Update"/>
                        <ComboBoxItem Content="Trusted Certificate"/>
                        <ComboBoxItem Content="Update Rings"/>
                        <ComboBoxItem Content="VPN"/>
                        <ComboBoxItem Content="Wi-Fi"/>
                        <ComboBoxItem Content="Windows Firewall"/>
                        <ComboBoxItem Content="Windows Firewall Rules"/>
                        <ComboBoxItem Content="Windows OS Recovery"/>
                        <ComboBoxItem Content="Windows Security Experience"/>
                      </ComboBox>
                    </StackPanel>

                    <!-- OS Platform -->
                    <StackPanel>
                      <TextBlock Text="OS PLATFORM" Style="{StaticResource FieldLabel}"/>
                      <ComboBox x:Name="CmbGPAPlatform" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
                        <ComboBoxItem Content="(All platforms)" IsSelected="True"/>
                        <ComboBoxItem Content="Windows"/>
                        <ComboBoxItem Content="iOS/iPadOS"/>
                        <ComboBoxItem Content="Android"/>
                        <ComboBoxItem Content="AOSP"/>
                        <ComboBoxItem Content="macOS"/>
                        <ComboBoxItem Content="Linux"/>
                      </ComboBox>
                    </StackPanel>

                  </StackPanel>

                  <!-- Action buttons (right of filters) -->
                  <StackPanel Grid.Column="2" VerticalAlignment="Center">
                    <Button x:Name="BtnGPAGetSelected" Content="&#x25B6;  Get Selected Assignments"
                            Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
                    <Button x:Name="BtnGPAGetAll" Content="&#x25B6;  Get All Assignments"
                            Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="0,8,0,0"/>
                  </StackPanel>

                </Grid>

                <!-- Progress -->
                <Border x:Name="PnlGPAProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtGPAProgressMsg" Text="Loading policies..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbGPAProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtGPAProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- No results message -->
                <TextBlock x:Name="TxtGPANoResults" Visibility="Collapsed"
                           Text="No policy assignments matched the selected filters."
                           FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>

                <!-- Results panel -->
                <Border x:Name="PnlGPAResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtGPACount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnGPAFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtGPAFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnGPAFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnGPACopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnGPACopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnGPACopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnGPAExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgGPAResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5F5FF"
                              BorderBrush="#B0B0D0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False" CanUserResizeColumns="True"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8EAF6"/>
                          <Setter Property="Foreground" Value="#1A237E"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Policy Name"     Binding="{Binding PolicyName}"    Width="3*" MinWidth="200"/>
                        <DataGridTextColumn Header="Type"            Binding="{Binding PolicyType}"    Width="2*" MinWidth="150"/>
                        <DataGridTextColumn Header="Sub-Type"        Binding="{Binding PolicySubType}" Width="2*" MinWidth="150"/>
                        <DataGridTextColumn Header="OS Platform"     Binding="{Binding OSPlatform}"    Width="*"  MinWidth="100"/>
                        <DataGridTextColumn Header="Included Group"  Binding="{Binding IncludedGroup}" Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Excluded Group"  Binding="{Binding ExcludedGroup}" Width="2*" MinWidth="160"/>
                        <DataGridTextColumn Header="Include Filter"  Binding="{Binding IncludeFilter}" Width="2*" MinWidth="150"/>
                        <DataGridTextColumn Header="Exclude Filter"  Binding="{Binding ExcludeFilter}" Width="2*" MinWidth="150"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>

              </StackPanel>
            </Border>


            <!-- === COMPARE GROUPS panel === -->
            <Border x:Name="PanelCompareGroups" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Compare Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock Text="Query and compare membership across multiple groups. Enter display names or Object IDs, one per line."
                           FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>

                <!-- Group input -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="GROUPS  *  (one per line  &#x2014;  display name or Object ID)" Style="{StaticResource FieldLabel}"/>
                  <Grid Margin="0,4,0,0">
                    <Grid.ColumnDefinitions>
                      <ColumnDefinition Width="*"/>
                      <ColumnDefinition Width="8"/>
                      <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="TxtCGGroupInput" Grid.Column="0" Style="{StaticResource InputBox}"
                             Height="80" TextWrapping="Wrap" AcceptsReturn="True"
                             VerticalScrollBarVisibility="Auto"
                             Tag="SG-Marketing-All&#10;SG-Sales-All&#10;SG-IT-All"/>
                    <StackPanel Grid.Column="2" Orientation="Vertical" VerticalAlignment="Top">
                      <Button x:Name="BtnCGCommon"   Content="Common Members"
                              Style="{StaticResource PrimaryBtn}" Width="140" Margin="0,0,0,6"/>
                      <Button x:Name="BtnCGDistinct" Content="Distinct Members"
                              Style="{StaticResource PrimaryBtn}" Width="140"/>
                    </StackPanel>
                  </Grid>
                </StackPanel>

                <!-- Progress overlay -->
                <Border x:Name="PnlCGProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtCGProgressMsg" Text="Querying groups..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbCGProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtCGProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results area -->
                <Border x:Name="PnlCGResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtCGCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnCGFilterClear" Content="&#x2715;"
                                Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
                                IsEnabled="False" Height="30" Width="30" FontSize="12"
                                Padding="0" Margin="0,0,4,0"/>
                        <TextBox x:Name="TxtCGFilter" Width="150" Height="30" FontSize="12"
                                VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
                                IsEnabled="False" Tag="keyword filter..."/>
                        <Button x:Name="BtnCGFilter" Content="&#x1F50D; Filter"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,12,0"/>
                        <Button x:Name="BtnCGCopyValue" Content="Copy Value"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnCGCopyRow" Content="Copy Row"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnCGCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnCGExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgCGResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#FFF8ED"
                              BorderBrush="#F5C96A" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="420" FontSize="12">
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#FEF3C7"/>
                          <Setter Property="Foreground" Value="#92400E"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Object"       Binding="{Binding DisplayName}" Width="2*" IsReadOnly="True"/>
                        <DataGridTextColumn Header="Object ID"    Binding="{Binding ObjectID}"    Width="2*" IsReadOnly="True"/>
                        <DataGridTextColumn Header="Object Type"  Binding="{Binding ObjectType}"  Width="*"  IsReadOnly="True"/>
                        <DataGridTextColumn Header="Source Group" Binding="{Binding SourceGroup}" Width="*"  IsReadOnly="True"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>
                <TextBlock x:Name="TxtCGNoResults" Visibility="Collapsed"
                           Text="No members found for the specified group(s)."
                           FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
              </StackPanel>
            </Border>

            <!-- === REMOVE GROUPS panel === -->
            <Border x:Name="PanelRemoveGroups" Style="{StaticResource Card}" Visibility="Collapsed">
              <StackPanel>
                <TextBlock Text="Remove Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
                           Foreground="$($DevConfig.ColorPrimary)"/>
                <TextBlock FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"
                           Text="&#x26A0; Remove one or more Groups. Enter one group display name or Object ID per line."/>

                <!-- Input -->
                <StackPanel Margin="0,0,0,12">
                  <TextBlock Text="GROUP NAMES / OBJECT IDs  &#x2014;  one per line" Style="{StaticResource FieldLabel}"/>
                  <TextBox x:Name="TxtRGGroupList" Style="{StaticResource InputBox}"
                           Height="100" TextWrapping="Wrap" AcceptsReturn="True"
                           VerticalScrollBarVisibility="Auto"
                           Tag="GroupDisplayName&#10;00000000-0000-0000-0000-000000000001&#10;AnotherGroup"/>
                </StackPanel>

                <!-- Validate button -->
                <Button x:Name="BtnRGValidate" Content="&#x2714;  Validate Groups"
                        Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="160" Margin="4,4,0,12"/>

                <!-- Validate progress -->
                <Border x:Name="PnlRGProgress" Visibility="Collapsed"
                        Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtRGProgressMsg" Text="Validating groups..."
                               Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbRGProgress" Height="6" IsIndeterminate="True"
                                 Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
                    <TextBlock x:Name="TxtRGProgressDetail" Text=""
                               Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Warning banner -->
                <Border x:Name="PnlRGWarning" Visibility="Collapsed"
                        Background="#FFF8E1" CornerRadius="6" Padding="12,10" Margin="0,0,0,12"
                        BorderBrush="#F59E0B" BorderThickness="1">
                  <StackPanel Orientation="Horizontal">
                    <TextBlock Text="&#x26A0;" FontSize="15" Foreground="#B45309" Margin="0,0,8,0" VerticalAlignment="Center"/>
                    <TextBlock x:Name="TxtRGWarning" FontSize="12" Foreground="#92400E" TextWrapping="Wrap"
                               VerticalAlignment="Center"/>
                  </StackPanel>
                </Border>

                <!-- Preview table + Execute button -->
                <Border x:Name="PnlRGPreview" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtRGPreviewCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <Button x:Name="BtnRGExecute" Grid.Column="1"
                              Content="&#x1F5D1;  Remove Groups" IsEnabled="False"
                              Padding="14,8" FontSize="12" FontWeight="Bold" Cursor="Hand"
                              Foreground="White" BorderThickness="0">
                        <Button.Background>
                          <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
                            <GradientStop Color="#DC2626" Offset="0"/>
                            <GradientStop Color="#B91C1C" Offset="1"/>
                          </LinearGradientBrush>
                        </Button.Background>
                        <Button.Template>
                          <ControlTemplate TargetType="Button">
                            <Border Background="{TemplateBinding Background}" CornerRadius="8"
                                    Padding="{TemplateBinding Padding}" x:Name="RGExecBg">
                              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                            </Border>
                            <ControlTemplate.Triggers>
                              <Trigger Property="IsEnabled" Value="False">
                                <Setter TargetName="RGExecBg" Property="Opacity" Value="0.45"/>
                              </Trigger>
                              <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="RGExecBg" Property="Opacity" Value="0.82"/>
                              </Trigger>
                            </ControlTemplate.Triggers>
                          </ControlTemplate>
                        </Button.Template>
                      </Button>
                    </Grid>
                    <DataGrid x:Name="DgRGPreview"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Single" SelectionUnit="FullRow"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5FBF5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="280" FontSize="12">
                      <DataGrid.RowStyle>
                        <Style TargetType="DataGridRow">
                          <Style.Triggers>
                            <DataTrigger Binding="{Binding HasMembers}" Value="True">
                              <Setter Property="Background" Value="#FFF8E1"/>
                              <Setter Property="FontWeight" Value="SemiBold"/>
                            </DataTrigger>
                          </Style.Triggers>
                        </Style>
                      </DataGrid.RowStyle>
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Display Name" Binding="{Binding DisplayName}" Width="3*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group ID"     Binding="{Binding GroupID}"     Width="3*" MinWidth="130"/>
                        <DataGridTextColumn Header="Group Type"   Binding="{Binding GroupType}"   Width="*"  MinWidth="120"/>
                        <DataGridTextColumn Header="Members"      Binding="{Binding MemberCount}" Width="*"  MinWidth="80"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>

                <!-- Execution progress -->
                <Border x:Name="PnlRGExecProgress" Visibility="Collapsed"
                        Background="#2A1A1A" CornerRadius="6" Padding="16,12" Margin="0,12,0,12">
                  <StackPanel>
                    <TextBlock x:Name="TxtRGExecProgressMsg" Text="Removing groups..."
                               Foreground="#FCA5A5" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ProgressBar x:Name="PbRGExecProgress" Height="6" IsIndeterminate="True"
                                 Background="#4A2A2A" Foreground="#EF4444" BorderThickness="0"/>
                    <TextBlock x:Name="TxtRGExecProgressDetail" Text=""
                               Foreground="#F87171" FontSize="11" Margin="0,5,0,0"/>
                  </StackPanel>
                </Border>

                <!-- Results table -->
                <Border x:Name="PnlRGResults" Visibility="Collapsed">
                  <StackPanel>
                    <Grid Margin="0,0,0,8">
                      <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                      </Grid.ColumnDefinitions>
                      <TextBlock x:Name="TxtRGResultCount" Grid.Column="0"
                                 FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
                      <StackPanel Grid.Column="1" Orientation="Horizontal">
                        <Button x:Name="BtnRGCopyAll" Content="Copy All"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4" Margin="0,0,6,0"/>
                        <Button x:Name="BtnRGExportXlsx" Content="Export XLSX"
                                Style="{StaticResource PrimaryBtn}"
                                IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
                                Padding="12,4"/>
                      </StackPanel>
                    </Grid>
                    <DataGrid x:Name="DgRGResults"
                              AutoGenerateColumns="False" IsReadOnly="True"
                              SelectionMode="Single" SelectionUnit="FullRow"
                              HeadersVisibility="Column"
                              GridLinesVisibility="All"
                              VerticalGridLinesBrush="#555555"
                              HorizontalGridLinesBrush="#D0D0D0"
                              RowBackground="White" AlternatingRowBackground="#F5F5F5"
                              BorderBrush="#B0D0B0" BorderThickness="1"
                              CanUserReorderColumns="False" CanUserResizeRows="False"
                              CanUserSortColumns="True" MaxHeight="280" FontSize="12">
                      <DataGrid.RowStyle>
                        <Style TargetType="DataGridRow">
                          <Style.Triggers>
                            <DataTrigger Binding="{Binding IsSuccess}" Value="True">
                              <Setter Property="Background" Value="#F0FDF4"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding IsSuccess}" Value="False">
                              <Setter Property="Background" Value="#FEF2F2"/>
                            </DataTrigger>
                          </Style.Triggers>
                        </Style>
                      </DataGrid.RowStyle>
                      <DataGrid.ColumnHeaderStyle>
                        <Style TargetType="DataGridColumnHeader">
                          <Setter Property="FontWeight" Value="SemiBold"/>
                          <Setter Property="Background" Value="#E8F5E9"/>
                          <Setter Property="Foreground" Value="#14532D"/>
                          <Setter Property="Padding"    Value="8,5"/>
                          <Setter Property="BorderBrush" Value="#555555"/>
                          <Setter Property="BorderThickness" Value="0,0,1,1"/>
                        </Style>
                      </DataGrid.ColumnHeaderStyle>
                      <DataGrid.Columns>
                        <DataGridTextColumn Header="Display Name" Binding="{Binding DisplayName}" Width="3*" MinWidth="160"/>
                        <DataGridTextColumn Header="Group ID"     Binding="{Binding GroupID}"     Width="3*" MinWidth="130"/>
                        <DataGridTextColumn Header="Status"       Binding="{Binding Status}"      Width="*"  MinWidth="80"/>
                        <DataGridTextColumn Header="Message"      Binding="{Binding Message}"     Width="4*" MinWidth="200"/>
                      </DataGrid.Columns>
                    </DataGrid>
                  </StackPanel>
                </Border>

                <TextBlock x:Name="TxtRGNoResults" Visibility="Collapsed"
                           Text="No groups could be resolved from the provided input. Check the names / IDs and try again."
                           FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
              </StackPanel>
            </Border>
          </Grid>
        </ScrollViewer>

      </Grid>
    </DockPanel>

    <!-- ================================================================== -->
    <!-- Session Notes floating overlay  (in-memory only, not persisted)    -->
    <!-- ================================================================== -->
    <Grid x:Name="OverlaySessionNotes" Visibility="Collapsed">
      <Rectangle x:Name="SnDimmer" Fill="#80000000" IsHitTestVisible="True"/>
      <Border Background="White" CornerRadius="12"
              BorderBrush="#E5E7EB" BorderThickness="1"
              Width="520" MaxHeight="480"
              HorizontalAlignment="Center" VerticalAlignment="Center">
        <Border.Effect>
          <DropShadowEffect BlurRadius="24" ShadowDepth="6" Direction="270"
                            Color="#000000" Opacity="0.28"/>
        </Border.Effect>
        <Grid>
          <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
          </Grid.RowDefinitions>
          <!-- Card header -->
          <Border Grid.Row="0" Background="#F9FAFB" CornerRadius="12,12,0,0" Padding="18,12">
            <Grid>
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
              </Grid.ColumnDefinitions>
              <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
                <TextBlock Text="&#x1F5D2;" FontSize="17" Margin="0,0,8,0" VerticalAlignment="Center"/>
                <TextBlock Text="Session Notes" FontSize="14" FontWeight="SemiBold"
                           Foreground="#111827" VerticalAlignment="Center"/>
                <TextBlock Text="  not saved to disk" FontSize="11"
                           Foreground="#9CA3AF" VerticalAlignment="Center" Margin="4,0,0,0"/>
              </StackPanel>
              <Button x:Name="BtnSessionNotesClose" Grid.Column="1"
                      Content="&#x2715;" FontSize="13" FontWeight="Bold"
                      Background="Transparent" BorderThickness="0"
                      Foreground="#6B7280" Cursor="Hand"
                      Width="30" Height="30" Padding="0"
                      VerticalContentAlignment="Center" HorizontalContentAlignment="Center"/>
            </Grid>
          </Border>
          <!-- Note text area -->
          <TextBox x:Name="TxtSessionNotes" Grid.Row="1"
                   TextWrapping="Wrap" AcceptsReturn="True" AcceptsTab="True"
                   VerticalScrollBarVisibility="Auto"
                   BorderThickness="0,1,0,1" BorderBrush="#E5E7EB"
                   Padding="18,12" FontSize="12" FontFamily="Consolas"
                   Foreground="#1F2937" Background="White"
                   MinHeight="260" MaxHeight="360"
                   Tag="Type your session notes here&#x2026;"/>
          <!-- Card footer -->
          <Border Grid.Row="2" Background="#F9FAFB" CornerRadius="0,0,12,12" Padding="18,8">
            <TextBlock Text="Notes are discarded when the tool is closed."
                       FontSize="11" Foreground="#9CA3AF" HorizontalAlignment="Center"/>
          </Border>
        </Grid>
      </Border>
    </Grid>
  </Grid>
</Window>
"@

#endregion XAML
Write-ErrorLog "Checkpoint 2: XAML defined OK."
Write-EarlyLog "Checkpoint 2: XAML defined OK."


# ============================================================
#region UI HELPER FUNCTIONS
# ============================================================

function Get-Timestamp { (Get-Date).ToString("HH:mm:ss") }

function Write-VerboseLog {
    param(
        [string]$Message,
        [ValidateSet('Info','Success','Warning','Error','Action')]
        [string]$Level = 'Info'
    )

    $color = switch ($Level) {
        'Info'    { $DevConfig.LogColorInfo }
        'Success' { $DevConfig.LogColorSuccess }
        'Warning' { $DevConfig.LogColorWarning }
        'Error'   { $DevConfig.LogColorError }
        'Action'  { $DevConfig.LogColorAction }
    }

    $prefix = switch ($Level) {
        'Info'    { '  ' }
        'Success' { "$([char]0x2713) " }
        'Warning' { '[!] ' }
        'Error'   { "$([char]0x2717) " }
        'Action'  { '> ' }
    }

    $ts   = Get-Timestamp
    $line = "[$ts] $prefix$Message"

    # Write to file
    if ($script:LogFile) {
        try { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue }
        catch {}
    }

    # Write to RichTextBox on UI thread
    $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
        try {
            $para = [System.Windows.Documents.Paragraph]::new()
            $run  = [System.Windows.Documents.Run]::new($line)
            $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                [System.Windows.Media.ColorConverter]::ConvertFromString($color)
            )
            $para.Inlines.Add($run)
            $para.Margin = [System.Windows.Thickness]::new(0,0,0,0)
            $script:RtbLog.Document.Blocks.Add($para)
            $script:RtbLog.ScrollToEnd()
        } catch {}
    })
}

function Show-Panel {
    param([string]$Name)
    $panels = @('PanelSearch','PanelListMembers','PanelObjectMembership','PanelFindGroupsByOwners','PanelWelcome','PanelCreate','PanelMembership','PanelExport',
                 'PanelDynamic','PanelRename','PanelOwner','PanelUserDevices','PanelFindCommon','PanelFindDistinct','PanelGetDeviceInfo','PanelGetDiscoveredApps','PanelGetPolicyAssignments','PanelCompareGroups','PanelRemoveGroups')
    foreach ($p in $panels) {
        $el = $script:Window.FindName($p)
        if ($el) { $el.Visibility = if ($p -eq $Name) { 'Visible' } else { 'Collapsed' } }
    }
    # Show Clear Inputs button on any panel except Welcome
    $clearBtn = $script:Window.FindName('BtnClearInputs')
    if ($clearBtn) {
        $clearBtn.Visibility = if ($Name -ne 'PanelWelcome') { 'Visible' } else { 'Collapsed' }
    }
}

function Show-Notification {
    param([string]$Message, [string]$BgColor = '#FFF3CD', [string]$FgColor = '#7A4800')
    $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
        $script:NotifStrip.Background = [System.Windows.Media.SolidColorBrush]::new(
            [System.Windows.Media.ColorConverter]::ConvertFromString($BgColor))
        $script:TxtNotif.Foreground = [System.Windows.Media.SolidColorBrush]::new(
            [System.Windows.Media.ColorConverter]::ConvertFromString($FgColor))
        $script:TxtNotif.Text    = $Message
        $script:NotifStrip.Visibility = 'Visible'
    })
}

function Hide-Notification {
    $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
        $script:NotifStrip.Visibility = 'Collapsed'
    })
}

function Update-StatusBar {
    param([string]$Text)
    $script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
        $script:TxtStatusBar.Text = $Text
    })
}

function Show-ConfirmationDialog {
    param(
        [string]$Title,
        [string]$OperationLabel,
        [string]$TargetGroup,
        [string]$TargetGroupId,
        [object[]]$ValidObjects,
        [object[]]$InvalidEntries,
        [string]$ExtraInfo = ""
    )

    $validCount   = $ValidObjects.Count
    $invalidCount = $InvalidEntries.Count

    # Build summary text
    $sb = [System.Text.StringBuilder]::new()
    $null = $sb.AppendLine("OPERATION : $OperationLabel")
    $null = $sb.AppendLine("GROUP     : $TargetGroup")
    $null = $sb.AppendLine("GROUP ID  : $TargetGroupId")
    if ($ExtraInfo) { $null = $sb.AppendLine($ExtraInfo) }
    $null = $sb.AppendLine("")
    $null = $sb.AppendLine("Valid objects  : $validCount")
    if ($invalidCount -gt 0) {
        $null = $sb.AppendLine("Invalid entries: $invalidCount  (will be SKIPPED)")
        $null = $sb.AppendLine("")
        $null = $sb.AppendLine("-- INVALID (not found in Entra) --")
        foreach ($inv in $InvalidEntries) { $null = $sb.AppendLine("  $([char]0x2717) $inv") }
    }
    $null = $sb.AppendLine("")
    $null = $sb.AppendLine("-- VALID (will be processed) --")
    foreach ($obj in $ValidObjects) {
        $dispLabel = if ($obj.Type -eq 'User') { $obj.Original } else { $obj.DisplayName }
        $null = $sb.AppendLine("  $([char]0x2713) $dispLabel  [$($obj.Type)]  ($($obj.Id))")
    }

    [xml]$dlgXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Confirm Action" Width="620" Height="480"
        WindowStartupLocation="CenterOwner"
        Background="$($DevConfig.ColorBackground)"
        FontFamily="$($DevConfig.FontFamily)" FontSize="13"
        ResizeMode="CanResize">
  <Grid Margin="16">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="*"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>

    <StackPanel Grid.Row="0" Margin="0,0,0,10">
      <TextBlock Text="$Title" FontSize="15" FontWeight="SemiBold"
                 Foreground="$($DevConfig.ColorPrimary)"/>
      <TextBlock Text="Review the summary below before proceeding."
                 FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,4,0,0"/>
    </StackPanel>

    <Border Grid.Row="1" BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"
            CornerRadius="6" Background="$($DevConfig.ColorSurface)">
      <TextBox x:Name="SummaryBox" IsReadOnly="True" TextWrapping="Wrap"
               Background="Transparent" BorderThickness="0"
               FontFamily="Consolas" FontSize="11"
               Foreground="$($DevConfig.ColorText)"
               VerticalScrollBarVisibility="Auto"
               HorizontalScrollBarVisibility="Disabled"
               Padding="10,8"/>
    </Border>

    <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right"
                Margin="0,12,0,0">
      <Button x:Name="BtnDlgCancel" Content="Cancel" Width="90" Margin="0,0,10,0"
              Background="Transparent"
              Foreground="$($DevConfig.ColorText)"
              BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"
              Padding="8,6" Cursor="Hand"/>
      <Button x:Name="BtnDlgConfirm" Content="&#x2713; Confirm &amp; Execute" Width="180"
              Background="$($DevConfig.ColorAccent)" Foreground="White"
              BorderThickness="0" Padding="10,7" FontWeight="SemiBold" Cursor="Hand"/>
    </StackPanel>
  </Grid>
</Window>
"@

    $dlgReader = [System.Xml.XmlNodeReader]::new($dlgXaml)
    $dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
    $dlg.Owner = $script:Window

    $dlg.FindName('SummaryBox').Text = $sb.ToString()

    $script:DlgResult = $false
    $dlg.FindName('BtnDlgCancel').Add_Click({ $script:DlgResult = $false; $dlg.Close() })
    $dlg.FindName('BtnDlgConfirm').Add_Click({ $script:DlgResult = $true; $dlg.Close() })

    $dlg.ShowDialog() | Out-Null
    return $script:DlgResult
}

#endregion
Write-ErrorLog "Checkpoint 3: UI helpers defined OK."


# ============================================================
#region GRAPH HELPER FUNCTIONS
# ============================================================

function Invoke-GraphGet {
    param([string]$Uri)
    $results = [System.Collections.Generic.List[object]]::new()
    $next = $Uri
    do {
        $resp = Invoke-MgGraphRequest -Method GET -Uri $next
        if ($resp['value']) { $results.AddRange([object[]]$resp['value']) }
        $next = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
    } while ($next)
    return ,$results
}

function Resolve-GroupByNameOrId {
    param([string]$GroupEntry)
    $trimmed = $GroupEntry.Trim()
    if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }

    # GUID pattern → look up by ID
    if ($trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
        try {
            $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled"
            return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
        } catch { return $null }
    }

    # Name search
    $enc = [Uri]::EscapeDataString($trimmed)
    try {
        $safe_trimmed = $trimmed -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
        $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_trimmed'`&`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled`&`$top=10"
        if ($resp['value'] -and $resp['value'].Count -gt 0) {
            $g = $resp['value'][0]
            return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
        }
    } catch {}
    return $null
}

function Search-Groups {
    param([string]$Query)
    if ([string]::IsNullOrWhiteSpace($Query)) { return @() }
    $enc = $Query.Trim() -replace "'","''"
    try {
        $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=startswith(displayName,'$enc')`&`$select=id,displayName,groupTypes`&`$top=15"
        return $resp['value']
    } catch { return @() }
}

function Resolve-MemberEntry {
    param([string]$Entry)

    # Returns [PSCustomObject]@{ Id; DisplayName; Type; Found }
    # Type = 'User' | 'Group' | 'Device' | 'Unknown'

    $trimmed = $Entry.Trim()
    if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }

    $isGuid = $trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
    $isUpn  = $trimmed -match '^[^@\s]+@[^@\s]+\.[^@\s]+$'

    # ---- User (UPN only) ----
    if ($isUpn) {
        try {
            $u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($trimmed))?`$select=id,displayName,userPrincipalName"
            return [PSCustomObject]@{ Id = $u['id']; DisplayName = $u['displayName']; Type = 'User'; Found = $true; Original = $trimmed }
        } catch {
            return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'User'; Found = $false; Original = $trimmed }
        }
    }

    # ---- GUID  -  try group, then device ----
    if ($isGuid) {
        try {
            $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName"
            return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
        } catch {}
        try {
            $d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$trimmed`?`$select=id,displayName"
            return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
        } catch {}
        return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
    }

    # ---- Display name  -  try group, then device ----
    $safeEntry = $trimmed -replace "'","''"
    try {
        $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
        if ($resp['value'] -and $resp['value'].Count -gt 0) {
            $g = $resp['value'][0]
            return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
        }
    } catch {}
    try {
        $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
        if ($resp['value'] -and $resp['value'].Count -gt 0) {
            $d = $resp['value'][0]
            return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
        }
    } catch {}

    return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
}

function Validate-InputList {
    param([string[]]$Entries)
    # Use plain arrays  -  Generic.List serialises to Object[] across runspace
    # boundaries which causes AddRange type errors.
    $valid   = @()
    $invalid = @()

    foreach ($entry in $Entries) {
        $e = $entry.Trim()
        if ([string]::IsNullOrWhiteSpace($e)) { continue }
        Write-VerboseLog "Resolving: $e" -Level Info
        $result = Resolve-MemberEntry -Entry $e
        if ($null -eq $result) { continue }
        if ($result.Found) {
            Write-VerboseLog "  $([char]0x2713) $($result.DisplayName) [$($result.Type)] ($($result.Id))" -Level Success
            $valid += $result
        } else {
            Write-VerboseLog "  $([char]0x2717) Not found: $e" -Level Warning
            $invalid += $e
        }
    }
    return @{ Valid = $valid; Invalid = $invalid }
}

function Get-GroupMemberCount {
    param([string]$GroupId)
    # Requires ConsistencyLevel:eventual AND $count=true in URL
    # Silently returns $null on any error (e.g. missing permission)  -  never blocks UI
    try {
        $resp = Invoke-MgGraphRequest -Method GET `
            -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$count?`$count=true" `
            -Headers @{ ConsistencyLevel = 'eventual' }
        return [int]$resp
    } catch { return $null }
}

function Get-GroupMembers {
    param([string]$GroupId)
    # Note: @odata.type must NOT be in $select  -  Graph returns it automatically.
    # Including it causes a BadRequest error.
    return Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName`&`$top=999"
}

function Add-MembersToGroup {
    param(
        [string]$GroupId,
        [object[]]$Members
    )
    $ok = 0; $fail = 0; $skipped = 0
    foreach ($m in $Members) {
        if ($null -ne $Shared -and $Shared['StopRequested']) {
            Write-VerboseLog "Stop requested  -  halting after $ok added." -Level Warning
            break
        }
        $body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($m.Id)" }
        Write-VerboseLog "Adding $($m.DisplayName) [$($m.Type)]..." -Level Action
        try {
            $null = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" -Body ($body | ConvertTo-Json) -ContentType "application/json"
            Write-VerboseLog "Added: $($m.DisplayName)" -Level Success
            $ok++
        } catch {
            $errMsg    = $_.Exception.Message
            $errDetail = $_.ErrorDetails.Message
            if ($errMsg -match 'already exist' -or $errDetail -match 'already exist') {
                Write-VerboseLog "Skipping $($m.DisplayName) - already a member of this group" -Level Warning
                $skipped++
            } else {
                Write-VerboseLog "Failed to add $($m.DisplayName): $errMsg" -Level Error
                $fail++
            }
        }
    }
    return @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
}

function Remove-MembersFromGroup {
    param(
        [string]$GroupId,
        [object[]]$Members
    )
    $ok = 0; $fail = 0
    foreach ($m in $Members) {
        if ($null -ne $Shared -and $Shared['StopRequested']) {
            Write-VerboseLog "Stop requested  -  halting after $ok removed." -Level Warning
            break
        }
        Write-VerboseLog "Removing $($m.DisplayName) [$($m.Type)]..." -Level Action
        try {
            $null = Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/$($m.Id)/`$ref"
            Write-VerboseLog "Removed: $($m.DisplayName)" -Level Success
            $ok++
        } catch {
            Write-VerboseLog "Failed to remove $($m.DisplayName): $($_.Exception.Message)" -Level Error
            $fail++
        }
    }
    return @{ Ok = $ok; Fail = $fail }
}

function Export-GroupMembersToXlsx {
    param([string]$GroupId, [string]$GroupName, [string]$OutputPath)

    Write-VerboseLog "Fetching members for group: $GroupName" -Level Action
    $members = Get-GroupMembers -GroupId $GroupId
    Write-VerboseLog "Retrieved $($members.Count) members" -Level Info

    # Build rows
    $rows = foreach ($m in $members) {
        $odataType = if ($m.ContainsKey('@odata.type')) { $m['@odata.type'] } else { '' }
        $type = switch ($odataType) {
            '#microsoft.graph.user'   { 'User' }
            '#microsoft.graph.group'  { 'Group' }
            '#microsoft.graph.device' { 'Device' }
            default                   { if ($odataType) { $odataType } else { 'Unknown' } }
        }
        [PSCustomObject]@{
            DisplayName       = $m['displayName']
            UserPrincipalName = if ($m['userPrincipalName']) { $m['userPrincipalName'] } else { '-' }
            ObjectId          = $m['id']
            ObjectType        = $type
            GroupName         = $GroupName
            GroupId           = $GroupId
            ExportedAt        = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
        }
    }

    # Ensure folder exists
    $folder = Split-Path $OutputPath -Parent
    if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }

    # Try ImportExcel if available, else CSV fallback
    if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
        Import-Module ImportExcel -ErrorAction SilentlyContinue
        $rows | Export-Excel -Path $OutputPath -WorksheetName "Members" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
        Write-VerboseLog "Exported $($rows.Count) members to XLSX: $OutputPath" -Level Success
    } else {
        # Fallback: save as CSV with .xlsx extension (user informed)
        $rows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
        Write-VerboseLog "ImportExcel module not found  -  exported as CSV to: $OutputPath" -Level Warning
        Write-VerboseLog "Install ImportExcel: Install-Module ImportExcel -Scope CurrentUser" -Level Info
    }
    return $rows.Count
}

#endregion
Write-ErrorLog "Checkpoint 4: Graph helpers defined OK."


# ============================================================
#region BUILD & WIRE UI
# ============================================================

Write-EarlyLog "Loading XAML..."
Write-ErrorLog "Loading XAML..."
try {
    $reader = [System.Xml.XmlNodeReader]::new($Xaml)
    $script:Window = [Windows.Markup.XamlReader]::Load($reader)
    Write-ErrorLog "XAML loaded OK."
    Write-EarlyLog "XAML loaded OK."
} catch {
    Write-ErrorLog "XAML load failed: $($_.Exception.Message)"
    Write-EarlyLog "XAML load FAILED: $($_.Exception.Message)"
    try {
        Add-Type -AssemblyName System.Windows.Forms
        [System.Windows.Forms.MessageBox]::Show("XAML load failed:`n`n$($_.Exception.Message)","Error") | Out-Null
    } catch {}
    exit 1
}

# Pre-initialise all script-scope state variables (required by Set-StrictMode -Version Latest)
$script:SelectedGroup       = $null
$script:ExportSelectedGroup = $null
$script:DynSelectedGroup    = $null
$script:RenSelectedGroup     = $null
$script:OwnerGroupsTxt       = ''
$script:OwnerGroupsValidated = $null
$script:CurrentMemberOp      = 'Add'
$script:DlgResult            = $false
$script:SensitivityLabels    = @()   # sensitivity label fetch removed (API permission not available)
$script:DynGroupState        = $null   # populated by BtnDynValidate Work block; checked in Done block
$script:SearchKeyword        = ''     # keyword for BtnEntraSearch
$script:SearchTypes          = @{}    # type filter hashtable
$script:SearchResults        = $null  # List[PSCustomObject] from BtnEntraSearch
$script:SearchExactMatch      = $false  # exact-match-only filter for BtnEntraSearch
$script:LMAllResults         = $null  # List[PSCustomObject] from BtnLMQuery (all, pre-filter)
$script:LMGroupInput         = ''     # raw text from TxtLMGroupInput
$script:LMProgressTimer      = $null  # DispatcherTimer for progress bar updates
$script:CGAllResults         = $null  # List[PSCustomObject] from BtnCGCommon / BtnCGDistinct
$script:CGGroupInput         = ''     # raw text from TxtCGGroupInput
$script:CGProgressTimer      = $null  # DispatcherTimer for CG progress bar
$script:CreateSensLabelId    = ''
$script:CreateSensLabelName  = ''
$script:PickerCallbacks     = @{}
$script:LogPaneVisible      = $true
$script:AuthTimer           = $null
$script:AuthPs              = $null
$script:AuthRs              = $null
$script:AuthHandle          = $null
$script:AuthDlgResult       = $false
$script:AuthDlgTenant       = ''
$script:AuthDlgClient       = ''
$script:BgPs                = $null
$script:BgRs                = $null
$script:BgHandle            = $null
$script:BgTimer             = $null
$script:BgDone              = $null
$script:BgBtn               = $null
$script:SharedBg            = $null
$script:BgStopped           = $false
$script:PimRoles            = @()
$script:PimQueue            = $null
$script:PimRs               = $null
$script:PimPs               = $null
$script:PimHandle           = $null
$script:PimTimer            = $null
$script:PimAutoTimer        = $null
$script:BladeExpanded       = $false
$script:CreateParams        = $null
$script:CreateValidated     = $null
$script:CreateNewGroupId    = $null
$script:MemberParams        = $null
$script:MemberValidated     = $null
$script:MemberExecResult    = $null
$script:ExportOutPath       = ''
$script:ExportCount         = 0
$script:DynRule             = ''
$script:RenNewName          = ''
$script:RenExistingId       = $null
$script:OwnerUpnsTxt        = ''
$script:OwnerValidated      = $null
$script:OwnerExecResult     = $null
$script:UDSelectedGroup     = $null
$script:UDParams            = $null
$script:UDValidated         = $null
$script:UDExecResult        = $null
$script:FCParams            = $null
$script:FCResult            = $null
$script:FDParams            = $null
$script:FDResult            = $null
$script:GPAParams         = $null  # GPA: query params passed to background work block
$script:GPAResult         = $null  # GPA: result rows from background query
$script:RGGroupList          = $null  # RG: raw group input text
$script:RGValidated          = $null  # RG: validated group objects for preview
$script:RGExecResult         = $null  # RG: per-group deletion results
$script:AuthCancelled       = $false
$script:AuthStartTime       = [datetime]::UtcNow
$script:AuthTimeoutSec      = 120
$script:BladeDevExpanded    = $false
$script:GDIParams           = $null
$script:GdiQueue           = $null
$script:GdiStop            = $null
$script:OmStop             = $null
$script:DaStop             = $null
$script:GdiRs              = $null
$script:OmQueue            = $null
$script:OmRs               = $null
$script:OmPs               = $null
$script:OmHandle           = $null
$script:OmTimer            = $null
$script:OMParams           = $null
$script:DaQueue            = $null
$script:DaRs               = $null
$script:DaPs               = $null
$script:DaHandle           = $null
$script:DaTimer            = $null
$script:SearchAllData      = $null
$script:LMAllData          = $null
$script:OMAllData          = $null
$script:FCAllData          = $null
$script:FDAllData          = $null
$script:GDIAllData         = $null
$script:DAAllData          = $null
$script:GPAAllData         = $null
$script:CGAllData          = $null
$script:DAParams           = $null
$script:GdiPs              = $null
$script:GdiHandle          = $null
$script:GdiTimer           = $null

# Cache named elements
$script:TxtStatusBar       = $script:Window.FindName('TxtStatusBar')
$script:NotifStrip         = $script:Window.FindName('NotifStrip')
$script:TxtNotif           = $script:Window.FindName('TxtNotif')
$script:PimStrip           = $script:Window.FindName('PimStrip')
$script:SpPimRoles         = $script:Window.FindName('SpPimRoles')
$script:BgImage            = $script:Window.FindName('BgImage')
$script:LogoImg            = $script:Window.FindName('LogoImg')
$script:RtbLog             = $script:Window.FindName('RtbLog')
$script:LogPane            = $script:Window.FindName('LogPane')


# ── Settings panel toggle ─────────────────────────────────────────────────────


# ── Log pane min/max height enforcement via GridSplitter drag ─────────────────
$script:LogPaneMinH = 44
$script:LogPaneMaxH = 260
$script:LogPane.Add_SizeChanged({
    $h = $script:LogPane.ActualHeight
    if ($h -lt $script:LogPaneMinH -and $script:LogPaneVisible) {
        $script:LogPane.Height = $script:LogPaneMinH
    } elseif ($h -gt $script:LogPaneMaxH) {
        $script:LogPane.Height = $script:LogPaneMaxH
    }
})


# ── Helper: load a bitmap from a local path or web URL ──
# BitmapFrame.Create(Uri) handles both file:// and http(s):// without extra modules.
# Local path takes priority over URL.
# ÄÄ SVG -> RasterBitmap  (no external libraries required) ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ
# Renders SVG via a hidden WPF WebBrowser (IE/Trident engine), captures with
# RenderTargetBitmap, then closes the window.  Handles simple/flat logo SVGs.
function Convert-SvgToBitmap {
    param([string]$SvgContent, [int]$Width = 120, [int]$Height = 40)
    try {
        # Add Win32 interop helpers (once per session)
        if (-not ([System.Management.Automation.PSTypeName]'SvgCapture').Type) {
            Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class SvgCapture {
    [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, int flags);
    [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd);
    [DllImport("user32.dll")] public static extern int  ReleaseDC(IntPtr hwnd, IntPtr hdc);
    [DllImport("gdi32.dll")]  public static extern IntPtr CreateCompatibleDC(IntPtr hdc);
    [DllImport("gdi32.dll")]  public static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h);
    [DllImport("gdi32.dll")]  public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);
    [DllImport("gdi32.dll")]  public static extern bool DeleteDC(IntPtr hdc);
    [DllImport("gdi32.dll")]  public static extern bool DeleteObject(IntPtr obj);
}
"@
        }

        $svgB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($SvgContent))
        $html   = "<!DOCTYPE html><html><body style='margin:0;padding:0;overflow:hidden;" +
                  "background:transparent;'><img src='data:image/svg+xml;base64,$svgB64' " +
                  "width='$Width' height='$Height'/></body></html>"

        $wb = [System.Windows.Controls.WebBrowser]::new()
        $wb.Width  = $Width
        $wb.Height = $Height
        $null = $wb.Add_LoadCompleted({ $script:_SvgLoadDone = $true })

        $hostWin = [System.Windows.Window]::new()
        $hostWin.WindowStyle   = 'None'
        $hostWin.ShowInTaskbar = $false
        $hostWin.AllowsTransparency = $false
        $hostWin.Left = -32000; $hostWin.Top = -32000
        $hostWin.Width = $Width; $hostWin.Height = $Height
        $hostWin.Content = $wb
        $script:_SvgLoadDone = $false
        $null = $hostWin.Show()
        $null = $wb.NavigateToString($html)

        # Wait up to 4 s for LoadCompleted, pumping dispatcher each cycle
        $deadline = (Get-Date).AddSeconds(4)
        while (-not $script:_SvgLoadDone -and (Get-Date) -lt $deadline) {
            $null = [System.Windows.Application]::Current.Dispatcher.Invoke(
                [Action]{}, [System.Windows.Threading.DispatcherPriority]::ApplicationIdle)
            Start-Sleep -Milliseconds 80
        }
        # Extra render pump
        $null = [System.Windows.Application]::Current.Dispatcher.Invoke(
            [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render)
        Start-Sleep -Milliseconds 100

        # Get the HWND of the host window (not the WebBrowser — window HWND captures its children too)
        $helper = [System.Windows.Interop.WindowInteropHelper]::new($hostWin)
        $hwnd   = $helper.Handle

        # Capture via PrintWindow (works on HWND-hosted controls; RenderTargetBitmap does NOT)
        $screenDC = [SvgCapture]::GetDC([IntPtr]::Zero)
        $memDC    = [SvgCapture]::CreateCompatibleDC($screenDC)
        $hBmp     = [SvgCapture]::CreateCompatibleBitmap($screenDC, $Width, $Height)
        $oldObj   = [SvgCapture]::SelectObject($memDC, $hBmp)
        # PW_RENDERFULLCONTENT = 2 (forces re-render, needed for HwndHost controls)
        $null = [SvgCapture]::PrintWindow($hwnd, $memDC, 2)

        # Convert GDI HBITMAP -> WPF BitmapSource
        $bmpSrc = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap(
                      $hBmp, [IntPtr]::Zero,
                      [System.Windows.Int32Rect]::Empty,
                      [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions())
        $null = $bmpSrc.Freeze()

        # GDI cleanup
        $null = [SvgCapture]::SelectObject($memDC, $oldObj)
        $null = [SvgCapture]::DeleteDC($memDC)
        $null = [SvgCapture]::DeleteObject($hBmp)
        $null = [SvgCapture]::ReleaseDC([IntPtr]::Zero, $screenDC)
        $null = $hostWin.Close()

        $null = Write-VerboseLog "SVG logo captured at ${Width}x${Height}." -Level Success
        return $bmpSrc
    } catch {
        $null = Write-VerboseLog "SVG render failed: $($_.Exception.Message)" -Level Warning
        return $null
    }
}

function Load-BitmapSource {
    param([string]$LocalPath, [string]$Url, [string]$Base64,
          [int]$SvgW = 120, [int]$SvgH = 40)

    # Robust SVG detector: handles BOM, <?xml?> preamble, leading whitespace
    function Test-IsSvg { param([string]$s)
        if (-not $s) { return $false }
        $sample = $s.Substring(0, [Math]::Min(600, $s.Length))
        return $sample -match '(?is)<svg[\s>]'
    }

    $result = $null

    # Priority 1: Base64-encoded image embedded in DevConfig
    if (-not [string]::IsNullOrWhiteSpace($Base64)) {
        try {
            $bytes   = [Convert]::FromBase64String($Base64.Trim())
            $decoded = [System.Text.Encoding]::UTF8.GetString($bytes, 0, [Math]::Min(600, $bytes.Length))
            if (Test-IsSvg $decoded) {
                $svgText = [System.Text.Encoding]::UTF8.GetString($bytes)
                $result  = Convert-SvgToBitmap -SvgContent $svgText -Width $SvgW -Height $SvgH
            } else {
                # Raster bytes (PNG / JPG / ICO / BMP)
                $stream = [System.IO.MemoryStream]::new($bytes)
                $bmp    = [System.Windows.Media.Imaging.BitmapImage]::new()
                $bmp.BeginInit()
                $bmp.StreamSource = $stream
                $bmp.CacheOption  = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
                $bmp.EndInit()
                $null = $bmp.Freeze()
                $result = $bmp
            }
        } catch { Write-VerboseLog "Base64 logo decode failed: $($_.Exception.Message)" -Level Warning }
        # Ensure we return only a BitmapSource (discard any leaked pipeline objects)
        if ($result -isnot [System.Windows.Media.Imaging.BitmapSource]) { $result = $null }
        return $result
    }

    # Priority 2: Local file path
    if (-not [string]::IsNullOrWhiteSpace($LocalPath) -and (Test-Path $LocalPath)) {
        if ($LocalPath -match '\.svg$') {
            $result = Convert-SvgToBitmap -SvgContent ([IO.File]::ReadAllText($LocalPath)) -Width $SvgW -Height $SvgH
        } else {
            $result = [System.Windows.Media.Imaging.BitmapFrame]::Create([Uri]::new((Resolve-Path $LocalPath).Path))
        }
        if ($result -isnot [System.Windows.Media.Imaging.BitmapSource]) { $result = $null }
        return $result
    }

    # Priority 3: Web URL
    if (-not [string]::IsNullOrWhiteSpace($Url)) {
        if ($Url -match '\.svg(\?|$)') {
            try {
                $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop
                $body = if ($resp.Content -is [byte[]]) {
                            [System.Text.Encoding]::UTF8.GetString($resp.Content)
                        } else { [string]$resp.Content }
                $result = Convert-SvgToBitmap -SvgContent $body -Width $SvgW -Height $SvgH
            } catch { Write-VerboseLog "SVG URL download failed: $($_.Exception.Message)" -Level Warning }
            if ($result -isnot [System.Windows.Media.Imaging.BitmapSource]) { $result = $null }
            return $result
        }
        try {
            $bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
            $bmp.BeginInit()
            $bmp.UriSource     = [Uri]::new($Url)
            $bmp.CacheOption   = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
            $bmp.CreateOptions = [System.Windows.Media.Imaging.BitmapCreateOptions]::None
            $bmp.EndInit()
            $result = $bmp
        } catch { Write-VerboseLog "URL logo load failed: $($_.Exception.Message)" -Level Warning }
    }
    return $result
}

# Apply background image (local path or URL)
try {
    $bgSrc = Load-BitmapSource -LocalPath $DevConfig.BackgroundImagePath -Url $DevConfig.BackgroundImageUrl
    if ($bgSrc) { $script:BgImage.Source = $bgSrc }
} catch { Write-VerboseLog "Could not load background image: $($_.Exception.Message)" -Level Warning }

# Apply logo (local path or URL)
try {
    $logoSrc = Load-BitmapSource -LocalPath $DevConfig.LogoImagePath -Url $DevConfig.LogoImageUrl `
                                 -Base64 $DevConfig.LogoBase64 -SvgW $DevConfig.LogoWidth -SvgH $DevConfig.LogoHeight
    if ($logoSrc) {
        $script:LogoImg.Source     = $logoSrc
        $script:LogoImg.Visibility = 'Visible'
    }
} catch { Write-VerboseLog "Could not load logo: $($_.Exception.Message)" -Level Warning }

# Log pane toggle
$script:Window.FindName('BtnLogToggle').Add_Click({
    if ($script:LogPaneVisible) {
        $script:LogPane.Height    = 0
        $script:LogPane.Visibility = 'Collapsed'
        $script:Window.FindName('BtnLogToggle').Content = '^ Show'
        $script:LogPaneVisible = $false
    } else {
        $script:LogPane.Visibility = 'Visible'
        $script:LogPane.Height     = $DevConfig.VerbosePaneDefaultHeight
        $script:Window.FindName('BtnLogToggle').Content = 'v Hide'
        $script:LogPaneVisible = $true
    }
})

$script:Window.FindName('BtnLogClear').Add_Click({
    $script:RtbLog.Document.Blocks.Clear()
})

# ÄÄ Collapse Group Mgmt blade on startup ÄÄ
$script:Window.FindName('BladeGroupMgmtContent').Visibility = 'Collapsed'
$script:Window.FindName('BladeDivider').Visibility = 'Collapsed'
$script:Window.FindName('TxtBladeArrow').Text = '+'

# ── Group Management blade toggle ──
$script:BladeExpanded = $false
$script:Window.FindName('BtnBladeGroupMgmt').Add_Click({
    $script:BladeExpanded = -not $script:BladeExpanded
    $content = $script:Window.FindName('BladeGroupMgmtContent')
    $divider = $script:Window.FindName('BladeDivider')
    $arrow   = $script:Window.FindName('TxtBladeArrow')
    if ($script:BladeExpanded) {
        $content.Visibility = 'Visible'
        $divider.Visibility = 'Visible'
        $arrow.Text = [char]0x2212
    } else {
        $content.Visibility = 'Collapsed'
        $divider.Visibility = 'Collapsed'
        $arrow.Text = '+'
    }
})

# ── Clear Inputs button ──
# Show when any panel other than Welcome is active; clears all input fields in the current panel
$script:Window.FindName('BtnClearInputs').Add_Click({
    $panels = @{
        'PanelCreate'     = @('TxtCreateName','TxtCreateMailNick','TxtCreateDesc','TxtCreateOwner','TxtCreateMembers','TxtCreateDynamic')
        'PanelMembership' = @('TxtGroupSearch','TxtMemberList')
        'PanelExport'     = @('TxtExportGroupSearch','TxtExportPath')
        'PanelDynamic'    = @('TxtDynGroupSearch','TxtDynRule')
        'PanelRename'     = @('TxtRenGroupSearch','TxtNewGroupName')
        'PanelOwner'      = @('TxtOwnerGroupList','TxtOwnerList')
        'PanelUserDevices'= @('TxtUDGroupSearch','TxtUDUpnList')
        'PanelFindCommon' = @('TxtFCInputList')
        'PanelFindDistinct' = @('TxtFDInputList')
        'PanelGetDeviceInfo' = @('TxtGDIInputList')
        'PanelGetDiscoveredApps' = @('TxtDAInputList','TxtDAKeyword')
        'PanelSearch'        = @('TxtSearchKeyword')
        'PanelListMembers'   = @('TxtLMGroupInput')
        'PanelObjectMembership' = @('TxtOMInputList')
        'PanelFindGroupsByOwners' = @('TxtFGOInputList')
        'PanelCompareGroups' = @('TxtCGGroupInput')
        'PanelGetPolicyAssignments' = @('TxtGPANameFilter')
        'PanelRemoveGroups'          = @('TxtRGGroupList')
    }
    $badgesToHide = @(
        'SelectedGroupBadge','GroupSearchResults',
        'ExportSelectedGroupBadge','ExportGroupSearchResults',
        'DynSelectedGroupBadge','DynGroupSearchResults',
        'RenSelectedGroupBadge','RenGroupSearchResults',
        'UDSelectedGroupBadge','UDGroupSearchResults'
    )
    foreach ($panelName in $panels.Keys) {
        $panel = $script:Window.FindName($panelName)
        if ($panel -and $panel.Visibility -eq 'Visible') {
            foreach ($fieldName in $panels[$panelName]) {
                $field = $script:Window.FindName($fieldName)
                if ($field) { $field.Text = '' }
            }
            # Reset combos (Create panel)
            $cmb = $script:Window.FindName('CmbCreateType')
            if ($cmb) { $cmb.SelectedIndex = 0 }
            $cmbLbl = $script:Window.FindName('CmbSensitivityLabel')
            if ($cmbLbl) { $cmbLbl.SelectedIndex = 0 }
            # Hide group badges and result lists
            foreach ($b in $badgesToHide) {
                $el = $script:Window.FindName($b)
                if ($el) { $el.Visibility = 'Collapsed' }
            }
            # Reset selected group state for this panel
            $script:SelectedGroup       = $null
            $script:ExportSelectedGroup = $null
            $script:DynSelectedGroup     = $null
            $script:RenSelectedGroup     = $null
            $script:OwnerGroupsTxt       = ''
            $script:OwnerGroupsValidated = $null
            $script:UDSelectedGroup      = $null
            # Special reset for Search panel (clears results + buttons + checkboxes)
            if ($panelName -eq 'PanelSearch') {
                $dg = $script:Window.FindName('DgSearchResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlSearchResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
                $script:Window.FindName('BtnSearchCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnSearchCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnSearchCopyAll').IsEnabled    = $false
                $script:Window.FindName('TxtSearchFilter').IsEnabled  = $false
                $script:Window.FindName('BtnSearchFilter').IsEnabled  = $false
                $script:Window.FindName('BtnSearchFilterClear').IsEnabled = $false
                $script:Window.FindName('TxtSearchFilter').Text       = ''
                $script:SearchAllData = $null
                $script:Window.FindName('BtnSearchExportXlsx').IsEnabled = $false
                foreach ($cb in @('ChkSrchUsers','ChkSrchDevices','ChkSrchSG','ChkSrchM365')) {
                    $cbx = $script:Window.FindName($cb)
                    if ($cbx) { $cbx.IsChecked = $true }
                }
                $script:SearchResults = $null
            }
            # Special reset for List Group Members panel
            if ($panelName -eq 'PanelListMembers') {
                $dg = $script:Window.FindName('DgLMResults')
                if ($dg) { $dg.ItemsSource = $null; $dg.Columns.Clear() }
                $script:Window.FindName('PnlLMResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlLMProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnLMCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnLMCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnLMCopyAll').IsEnabled    = $false
                $script:Window.FindName('TxtLMFilter').IsEnabled  = $false
                $script:Window.FindName('BtnLMFilter').IsEnabled  = $false
                $script:Window.FindName('BtnLMFilterClear').IsEnabled = $false
                $script:Window.FindName('TxtLMFilter').Text       = ''
                $script:LMAllData = $null
                $script:Window.FindName('BtnLMExportXlsx').IsEnabled = $false
                foreach ($cb in @('ChkLMUsers','ChkLMDevices','ChkLMGroups')) {
                    $cbx = $script:Window.FindName($cb)
                    if ($cbx) { $cbx.IsChecked = $true }
                }
                $script:LMAllResults = $null
                if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop() }
            }
            # Special reset for Object Membership panel
            if ($panelName -eq 'PanelObjectMembership') {
                $dg = $script:Window.FindName('DgOMResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlOMResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtOMNoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlOMProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnOMCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnOMCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnOMCopyAll').IsEnabled    = $false
                $script:Window.FindName('BtnOMFilter').IsEnabled  = $false
                $script:Window.FindName('BtnOMFilterClear').IsEnabled = $false
                $script:OMAllData = $null
                $script:Window.FindName('TxtOMFilter').Text       = ''
                $script:Window.FindName('TxtOMFilter').IsEnabled  = $false
                $script:Window.FindName('BtnOMExportXlsx').IsEnabled = $false
                $script:OMAllResults = $null
                if ($script:OmTimer) { $script:OmTimer.Stop() }
            }
            # Special reset for Find Groups by Owners panel
            if ($panelName -eq 'PanelFindGroupsByOwners') {
                $dg = $script:Window.FindName('DgFGOResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlFGOResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtFGONoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlFGOProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnFGOCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnFGOCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnFGOCopyAll').IsEnabled    = $false
                $script:Window.FindName('BtnFGOFilter').IsEnabled  = $false
                $script:Window.FindName('BtnFGOFilterClear').IsEnabled = $false
                $script:FGOAllData = $null
                $script:Window.FindName('TxtFGOFilter').Text       = ''
                $script:Window.FindName('TxtFGOFilter').IsEnabled  = $false
                $script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $false
                if ($script:FGOTimer) { $script:FGOTimer.Stop() }
            }
            # Special reset for Get Discovered Apps panel
            if ($panelName -eq 'PanelGetDiscoveredApps') {
                $dg = $script:Window.FindName('DgDAResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlDAResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlDAProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnDACopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnDACopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnDACopyAll').IsEnabled    = $false
                $script:Window.FindName('TxtDAFilter').IsEnabled  = $false
                $script:Window.FindName('BtnDAFilter').IsEnabled  = $false
                $script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
                $script:Window.FindName('TxtDAFilter').Text       = ''
                $script:DAAllData = $null
                $script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
                if ($script:DaTimer) { $script:DaTimer.Stop() }
                foreach ($chk in @('ChkDAWindows','ChkDAAndroid','ChkDAiOS','ChkDAMacOS')) {
                    $cbx = $script:Window.FindName($chk)
                    if ($cbx) { $cbx.IsChecked = $false }
                }
            }
            # Special reset for Compare Groups panel
            if ($panelName -eq 'PanelCompareGroups') {
                $dg = $script:Window.FindName('DgCGResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlCGResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlCGProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnCGCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnCGCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnCGCopyAll').IsEnabled    = $false
                $script:Window.FindName('TxtCGFilter').IsEnabled  = $false
                $script:Window.FindName('BtnCGFilter').IsEnabled  = $false
                $script:Window.FindName('BtnCGFilterClear').IsEnabled = $false
                $script:Window.FindName('TxtCGFilter').Text       = ''
                $script:CGAllData = $null
                $script:Window.FindName('BtnCGExportXlsx').IsEnabled = $false
                $script:CGAllResults = $null
                if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
                $script:Window.FindName('BtnCGCommon').IsEnabled   = $true
                $script:Window.FindName('BtnCGDistinct').IsEnabled = $true
            }
            # Special reset for Find Common Groups panel
            if ($panelName -eq 'PanelFindCommon') {
                $dg = $script:Window.FindName('DgFCResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlFCResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtFCNoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlFCProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnFCCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnFCCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnFCCopyAll').IsEnabled    = $false
                $script:Window.FindName('TxtFCFilter').Text       = ''
                $script:FCAllData = $null
                $script:Window.FindName('BtnFCFilter').IsEnabled  = $false
                $script:Window.FindName('BtnFCFilterClear').IsEnabled = $false
                $script:Window.FindName('TxtFCFilter').IsEnabled  = $false
                $script:Window.FindName('BtnFCExportXlsx').IsEnabled = $false
                $script:FCResult = $null
            }
            # Special reset for Find Distinct Groups panel
            if ($panelName -eq 'PanelFindDistinct') {
                $dg = $script:Window.FindName('DgFDResults')
                if ($dg) { $dg.ItemsSource = $null }
                $script:Window.FindName('PnlFDResults').Visibility   = 'Collapsed'
                $script:Window.FindName('TxtFDNoResults').Visibility = 'Collapsed'
                $script:Window.FindName('PnlFDProgress').Visibility  = 'Collapsed'
                $script:Window.FindName('BtnFDCopyValue').IsEnabled  = $false
                $script:Window.FindName('BtnFDCopyRow').IsEnabled    = $false
                $script:Window.FindName('BtnFDCopyAll').IsEnabled    = $false
                $script:Window.FindName('TxtFDFilter').IsEnabled  = $false
                $script:Window.FindName('BtnFDFilter').IsEnabled  = $false
                $script:Window.FindName('BtnFDFilterClear').IsEnabled = $false
                $script:Window.FindName('TxtFDFilter').Text       = ''
                $script:FDAllData = $null
                $script:Window.FindName('BtnFDExportXlsx').IsEnabled = $false
                $script:FDResult = $null
            }
            # Special reset for Get Policy Assignments panel
            if ($panelName -eq 'PanelGetPolicyAssignments') {
                $script:Window.FindName('CmbGPAType').SelectedIndex     = 0
                $script:Window.FindName('CmbGPAType').IsEnabled         = $true
                $script:Window.FindName('CmbGPASubType').SelectedIndex  = 0
                $script:Window.FindName('CmbGPAPlatform').SelectedIndex = 0
                $script:Window.FindName('PnlGPAProgress').Visibility    = 'Collapsed'
                $script:Window.FindName('PnlGPAResults').Visibility     = 'Collapsed'
                $script:Window.FindName('TxtGPANoResults').Visibility   = 'Collapsed'
                $dg = $script:Window.FindName('DgGPAResults')
                if ($dg) { $dg.ItemsSource = $null }
                foreach ($b in @('BtnGPACopyValue','BtnGPACopyRow','BtnGPACopyAll','BtnGPAExportXlsx')) {
                    $script:Window.FindName($b).IsEnabled = $false
                }
                foreach ($b in @('BtnGPAGetSelected','BtnGPAGetAll')) {
                    $script:Window.FindName($b).IsEnabled = $true
                }
                $script:GPAResult = $null
            }
            # Special reset for Remove Groups panel
            if ($panelName -eq 'PanelRemoveGroups') {
                $dg = $script:Window.FindName('DgRGPreview')
                if ($dg) { $dg.ItemsSource = $null }
                $dg2 = $script:Window.FindName('DgRGResults')
                if ($dg2) { $dg2.ItemsSource = $null }
                $script:Window.FindName('PnlRGProgress').Visibility      = 'Collapsed'
                $script:Window.FindName('PnlRGWarning').Visibility        = 'Collapsed'
                $script:Window.FindName('PnlRGPreview').Visibility        = 'Collapsed'
                $script:Window.FindName('PnlRGExecProgress').Visibility   = 'Collapsed'
                $script:Window.FindName('PnlRGResults').Visibility        = 'Collapsed'
                $script:Window.FindName('TxtRGNoResults').Visibility      = 'Collapsed'
                $script:Window.FindName('BtnRGExecute').IsEnabled         = $false
                $script:Window.FindName('BtnRGCopyAll').IsEnabled         = $false
                $script:Window.FindName('BtnRGExportXlsx').IsEnabled      = $false
                $script:RGGroupList  = $null
                $script:RGValidated  = $null
                $script:RGExecResult = $null
            }
            Hide-Notification
            Write-VerboseLog "Inputs cleared." -Level Info
            break
        }
    }
})

# ── Reset-AuthUI  -  defined at script scope so the DispatcherTimer tick can call it ──
function Reset-AuthUI {
    param([string]$StatusText = 'Not connected')
    try { $script:AuthTimer.Stop() }                              catch {}
    try { $script:AuthPs.Stop(1000, $null) | Out-Null }          catch {}
    try { $script:AuthPs.EndInvoke($script:AuthHandle) }         catch {}
    try { $script:AuthRs.Close() }                               catch {}
    try { $script:AuthPs.Dispose() }                             catch {}
    $script:Window.Tag = $null
    try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
    $script:Window.FindName('BtnConnect').IsEnabled     = $true
    $script:Window.FindName('BtnAuthCancel').Visibility = 'Collapsed'
    Update-StatusBar -Text $StatusText
}

# ── CONNECT button ──
$script:Window.FindName('BtnConnect').Add_Click({
    Hide-Notification

    # ── Auth input dialog  -  pre-populated from DevConfig, user can override Client ID ──
    [xml]$authDlgXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Connect to Microsoft Graph" Width="460" SizeToContent="Height"
        WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
        Background="$($DevConfig.ColorBackground)"
        FontFamily="$($DevConfig.FontFamily)" FontSize="13">
  <Grid Margin="20">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="10"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="10"/>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="16"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <StackPanel Grid.Row="0">
      <TextBlock Text="TENANT ID" FontSize="11" FontWeight="SemiBold"
                 Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,3"/>
      <TextBox x:Name="TxtAuthTenantId" FontSize="12" Padding="8,6"
               Background="$($DevConfig.ColorSurface)" Foreground="$($DevConfig.ColorText)"
               BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"/>
    </StackPanel>
    <StackPanel Grid.Row="2">
      <TextBlock Text="CLIENT ID  (override or leave as configured)" FontSize="11" FontWeight="SemiBold"
                 Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,3"/>
      <TextBox x:Name="TxtAuthClientId" FontSize="12" Padding="8,6"
               Background="$($DevConfig.ColorSurface)" Foreground="$($DevConfig.ColorText)"
               BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"/>
    </StackPanel>
    <StackPanel Grid.Row="4">
      <TextBlock x:Name="TxtAuthError" FontSize="11" Foreground="$($DevConfig.ColorError)"
                 Visibility="Collapsed" TextWrapping="Wrap"/>
    </StackPanel>
    <StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right">
      <Button x:Name="BtnAuthCancel" Content="Cancel" Width="80" Margin="0,0,10,0"
              Background="Transparent" Foreground="$($DevConfig.ColorText)"
              BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"
              Padding="8,6" Cursor="Hand"/>
      <Button x:Name="BtnAuthConnect" Content="Connect" Width="100"
              Background="$($DevConfig.ColorPrimary)" Foreground="White"
              BorderThickness="0" Padding="8,6" FontWeight="SemiBold" Cursor="Hand"/>
    </StackPanel>
  </Grid>
</Window>
"@
    $authReader = [System.Xml.XmlNodeReader]::new($authDlgXaml)
    $authDlg    = [Windows.Markup.XamlReader]::Load($authReader)
    $authDlg.Owner = $script:Window

    # Pre-populate fields
    $authDlg.FindName('TxtAuthTenantId').Text = $DevConfig.TenantId
    $authDlg.FindName('TxtAuthClientId').Text = $DevConfig.ClientId

    $script:AuthDlgResult  = $false
    $script:AuthDlgTenant  = ''
    $script:AuthDlgClient  = ''

    $authDlg.FindName('BtnAuthCancel').Add_Click({
        $script:AuthDlgResult = $false
        $authDlg.Close()
    })

    $authDlg.FindName('BtnAuthConnect').Add_Click({
        $tid = $authDlg.FindName('TxtAuthTenantId').Text.Trim()
        $cid = $authDlg.FindName('TxtAuthClientId').Text.Trim()
        $errBlk = $authDlg.FindName('TxtAuthError')
        if ([string]::IsNullOrWhiteSpace($tid)) {
            $errBlk.Text = "Tenant ID is required."
            $errBlk.Visibility = 'Visible'
            return
        }
        if ([string]::IsNullOrWhiteSpace($cid)) {
            $errBlk.Text = "Client ID is required."
            $errBlk.Visibility = 'Visible'
            return
        }
        $script:AuthDlgResult = $true
        $script:AuthDlgTenant = $tid
        $script:AuthDlgClient = $cid
        $authDlg.Close()
    })

    $authDlg.ShowDialog() | Out-Null

    if (-not $script:AuthDlgResult) {
        Write-VerboseLog "Connection cancelled by user." -Level Warning
        return
    }

    $effectiveTenantId = $script:AuthDlgTenant
    $effectiveClientId = $script:AuthDlgClient

    # Auth timeout  -  seconds to wait for browser authentication before aborting
    $script:AuthTimeoutSec = 120

    $script:Window.FindName('BtnConnect').IsEnabled     = $false
    $script:Window.FindName('BtnAuthCancel').Visibility = 'Visible'
    $script:Window.FindName('BtnAuthCancel').IsEnabled  = $true
    $script:Window.FindName('BtnAuthCancel').Content = (New-StopBtnContent 'Cancel Auth')
    $script:AuthCancelled = $false
    $script:AuthStartTime = [datetime]::UtcNow
    Write-VerboseLog 'Initiating Microsoft Graph connection...' -Level Action
    Write-VerboseLog "Tenant ID : $effectiveTenantId" -Level Info
    Write-VerboseLog "Client ID : $effectiveClientId" -Level Info
    Write-VerboseLog "Browser authentication window should open. Sign in within $script:AuthTimeoutSec seconds, or click [Stop] Cancel Auth to abort." -Level Info
    Update-StatusBar -Text 'Waiting for browser authentication... (Cancel Auth to abort)'

    $rs = [runspacefactory]::CreateRunspace()
    $rs.ApartmentState = 'STA'
    $rs.ThreadOptions  = 'ReuseThread'
    $rs.Open()
    $rs.SessionStateProxy.SetVariable('TenantId', $effectiveTenantId)
    $rs.SessionStateProxy.SetVariable('ClientId', $effectiveClientId)
    $rs.SessionStateProxy.SetVariable('Window',   $script:Window)

    $ps = [powershell]::Create()
    $ps.Runspace = $rs

    $null = $ps.AddScript({
        param()

        # ── Helper: clear MSAL token cache to remove stale/corrupt entries ──
        function Clear-MsalCache {
            try {
                $cachePath = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenCache'
                if (Test-Path $cachePath) {
                    Get-ChildItem $cachePath -File -ErrorAction SilentlyContinue |
                        Remove-Item -Force -ErrorAction SilentlyContinue
                }
                # Also clear the Graph SDK's own cache
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            } catch {}
        }

        # ── Helper: try browser auth, return error message or $null on success ──
        function Try-BrowserAuth {
            try {
                try {
                    Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -NoWelcome `
                        -AuthType Browser -ErrorAction Stop
                } catch [System.Management.Automation.ParameterBindingException] {
                    # -AuthType not supported in this SDK version
                    Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -NoWelcome -ErrorAction Stop
                }
                return $null   # success
            } catch {
                return $_.Exception.Message
            }
        }

        try {
            Import-Module Microsoft.Graph.Authentication -ErrorAction Stop

            # ── Attempt 1: browser auth ────────────────────────────────────
             # Force browser re-authentication - clear any cached MSAL tokens first
             Clear-MsalCache
            $err1 = Try-BrowserAuth

            if ($err1) {
                # Signal UI to log the first failure
                $Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
                    $Window.Tag = "warn:Browser auth failed: $err1  -  clearing cache and retrying..."
                })
                Start-Sleep -Milliseconds 500

                # ── Attempt 2: clear MSAL cache + retry browser ────────────
                Clear-MsalCache
                $err2 = Try-BrowserAuth

                if ($err2) {
                    # Signal UI to show device code instructions
                    $Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
                        $Window.Tag = "warn:Browser auth failed again: $err2  -  switching to device code flow..."
                    })
                    Start-Sleep -Milliseconds 500

                    # ── Attempt 3: TLS callback fix + retry browser ────────
                    # Zscaler and other SSL-inspection proxies re-sign TLS
                    # connections. The runspace may not inherit the Windows
                    # certificate store correctly, causing MSAL to reject the
                    # Zscaler root CA and produce an invalid JWT error.
                    # Setting ServerCertificateValidationCallback to null forces
                    # .NET to use the OS certificate store (which includes the
                    # Zscaler root CA pushed by IT policy). Scoped to this
                    # runspace only  -  disposed immediately after auth.
                    [Net.ServicePointManager]::SecurityProtocol = `
                        [Net.SecurityProtocolType]::Tls12
                    [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null
                    Clear-MsalCache
                    $err3 = Try-BrowserAuth
                    if ($err3) { throw $err3 }
                }
            }

            $ctx = Get-MgContext
            $Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
                $Window.Tag = "connected:$($ctx.Account)|$($ctx.TenantId)|$(($ctx.Scopes -join ','))"
            })
        } catch {
            $err = $_.Exception.Message
            $Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
                $Window.Tag = "error:$err"
            })
        }
    })

    $handle = $ps.BeginInvoke()

    $timer = [System.Windows.Threading.DispatcherTimer]::new()
    $timer.Interval = [TimeSpan]::FromMilliseconds(500)
    $script:AuthTimer  = $timer
    $script:AuthPs     = $ps
    $script:AuthRs     = $rs
    $script:AuthHandle = $handle

    $timer.Add_Tick({
        # ── User clicked Cancel Auth ────────────────────────────────
        if ($script:AuthCancelled) {
            Write-VerboseLog 'Authentication cancelled by user.' -Level Warning
            Reset-AuthUI -StatusText 'Not connected'
            Show-Notification 'Authentication cancelled.' -BgColor '#FFF3CD' -FgColor '#7A4800'
            return
        }

        # ── Timeout ─────────────────────────────────────────────────
        $elapsed = ([datetime]::UtcNow - $script:AuthStartTime).TotalSeconds
        if ($elapsed -gt $script:AuthTimeoutSec) {
            Write-VerboseLog "Authentication timed out after $script:AuthTimeoutSec seconds." -Level Warning
            Reset-AuthUI -StatusText 'Not connected  -  timed out'
            Show-Notification "Authentication timed out after $script:AuthTimeoutSec seconds. Please try again." `
                -BgColor '#FFF3CD' -FgColor '#7A4800'
            return
        }

        $tag = $script:Window.Tag -as [string]
        if (-not $tag) { return }

        # ── Intermediate warning from runspace (cache clear / fallback notice) ──
        if ($tag.StartsWith('warn:')) {
            $warnMsg = $tag.Substring(5)
            $script:Window.Tag = $null
            Write-VerboseLog $warnMsg -Level Warning
            # If switching to device code, update status bar to guide the user
            if ($warnMsg -like '*TLS*' -or $warnMsg -like '*switching*') {
                Update-StatusBar -Text 'Retrying with TLS fix  -  please complete browser authentication again'
            }
            return
        }

        if ($tag.StartsWith('connected:')) {
            $script:AuthTimer.Stop()
            $info   = $tag.Substring(10) -split '\|'
            $acct   = $info[0]
            $tenant = $info[1]
            try { $script:AuthPs.EndInvoke($script:AuthHandle) } catch {}
            $script:AuthRs.Close()
            $script:AuthPs.Dispose()
            $script:Window.Tag = $null
            $script:Window.FindName('BtnAuthCancel').Visibility = 'Collapsed'

            Write-VerboseLog 'Connected successfully' -Level Success
            Write-VerboseLog "Account : $acct" -Level Info
            Write-VerboseLog "Tenant  : $tenant" -Level Info

            Update-StatusBar -Text "Connected as: $acct | Tenant: $tenant"
            $script:Window.FindName('BtnConnect').IsEnabled    = $false
            $script:Window.FindName('BtnDisconnect').IsEnabled = $true
            Show-Notification -Message "Connected as $acct" -BgColor '#D4EDDA' -FgColor '#155724'

            foreach ($b in @('BtnOpSearch','BtnOpListMembers','BtnOpObjectMembership','BtnOpFindGroupsByOwners','BtnOpCreate','BtnOpAdd','BtnOpRemove','BtnOpExport','BtnOpDynamic','BtnOpRename','BtnOpOwner','BtnOpGetPolicyAssignments','BtnOpUserDevices','BtnOpFindCommon','BtnOpFindDistinct','BtnOpGetDeviceInfo','BtnOpGetDiscoveredApps','BtnOpCompareGroups','BtnOpRemoveGroups')) {
                $script:Window.FindName($b).IsEnabled = $true
            }
            Invoke-PimCheck
            Start-PimAutoRefresh

        } elseif ($tag.StartsWith('error:')) {
            $err = $tag.Substring(6)
            Write-VerboseLog "Connection failed: $err" -Level Error
            Reset-AuthUI -StatusText 'Not connected'
            Show-Notification "Connection failed: $err" -BgColor '#F8D7DA' -FgColor '#721C24'
        }
    })
    $timer.Start()
})

# ── AUTH CANCEL button ──
$script:Window.FindName('BtnAuthCancel').Add_Click({
    $script:AuthCancelled = $true
    $script:Window.FindName('BtnAuthCancel').IsEnabled = $false
    $script:Window.FindName('BtnAuthCancel').Content   = 'Cancelling...'
    Write-VerboseLog 'Cancelling authentication...' -Level Warning
})

# ── DISCONNECT button ──
$script:Window.FindName('BtnDisconnect').Add_Click({
    try {
        Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        Write-VerboseLog "Disconnected from Microsoft Graph." -Level Info
    } catch {}
    Update-StatusBar -Text "Not connected"
    $script:Window.FindName('BtnConnect').IsEnabled    = $true
    $script:Window.FindName('BtnDisconnect').IsEnabled = $false
    $script:PimStrip.Visibility = 'Collapsed'
    $script:SpPimRoles.Children.Clear()
    Stop-PimAutoRefresh
    Show-Panel 'PanelWelcome'
    Hide-Notification
    foreach ($b in @('BtnOpSearch','BtnOpListMembers','BtnOpObjectMembership','BtnOpFindGroupsByOwners','BtnOpCreate','BtnOpAdd','BtnOpRemove','BtnOpExport','BtnOpDynamic','BtnOpRename','BtnOpOwner','BtnOpGetPolicyAssignments','BtnOpUserDevices','BtnOpFindCommon','BtnOpFindDistinct','BtnOpGetDeviceInfo','BtnOpGetDiscoveredApps','BtnOpCompareGroups','BtnOpRemoveGroups')) {
        $btn = $script:Window.FindName($b)
        if ($btn) { $btn.IsEnabled = $false }
    }
})

# ÄÄ FEEDBACK button ÄÄ
# Hide the button if no FeedbackUrl is configured
$btnFeedback = $script:Window.FindName('BtnFeedback')
if ($btnFeedback) {
    # Always show the button; click handler opens URL or logs a hint if not configured
    $btnFeedback.Visibility = 'Visible'
    $btnFeedback.Add_Click({
        $url = $DevConfig.FeedbackUrl
        if (-not [string]::IsNullOrWhiteSpace($url)) {
            try { Start-Process $url } catch {
                Write-VerboseLog "Could not open feedback URL: $url" -Level Warning
            }
        } else {
            Write-VerboseLog "Feedback URL not configured. Set FeedbackUrl in DevConfig." -Level Info
        }
    })
}

# ── AUTHOR attribution link (blog version) ──
$authorLink = $script:Window.FindName('TxtAuthorLink')
if ($authorLink) {
    $authorLink.Add_MouseLeftButtonDown({
        try { Start-Process "https://www.linkedin.com/in/satish-singhi-791163167/" } catch {}
    })
}

# ── PIM Role Status Check ────────────────────────────────────────────────────
function Invoke-PimCheck {

    # Queries the current user's active PIM role assignments via Graph.
    # Runs on a background runspace, renders results as coloured badges
    # in the PIM strip. Colour: green = >60min, amber = <=60min, red = <=15min.

    Write-VerboseLog "Checking active PIM roles..." -Level Info
    $script:PimStrip.Visibility = 'Visible'
    $script:SpPimRoles.Children.Clear()

    # Placeholder while loading
    $loadTb = [System.Windows.Controls.TextBlock]::new()
    $loadTb.Text = "Checking..."; $loadTb.FontSize = 11; $loadTb.VerticalAlignment = 'Center'
    $loadTb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
        [System.Windows.Media.ColorConverter]::ConvertFromString('#A0C0E8'))
    $loadTb.Margin = [System.Windows.Thickness]::new(0,0,0,0)
    $script:SpPimRoles.Children.Add($loadTb) | Out-Null

    $script:PimQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()

    $script:PimRs = [runspacefactory]::CreateRunspace()
    $script:PimRs.ApartmentState = 'MTA'
    $script:PimRs.ThreadOptions  = 'ReuseThread'
    $script:PimRs.Open()
    $script:PimRs.SessionStateProxy.SetVariable('PimQueue', $script:PimQueue)

    $script:PimPs = [powershell]::Create()
    $script:PimPs.Runspace = $script:PimRs
    $null = $script:PimPs.AddScript({
        function PQ { param([hashtable]$M) $PimQueue.Enqueue($M) }

        function Send-RoleResult {
            param([string]$RoleName, [string]$AssignType)
            # Expiry detection removed  -  show active role name only.
            PQ @{ Type='role'; RoleName=$RoleName; AssignType=$AssignType }
        }

        Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
        $ctx = Get-MgContext
        if (-not $ctx) { PQ @{ Type='error'; Msg='No active Graph context' }; PQ @{ Type='done' }; return }

        $me     = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me?$select=id,userPrincipalName'
        $userId = $me['id']
        PQ @{ Type='log'; Msg="Checking PIM roles for: $($me['userPrincipalName'])" }

        # ── transitiveMemberOf: active directory roles for the current user ──
        # Uses only Directory.Read.All (already required). Returns currently active roles.
        try {
            $uri2  = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole" +
                     "?`$select=id,displayName,roleTemplateId"
            $resp2 = Invoke-MgGraphRequest -Method GET -Uri $uri2
            $roles = $resp2['value']
            PQ @{ Type='log'; Msg=('PIM: transitiveMemberOf (' + $roles.Count + ' active role(s))') }
        
            if (-not $roles -or $roles.Count -eq 0) {
                PQ @{ Type='none' }
            } else {
                foreach ($role in $roles) {
                    Send-RoleResult -RoleName $role['displayName'] -AssignType 'Active (PIM)'
                }
            }
        } catch {
            $pimErr = $_.Exception.Message
            if ($pimErr -match '403|Forbidden|401|Unauthorized') {
                PQ @{ Type='nopim' }
            } else {
                PQ @{ Type='error'; Msg=$pimErr }
            }
        }
        PQ @{ Type='done' }
    })

    $script:PimHandle = $script:PimPs.BeginInvoke()

    $script:PimTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:PimTimer.Interval = [TimeSpan]::FromMilliseconds(200)
    $script:PimTimer.Add_Tick({
        $m = $null
        $gotResults = $false
        while ($script:PimQueue.TryDequeue([ref]$m)) {
            switch ($m['Type']) {
                'log' {
                    # Verbose log message from runspace
                    Write-VerboseLog "PIM: $($m['Msg'])" -Level Info
                }
                'role' {
                    if (-not $gotResults) {
                        $script:SpPimRoles.Children.Clear()
                        $gotResults = $true
                    }
                    # Build badge  -  role name only, no expiry
                    $badge = [System.Windows.Controls.Border]::new()
                    $badge.CornerRadius = [System.Windows.CornerRadius]::new(4)
                    $badge.Padding      = [System.Windows.Thickness]::new(8,3,8,3)
                    $badge.Margin       = [System.Windows.Thickness]::new(0,3,6,3)
                    $badge.Background   = [System.Windows.Media.SolidColorBrush]::new(
                        [System.Windows.Media.ColorConverter]::ConvertFromString('#1A5E3A'))
                    $badge.ToolTip      = "$($m['RoleName'])  ($($m['AssignType']))"

                    $badgeTb = [System.Windows.Controls.TextBlock]::new()
                    $badgeTb.FontSize   = 10.5
                    $badgeTb.FontWeight = 'SemiBold'
                    $badgeTb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                        [System.Windows.Media.ColorConverter]::ConvertFromString('#90EEC0'))
                    $badgeTb.Text       = "$([char]0x2713) $($m['RoleName'])"
                    $badge.Child        = $badgeTb
                    $script:SpPimRoles.Children.Add($badge) | Out-Null

                    Write-VerboseLog "PIM: $([char]0x2713) $($m['RoleName'])  ($($m['AssignType']))" -Level Info
                }
                'none' {
                    # API returned 200 but zero instances  -  no active roles right now
                    $script:SpPimRoles.Children.Clear()
                    $tb = [System.Windows.Controls.TextBlock]::new()
                    $tb.Text      = "No active PIM roles detected"
                    $tb.FontSize  = 11
                    $tb.VerticalAlignment = 'Center'
                    $tb.ToolTip   = "Navigate to Entra ID > Privileged Identity Management > My roles to activate"
                    $tb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                        [System.Windows.Media.ColorConverter]::ConvertFromString('#92400E'))
                    $script:SpPimRoles.Children.Add($tb) | Out-Null
                    Write-VerboseLog "PIM: No active PIM roles detected" -Level Warning
                }
                'nopim' {
                    # 403 response  -  no active PIM roles or RoleManagement.Read.Directory not consented
                    # Treat the same as 'none': roles are not active
                    $script:SpPimRoles.Children.Clear()
                    $tb = [System.Windows.Controls.TextBlock]::new()
                    $tb.Text      = "No active PIM roles detected"
                    $tb.FontSize  = 11
                    $tb.VerticalAlignment = 'Center'
                    $tb.ToolTip   = "403 response from Graph  -  either no PIM roles are active, or RoleManagement.Read.Directory permission is not granted on the App Registration.`nNavigate to Entra ID > Privileged Identity Management > My roles to activate."
                    $tb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                        [System.Windows.Media.ColorConverter]::ConvertFromString('#92400E'))
                    $script:SpPimRoles.Children.Add($tb) | Out-Null
                    Write-VerboseLog "PIM: No active PIM roles detected (transitiveMemberOf returned 403)" -Level Warning
                    Write-VerboseLog "PIM: To activate: Entra ID > Privileged Identity Management > My roles" -Level Info
                }
                'error' {
                    # Unexpected error  -  not a permission issue
                    $script:SpPimRoles.Children.Clear()
                    $tb = [System.Windows.Controls.TextBlock]::new()
                    $tb.Text      = "PIM check error  -  see verbose log"
                    $tb.FontSize  = 11
                    $tb.VerticalAlignment = 'Center'
                    $tb.ToolTip   = $m['Msg']
                    $tb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                        [System.Windows.Media.ColorConverter]::ConvertFromString('#F06C6C'))
                    $script:SpPimRoles.Children.Add($tb) | Out-Null
                    Write-VerboseLog "PIM check error: $($m['Msg'])" -Level Error
                }
                'done' {
                    $script:PimTimer.Stop()
                    $script:PimPs.EndInvoke($script:PimHandle)
                    $script:PimRs.Close(); $script:PimPs.Dispose()
                    $script:PimPs = $null; $script:PimRs = $null
                }
            }
        }
    })
    $script:PimTimer.Start()
}

# ── PIM refresh button ──
$script:Window.FindName('BtnPimRefresh').Add_Click({
    Write-VerboseLog "PIM roles  -  manual refresh." -Level Info
    Invoke-PimCheck
})

# ── PIM auto-refresh timer ────────────────────────────────────────────────────
# Fires every PimRefreshIntervalMinutes. Skipped silently if a background
# operation is running ($script:BgPs is not null = job in progress).
function Start-PimAutoRefresh {
    if ($DevConfig.PimRefreshIntervalMinutes -le 0) { return }
    if ($null -ne $script:PimAutoTimer) {
        $script:PimAutoTimer.Stop()
    }
    $script:PimAutoTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:PimAutoTimer.Interval = [TimeSpan]::FromMinutes($DevConfig.PimRefreshIntervalMinutes)
    $script:PimAutoTimer.Add_Tick({
        # Skip if a background Graph operation is currently running
        if ($null -ne $script:BgPs) {
            Write-VerboseLog "PIM auto-refresh skipped  -  background operation in progress." -Level Info
            return
        }
        # Skip if PIM check itself is already running
        if ($null -ne $script:PimPs) {
            return
        }
        Write-VerboseLog "PIM auto-refresh (every $($DevConfig.PimRefreshIntervalMinutes) min)." -Level Info
        Invoke-PimCheck
    })
    $script:PimAutoTimer.Start()
    Write-VerboseLog "PIM auto-refresh enabled  -  interval: $($DevConfig.PimRefreshIntervalMinutes) min." -Level Info
}

function Stop-PimAutoRefresh {
    if ($null -ne $script:PimAutoTimer) {
        $script:PimAutoTimer.Stop()
        $script:PimAutoTimer = $null
    }
}

# Helper: build StackPanel content for Stop-style buttons (icon + label)
function New-StopBtnContent {
    param([string]$Text)
    $sp   = [System.Windows.Controls.StackPanel]::new()
    $sp.Orientation = [System.Windows.Controls.Orientation]::Horizontal
    $ico  = [System.Windows.Controls.TextBlock]::new()
    $ico.Text        = [char]0xE71A
    $ico.FontFamily  = [System.Windows.Media.FontFamily]::new("Segoe MDL2 Assets")
    $ico.FontSize    = 12
    $ico.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
    $ico.Margin      = [System.Windows.Thickness]::new(0,0,5,0)
    $ico.Foreground  = [System.Windows.Media.Brushes]::White
    $lbl  = [System.Windows.Controls.TextBlock]::new()
    $lbl.Text              = $Text
    $lbl.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
    $null = $sp.Children.Add($ico)
    $null = $sp.Children.Add($lbl)
    return $sp
}

# ── STOP button ──
$script:Window.FindName('BtnStop').Add_Click({
    Write-VerboseLog "Stop requested by user  -  stopping after current object..." -Level Warning
    $script:BgStopped = $true
    if ($null -ne $script:SharedBg) { $script:SharedBg['StopRequested'] = $true }
    if ($null -ne $script:GdiStop)  { $script:GdiStop['Stop'] = $true }
    if ($null -ne $script:OmStop)  { $script:OmStop['Stop'] = $true }
    if ($null -ne $script:DaStop)  { $script:DaStop['Stop'] = $true }
    if ($null -ne $script:FGOStop)  { $script:FGOStop['Stop'] = $true }
    $script:Window.FindName('BtnStop').IsEnabled = $false
    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent "Stopping...")
})

# Disable operation buttons until connected
foreach ($b in @('BtnOpSearch','BtnOpListMembers','BtnOpObjectMembership','BtnOpFindGroupsByOwners','BtnOpCreate','BtnOpAdd','BtnOpRemove','BtnOpExport','BtnOpDynamic','BtnOpRename','BtnOpOwner','BtnOpGetPolicyAssignments','BtnOpUserDevices','BtnOpFindCommon','BtnOpFindDistinct','BtnOpGetDeviceInfo','BtnOpGetDiscoveredApps','BtnOpCompareGroups','BtnOpRemoveGroups')) {
    $btn = $script:Window.FindName($b)
    if ($btn) { $btn.IsEnabled = $false }
}

# ── Op tile buttons ──
$script:Window.FindName('BtnOpCreate').Add_Click({
    Show-Panel 'PanelCreate'
    Hide-Notification
    Write-VerboseLog "Panel: Create Group" -Level Info
})

$script:Window.FindName('BtnOpAdd').Add_Click({
    $script:CurrentMemberOp = 'Add'
    $script:Window.FindName('TxtMembershipTitle').Text       = "Add Members"
    $script:Window.FindName('TxtMemberInputLabel').Text      = "MEMBERS TO ADD  -  one per line (User UPNs / Group names or IDs / Device names or IDs)"
    $script:Window.FindName('BtnMemberValidate').Content     = "Validate & Preview ->"
    # Reset state
    $script:Window.FindName('TxtGroupSearch').Text           = ""
    $script:Window.FindName('TxtMemberList').Text            = ""
    $script:Window.FindName('SelectedGroupBadge').Visibility = 'Collapsed'
    $script:Window.FindName('GroupSearchResults').Visibility = 'Collapsed'
    $script:SelectedGroup = $null
    Show-Panel 'PanelMembership'
    Hide-Notification
    Write-VerboseLog "Panel: Add Members" -Level Info
})

$script:Window.FindName('BtnOpRemove').Add_Click({
    $script:CurrentMemberOp = 'Remove'
    $script:Window.FindName('TxtMembershipTitle').Text       = "Remove Members"
    $script:Window.FindName('TxtMemberInputLabel').Text      = "MEMBERS TO REMOVE  -  one per line (User UPNs / Group names or IDs / Device names or IDs)"
    $script:Window.FindName('BtnMemberValidate').Content     = "Validate & Preview ->"
    $script:Window.FindName('TxtGroupSearch').Text           = ""
    $script:Window.FindName('TxtMemberList').Text            = ""
    $script:Window.FindName('SelectedGroupBadge').Visibility = 'Collapsed'
    $script:Window.FindName('GroupSearchResults').Visibility = 'Collapsed'
    $script:SelectedGroup = $null
    Show-Panel 'PanelMembership'
    Hide-Notification
    Write-VerboseLog "Panel: Remove Members" -Level Info
})

$script:Window.FindName('BtnOpExport').Add_Click({
    $script:Window.FindName('TxtExportGroupSearch').Text           = ""
    $script:Window.FindName('TxtExportPath').Text                  = ""
    $script:Window.FindName('ExportSelectedGroupBadge').Visibility = 'Collapsed'
    $script:Window.FindName('ExportGroupSearchResults').Visibility = 'Collapsed'
    $script:ExportSelectedGroup = $null
    Show-Panel 'PanelExport'
    Hide-Notification
    Write-VerboseLog "Panel: Export Members" -Level Info
})

$script:Window.FindName('BtnOpDynamic').Add_Click({
    $script:Window.FindName('TxtDynGroupSearch').Text           = ""
    $script:Window.FindName('TxtDynRule').Text                  = ""
    $script:Window.FindName('DynSelectedGroupBadge').Visibility = 'Collapsed'
    $script:Window.FindName('DynGroupSearchResults').Visibility = 'Collapsed'
    $script:DynSelectedGroup = $null
    Show-Panel 'PanelDynamic'
    Hide-Notification
    Write-VerboseLog "Panel: Set Dynamic Query" -Level Info
})

$script:Window.FindName('BtnOpRename').Add_Click({
    $script:Window.FindName('TxtRenGroupSearch').Text           = ""
    $script:Window.FindName('TxtNewGroupName').Text             = ""
    $script:Window.FindName('RenSelectedGroupBadge').Visibility = 'Collapsed'
    $script:Window.FindName('RenGroupSearchResults').Visibility = 'Collapsed'
    $script:RenSelectedGroup = $null
    Show-Panel 'PanelRename'
    Hide-Notification
    Write-VerboseLog "Panel: Rename Group" -Level Info
})

$script:Window.FindName('BtnOpOwner').Add_Click({
    $script:Window.FindName('TxtOwnerGroupList').Text = ""
    $script:Window.FindName('TxtOwnerList').Text      = ""
    $script:OwnerGroupsTxt       = ''
    $script:OwnerGroupsValidated = $null
    Show-Panel 'PanelOwner'
    Hide-Notification
    Write-VerboseLog "Panel: Set Group Owner" -Level Info
})

$script:Window.FindName('BtnOpSearch').Add_Click({
    $script:Window.FindName('TxtSearchKeyword').Text         = ""
    $script:Window.FindName('PnlSearchResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
    $script:SearchResults = $null
    Show-Panel 'PanelSearch'
    Hide-Notification
    Write-VerboseLog "Panel: Search Entra Objects" -Level Info
})

$script:Window.FindName('BtnOpListMembers').Add_Click({
    $script:Window.FindName('PnlLMResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlLMProgress').Visibility  = 'Collapsed'
    Show-Panel 'PanelListMembers'
    Hide-Notification
    Write-VerboseLog "Panel: List Group Members" -Level Info
})

$script:Window.FindName('BtnOpObjectMembership').Add_Click({
    $script:Window.FindName('PnlOMResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtOMNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlOMProgress').Visibility  = 'Collapsed'
    $script:Window.FindName('TxtOMInputList').Text       = ''
    $script:Window.FindName('CmbOMObjectType').SelectedIndex = 0
    $script:Window.FindName('TxtOMInputLabel').Text = 'USER UPNs  -  one per line'
    $dg = $script:Window.FindName('DgOMResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('BtnOMCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnOMCopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnOMCopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtOMFilter').IsEnabled  = $false
    $script:Window.FindName('BtnOMFilter').IsEnabled  = $false
    $script:Window.FindName('BtnOMFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtOMFilter').Text       = ''
    $script:OMAllData = $null
    $script:Window.FindName('BtnOMExportXlsx').IsEnabled = $false
    Show-Panel 'PanelObjectMembership'
    Hide-Notification
    Write-VerboseLog "Panel: Object Membership" -Level Info
})

$script:Window.FindName('CmbOMObjectType').Add_SelectionChanged({
    $sel = $script:Window.FindName('CmbOMObjectType').SelectedItem.Content
    $label = switch ($sel) {
        'Users (UPN)'        { 'USER UPNs  -  one per line' }
        'Devices (Name)'     { 'DEVICE NAMES  -  one per line' }
        'Devices (ID)'       { 'DEVICE IDs  -  one per line' }
        'Security Groups'    { 'SECURITY GROUP names or IDs  -  one per line' }
        'M365 Groups'        { 'M365 GROUP names or IDs  -  one per line' }
        default              { 'OBJECTS  -  one per line' }
    }
    $script:Window.FindName('TxtOMInputLabel').Text = $label
})


# ── OBJECT MEMBERSHIP  -  Run ─────────────────────────────────────────
$script:Window.FindName('BtnOMRun').Add_Click({
    Hide-Notification

    $inputTxt   = $script:Window.FindName('TxtOMInputList').Text
    $objTypeSel = $script:Window.FindName('CmbOMObjectType').SelectedItem.Content

    if ([string]::IsNullOrWhiteSpace($inputTxt)) {
        Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    $script:OMParams = @{
        InputTxt  = $inputTxt
        ObjType   = $objTypeSel
    }

    # Reset UI
    $script:Window.FindName('PnlOMResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtOMNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnOMCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnOMCopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnOMCopyAll').IsEnabled    = $false
    $script:Window.FindName('BtnOMFilter').IsEnabled  = $false
    $script:Window.FindName('BtnOMFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtOMFilter').Text       = ''
    $script:OMAllData = $null
    $script:Window.FindName('TxtOMFilter').IsEnabled  = $false
    $script:Window.FindName('BtnOMExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgOMResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('PnlOMProgress').Visibility      = 'Visible'
    $script:Window.FindName('TxtOMProgressMsg').Text         = 'Resolving objects...'
    $script:Window.FindName('TxtOMProgressDetail').Text      = ''

    $script:Window.FindName('BtnOMRun').IsEnabled = $false
    $script:Window.FindName('BtnStop').Visibility = 'Visible'
    $script:Window.FindName('BtnStop').IsEnabled  = $true
    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
    $script:BgStopped = $false
    Write-VerboseLog '--- Object Membership: starting ---' -Level Action

    $script:OmQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    $script:OmStop  = [hashtable]::Synchronized(@{ Stop = $false })

    $script:OmRs = [runspacefactory]::CreateRunspace()
    $script:OmRs.ApartmentState = 'STA'
    $script:OmRs.ThreadOptions  = 'ReuseThread'
    $script:OmRs.Open()
    $script:OmRs.SessionStateProxy.SetVariable('OmQueue',  $script:OmQueue)
    $script:OmRs.SessionStateProxy.SetVariable('OmStop',   $script:OmStop)
    $script:OmRs.SessionStateProxy.SetVariable('OmParams', $script:OMParams)
    $script:OmRs.SessionStateProxy.SetVariable('LogFile',  $script:LogFile)

    $script:OmPs = [powershell]::Create()
    $script:OmPs.Runspace = $script:OmRs

    $null = $script:OmPs.AddScript({
        function OLog {
            param([string]$Msg, [string]$Level = 'Info')
            $prefix = switch ($Level) {
                "Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
                'Action'  { '> ' } default   { '  ' }
            }
            $line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
            $OmQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
            if ($LogFile) {
                try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
            }
        }

        function Get-GraphAll([string]$Uri) {
            $items = [System.Collections.Generic.List[object]]::new()
            $next  = $Uri
            do {
                $r = Invoke-MgGraphRequest -Method GET -Uri $next
                if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
                $next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
            } while ($next)
            return ,$items
        }

        $isGuidRx = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'

        function Resolve-ObjectId {
            param([string]$Entry, [string]$ObjType)
            $Entry = $Entry.Trim()
            if ([string]::IsNullOrWhiteSpace($Entry)) { return $null }
            switch ($ObjType) {
                'Users (UPN)' {
                    try {
                        $enc = [Uri]::EscapeDataString($Entry)
                        $u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$enc`?`$select=id,displayName"
                        return @{ Id=[string]$u['id']; DisplayName=[string]$u['displayName']; ResourceType='users' }
                    } catch { return $null }
                }
                'Devices (Name)' {
                    try {
                        $safe = $Entry -replace "'","''"
                        $r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
                        if ($r['value'] -and $r['value'].Count -gt 0) {
                            $d = $r['value'][0]
                            return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='devices' }
                        }
                        return $null
                    } catch { return $null }
                }
                'Devices (ID)' {
                    try {
                        $enc = [Uri]::EscapeDataString($Entry)
                        $d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$enc`?`$select=id,displayName"
                        return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='devices' }
                    } catch { return $null }
                }
                'Security Groups' {
                    try {
                        if ($Entry -match $isGuidRx) {
                            $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$Entry`?`$select=id,displayName"
                            return @{ Id=[string]$g['id']; DisplayName=[string]$g['displayName']; ResourceType='groups' }
                        } else {
                            $safe = $Entry -replace "'","''"
                            $r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe' and securityEnabled eq true and mailEnabled eq false`&`$select=id,displayName`&`$top=1"
                            if ($r['value'] -and $r['value'].Count -gt 0) {
                                $d = $r['value'][0]
                                return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='groups' }
                            }
                            return $null
                        }
                    } catch { return $null }
                }
                'M365 Groups' {
                    try {
                        if ($Entry -match $isGuidRx) {
                            $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$Entry`?`$select=id,displayName"
                            return @{ Id=[string]$g['id']; DisplayName=[string]$g['displayName']; ResourceType='groups' }
                        } else {
                            $safe = $Entry -replace "'","''"
                            $r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe' and groupTypes/any(c:c eq 'Unified')`&`$select=id,displayName`&`$top=1"
                            if ($r['value'] -and $r['value'].Count -gt 0) {
                                $d = $r['value'][0]
                                return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='groups' }
                            }
                            return $null
                        }
                    } catch { return $null }
                }
            }
            return $null
        }

        try {
            $p = $OmParams
            $entries = @($p['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
            OLog "Processing $($entries.Count) input(s) - Type: $($p['ObjType'])" 'Action'

            $allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
            $idx = 0

            foreach ($entry in $entries) {
                if ($OmStop['Stop']) { OLog 'Stop requested - halting.' 'Warning'; break }
                $idx++
                $OmQueue.Enqueue(@{ Type='progress'; Msg="Resolving object $idx of $($entries.Count)..."; Detail=$entry })

                OLog "Resolving: $entry" 'Info'
                $resolved = Resolve-ObjectId -Entry $entry -ObjType $p['ObjType']
                if (-not $resolved) {
                    OLog "  Could not resolve: $entry" 'Warning'
                    continue
                }

                $resType = $resolved['ResourceType']
                $resId   = $resolved['Id']
                $resName = $resolved['DisplayName']
                OLog "  Resolved: $resName ($resType/$resId)" 'Success'

                OLog "  Fetching transitive memberships..." 'Info'
                try {
                    $memberships = Get-GraphAll "https://graph.microsoft.com/v1.0/$resType/$resId/transitiveMemberOf?`$select=id,displayName,description,groupTypes,securityEnabled,mailEnabled`&`$top=999"
                } catch {
                    OLog "  transitiveMemberOf failed: $($_.Exception.Message)" 'Warning'
                    continue
                }

                OLog "  $($memberships.Count) group membership(s) found" 'Info'

                # Fetch direct memberships to distinguish Direct vs Transitive
                $directIds = [System.Collections.Generic.HashSet[string]]::new()
                try {
                    $directMembers = Get-GraphAll "https://graph.microsoft.com/v1.0/$resType/$resId/memberOf?`$select=id`&`$top=999"
                    foreach ($dm in $directMembers) {
                        $null = $directIds.Add([string]$dm['id'])
                    }
                } catch {}

                foreach ($grp in $memberships) {
                    if ($OmStop['Stop']) { break }
                    $odataType = [string]$grp['@odata.type']
                    if ($odataType -ne '#microsoft.graph.group') { continue }

                    $gId    = [string]$grp['id']
                    $gName  = [string]$grp['displayName']
                    $gDesc  = [string]$grp['description']
                    $gTypes = @($grp['groupTypes'])
                    $secEn  = $grp['securityEnabled']
                    $mailEn = $grp['mailEnabled']

                    $groupType = if ($gTypes -contains 'Unified') { 'M365' }
                                 elseif ($secEn -eq $true -and $mailEn -eq $false) { 'Security' }
                                 elseif ($secEn -eq $true -and $mailEn -eq $true)  { 'Mail-Enabled Security' }
                                 elseif ($secEn -eq $false -and $mailEn -eq $true) { 'Distribution' }
                                 else { 'Other' }

                    $memType = if ($directIds.Contains($gId)) { 'Direct' } else { 'Transitive' }

                    $objTypeDisplay = switch ($resType) {
                        'users'   { 'User' }
                        'devices' { 'Device' }
                        'groups'  { 'Group' }
                    }

                    $allRows.Add([PSCustomObject]@{
                        InputObject    = $entry
                        ObjectType     = $objTypeDisplay
                        GroupName      = $gName
                        GroupId        = $gId
                        GroupType      = $groupType
                        Description    = $gDesc
                        MembershipType = $memType
                    })
                }
            }

            OLog "Completed: $($allRows.Count) membership record(s) found." 'Success'
            $OmQueue.Enqueue(@{ Type='done'; Rows=$allRows; Error=$null })

        } catch {
            $OmQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
        }
    })

    $script:OmHandle = $script:OmPs.BeginInvoke()

    # Timer to drain OmQueue on the UI thread
    $script:OmTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:OmTimer.Interval = [TimeSpan]::FromMilliseconds(300)
    $script:OmTimer.Add_Tick({
        $entry = $null
        while ($script:OmQueue.TryDequeue([ref]$entry)) {
            switch ($entry['Type']) {
                'log' {
                    $lvl = $entry['Level']
                    $col = switch ($lvl) {
                        'Success' { $DevConfig.LogColorSuccess }
                        'Warning' { $DevConfig.LogColorWarning }
                        'Error'   { $DevConfig.LogColorError   }
                        'Action'  { $DevConfig.LogColorAction  }
                        default   { $DevConfig.LogColorInfo    }
                    }
                    try {
                        $para = [System.Windows.Documents.Paragraph]::new()
                        $run  = [System.Windows.Documents.Run]::new($entry['Line'])
                        $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                            [System.Windows.Media.ColorConverter]::ConvertFromString($col))
                        $para.Margin = [System.Windows.Thickness]::new(0)
                        $para.Inlines.Add($run)
                        $script:RtbLog.Document.Blocks.Add($para)
                        $script:RtbLog.ScrollToEnd()
                    } catch {}
                }
                'progress' {
                    $script:Window.FindName('PnlOMProgress').Visibility       = 'Visible'
                    $script:Window.FindName('TxtOMProgressMsg').Text          = $entry['Msg']
                    $script:Window.FindName('TxtOMProgressDetail').Text       = $entry['Detail']
                    Update-StatusBar -Text "Object Membership: $($entry['Msg'])"
                }
                'done' {
                    $script:OmTimer.Stop()
                    try { $script:OmPs.EndInvoke($script:OmHandle) } catch {}
                    try { $script:OmRs.Close() }                      catch {}
                    try { $script:OmPs.Dispose() }                    catch {}
                    $script:OmPs = $null; $script:OmRs = $null
                    $script:Window.FindName('BtnStop').Visibility = 'Collapsed'
                    $script:Window.FindName('BtnStop').IsEnabled  = $true
                    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
                    $script:Window.FindName('BtnOMRun').IsEnabled = $true
                    $script:Window.FindName('PnlOMProgress').Visibility = 'Collapsed'

                    $errMsg = $entry['Error']
                    if ($errMsg) {
                        Write-VerboseLog "Object Membership error: $errMsg" -Level Error
                        Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    $rows = @($entry['Rows'])
                    if ($rows.Count -eq 0) {
                        Write-VerboseLog 'Object Membership: no results.' -Level Warning
                        $script:Window.FindName('TxtOMNoResults').Visibility = 'Visible'
                        Show-Notification 'No group memberships found.' -BgColor '#FFF3CD' -FgColor '#7A4800'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    $dg = $script:Window.FindName('DgOMResults')
                    $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
                    $script:Window.FindName('TxtOMCount').Text            = "$($rows.Count) membership(s) found"
                    $script:Window.FindName('PnlOMResults').Visibility     = 'Visible'
                    $script:Window.FindName('BtnOMCopyAll').IsEnabled      = $true
                    $script:Window.FindName('TxtOMFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnOMFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnOMFilterClear').IsEnabled = $true
                    $script:Window.FindName('BtnOMExportXlsx').IsEnabled   = $true
                    Write-VerboseLog "Object Membership: $($rows.Count) record(s) loaded." -Level Success
                    Show-Notification "$($rows.Count) membership(s) ready." -BgColor '#D4EDDA' -FgColor '#155724'
                    Update-StatusBar -Text 'Connected'
                }
            }
        }
    })
    $script:OmTimer.Start()
})


# ── DgOMResults: cell selection ──
$script:Window.FindName('DgOMResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgOMResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnOMCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnOMCopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnOMCopyValue ──
# ── BtnOMFilter ──
$script:Window.FindName('BtnOMFilter').Add_Click({
    $dg      = $script:Window.FindName('DgOMResults')
    $keyword = $script:Window.FindName('TxtOMFilter').Text.Trim()
    if ($null -eq $script:OMAllData) {
        $script:OMAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:OMAllData
        Show-Notification "Filter cleared - $($script:OMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:OMAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnOMFilterClear ──
$script:Window.FindName('BtnOMFilterClear').Add_Click({
    $script:Window.FindName('TxtOMFilter').Text = ''
    $dg = $script:Window.FindName('DgOMResults')
    if ($null -ne $script:OMAllData) {
        $dg.ItemsSource = $script:OMAllData
        Show-Notification "Filter cleared - $($script:OMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:OMAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "OM: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnOMCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgOMResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification '$($vals.Count) value(s) copied to clipboard.' -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog 'OM: Copied cell value to clipboard.' -Level Success
})

# ── BtnOMCopyRow ──
$script:Window.FindName('BtnOMCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgOMResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rowObjs = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rowObjs.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $rowObjs) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "`n")
    Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "OM: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnOMCopyAll ──
$script:Window.FindName('BtnOMCopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgOMResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "OM: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnOMExportXlsx ──
$script:Window.FindName('BtnOMExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgOMResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title            = 'Export Object Membership Results'
    $dlg.Filter           = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
    $dlg.FileName         = "ObjectMembership_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $rowObj = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName 'ObjectMembership' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found - saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "OM: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
    } catch {
        Write-VerboseLog "OM Export failed: $($_.Exception.Message)" -Level Error
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
    }
})




# ============================================================
# BACKGROUND WORKER HELPER
# Uses a [hashtable]::Synchronized() shared between the UI thread
# and the background runspace  -  no serialisation, no write-back needed.
# $script:SharedBg is the live object both sides read/write directly.
# ============================================================
function Invoke-OnBackground {
    param(
        [scriptblock]$Work,
        [scriptblock]$Done,
        [string]$BusyMessage = "Working...",
        [System.Windows.Controls.Button]$DisableButton = $null
    )

    if ($DisableButton) { $DisableButton.IsEnabled = $false }
    $script:BgStopped = $false
    $script:Window.FindName('BtnStop').Visibility = 'Visible'
    Write-VerboseLog $BusyMessage -Level Action

    # Initialise / reset the shared state bag
    $script:SharedBg = [hashtable]::Synchronized(@{
        # Thread-safe log queue  -  runspace appends, timer drains to RichTextBox
        LogQueue            = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
        # Inputs  -  written by UI thread before runspace starts
        CreateParams        = $script:CreateParams
        CreateValidated     = $script:CreateValidated
        MemberParams        = $script:MemberParams
        MemberValidated     = $script:MemberValidated
        ExportOutPath       = $script:ExportOutPath
        ExportCount         = $script:ExportCount
        DynRule             = $script:DynRule
        RenNewName          = $script:RenNewName
        RenExistingId       = $script:RenExistingId
        OwnerUpnsTxt         = $script:OwnerUpnsTxt
        OwnerValidated       = $script:OwnerValidated
        OwnerExecResult      = $script:OwnerExecResult
        MemberExecResult    = $script:MemberExecResult
        CreateNewGroupId    = $script:CreateNewGroupId
        SelectedGroup       = $script:SelectedGroup
        ExportSelectedGroup = $script:ExportSelectedGroup
        DynSelectedGroup     = $script:DynSelectedGroup
        RenSelectedGroup     = $script:RenSelectedGroup
        OwnerGroupsTxt       = $script:OwnerGroupsTxt
        OwnerGroupsValidated = $script:OwnerGroupsValidated
        UDSelectedGroup      = $script:UDSelectedGroup
        UDParams            = $script:UDParams
        UDValidated         = $script:UDValidated
        UDExecResult        = $script:UDExecResult
        FCParams            = $script:FCParams
        FCResult            = $script:FCResult
        FDParams            = $script:FDParams
        FDResult            = $script:FDResult
        SensitivityLabels   = $script:SensitivityLabels
        LogFile             = $script:LogFile
        SearchKeyword        = $script:SearchKeyword
        SearchTypes          = $script:SearchTypes
        LMGroupInput         = $script:LMGroupInput
        LMAllResults         = $script:LMAllResults
        LMProgress           = $null
        CGGroupInput         = $script:CGGroupInput
        CGAllResults         = $script:CGAllResults
        CGProgress           = $null
        GPAParams            = $script:GPAParams
        GPAResult            = $null
        RGGroupList          = $script:RGGroupList
        RGValidated          = $script:RGValidated
        RGExecResult         = $null
        # Signalling
        Status              = 'running'   # 'ok' | 'error' | 'stopped'
        ErrorMessage        = ''
        StopRequested       = $false
    })

    # Build function definitions to inject
    $fnNames = @(
        'Get-Timestamp','Write-VerboseLog','Invoke-GraphGet','Resolve-GroupByNameOrId',
        'Search-Groups','Resolve-MemberEntry','Validate-InputList',
        'Get-GroupMemberCount','Get-GroupMembers','Add-MembersToGroup',
        'Remove-MembersFromGroup','Export-GroupMembersToXlsx',
        'Get-GPASubType','Get-GPAPlatform'
    )
    $fnScript = [System.Text.StringBuilder]::new()
    foreach ($name in $fnNames) {
        $sb = (Get-Command $name -ErrorAction SilentlyContinue).ScriptBlock
        if ($sb) { $null = $fnScript.AppendLine("function $name {`n$sb`n}") }
    }

    $rs = [runspacefactory]::CreateRunspace()
    $rs.ApartmentState = 'STA'
    $rs.ThreadOptions  = 'ReuseThread'
    $rs.Open()

    # Pass the SAME synchronized hashtable object  -  no serialisation occurs
    $rs.SessionStateProxy.SetVariable('Shared',  $script:SharedBg)
    $rs.SessionStateProxy.SetVariable('WorkStr', $Work.ToString())
    $rs.SessionStateProxy.SetVariable('DevConfig', $DevConfig)

    $runScript = $fnScript.ToString() + @'
Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue

# Runspace-safe Write-VerboseLog  -  no WPF/Dispatcher access.
# Logs to file and enqueues for the UI timer to drain into the RichTextBox.
function Write-VerboseLog {
    param([string]$Message,
          [ValidateSet("Info","Success","Warning","Error","Action")][string]$Level = "Info")
    $prefix = switch ($Level) {
        "Info"    { "  " } "Success" { "✓ " } "Warning" { "⚠ " }
        "Error"   { "✗ " } "Action"  { "▶ " }
    }
    $ts   = Get-Timestamp
    $line = "[$ts] $prefix$Message"
    if ($script:LogFile) {
        try { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
    }
    $Shared["LogQueue"].Enqueue(@{ Line = $line; Level = $Level })
}

# Map Shared keys into $script:* so the Work block can use $script:X syntax
$script:CreateParams        = $Shared['CreateParams']
$script:CreateValidated     = $Shared['CreateValidated']
$script:MemberParams        = $Shared['MemberParams']
$script:MemberValidated     = $Shared['MemberValidated']
$script:ExportOutPath       = $Shared['ExportOutPath']
$script:ExportCount         = $Shared['ExportCount']
$script:DynRule             = $Shared['DynRule']
$script:RenNewName          = $Shared['RenNewName']
$script:RenExistingId       = $Shared['RenExistingId']
$script:OwnerUpnsTxt        = $Shared['OwnerUpnsTxt']
$script:OwnerValidated      = $Shared['OwnerValidated']
$script:OwnerExecResult     = $Shared['OwnerExecResult']
$script:MemberExecResult    = $Shared['MemberExecResult']
$script:CreateNewGroupId    = $Shared['CreateNewGroupId']
$script:SelectedGroup       = $Shared['SelectedGroup']
$script:ExportSelectedGroup = $Shared['ExportSelectedGroup']
$script:DynSelectedGroup    = $Shared['DynSelectedGroup']
$script:RenSelectedGroup     = $Shared['RenSelectedGroup']
$script:OwnerGroupsTxt       = $Shared['OwnerGroupsTxt']
$script:OwnerGroupsValidated = $Shared['OwnerGroupsValidated']
$script:UDSelectedGroup      = $Shared['UDSelectedGroup']
$script:UDParams            = $Shared['UDParams']
$script:UDValidated         = $Shared['UDValidated']
$script:UDExecResult        = $Shared['UDExecResult']
$script:FCParams            = $Shared['FCParams']
$script:FCResult            = $Shared['FCResult']
$script:FDParams            = $Shared['FDParams']
$script:FDResult            = $Shared['FDResult']
$script:SensitivityLabels   = $Shared['SensitivityLabels']
$script:LogFile             = $Shared['LogFile']
$script:SearchKeyword        = $Shared['SearchKeyword']
$script:SearchTypes          = $Shared['SearchTypes']
$script:LMGroupInput         = $Shared['LMGroupInput']
$script:LMAllResults         = $Shared['LMAllResults']
$script:CGGroupInput         = $Shared['CGGroupInput']
$script:CGAllResults         = $Shared['CGAllResults']
$script:GPAParams            = $Shared['GPAParams']
$script:RGGroupList  = $Shared['RGGroupList']
$script:RGValidated  = $Shared['RGValidated']
$script:RGExecResult = $Shared['RGExecResult']

try {
    $workBlock = [scriptblock]::Create($WorkStr)
    & $workBlock

    # Write results back into the shared bag  -  UI thread reads these in Done
    $Shared['CreateValidated']  = $script:CreateValidated
    $Shared['MemberValidated']  = $script:MemberValidated
    $Shared['MemberExecResult'] = $script:MemberExecResult
    $Shared['ExportCount']      = $script:ExportCount
    $Shared['CreateNewGroupId'] = $script:CreateNewGroupId
    $Shared['RenExistingId']    = $script:RenExistingId
    $Shared['OwnerValidated']       = $script:OwnerValidated
    $Shared['OwnerExecResult']      = $script:OwnerExecResult
    $Shared['OwnerGroupsValidated'] = $script:OwnerGroupsValidated
    $Shared['UDValidated']      = $script:UDValidated
    $Shared['UDExecResult']     = $script:UDExecResult
    $Shared['FCResult']         = $script:FCResult
    $Shared['FDResult']         = $script:FDResult
    $Shared['LMAllResults']     = $script:LMAllResults
    $Shared['CGAllResults']     = $script:CGAllResults
    $Shared['GPAResult']        = $script:GPAResult
    $Shared['RGValidated']      = $script:RGValidated
    $Shared['RGExecResult']     = $script:RGExecResult
    $Shared['SensitivityLabels'] = $script:SensitivityLabels
    $Shared['Status']           = 'ok'
} catch {
    $Shared['ErrorMessage'] = $_.Exception.Message
    $Shared['Status']       = 'error'
}
'@

    $ps = [powershell]::Create()
    $ps.Runspace = $rs
    $null = $ps.AddScript($runScript)

    $script:BgPs     = $ps
    $script:BgRs     = $rs
    $script:BgHandle = $ps.BeginInvoke()
    $script:BgDone   = $Done
    $script:BgBtn    = $DisableButton

    $timer = [System.Windows.Threading.DispatcherTimer]::new()
    $timer.Interval = [TimeSpan]::FromMilliseconds(300)
    $script:BgTimer  = $timer

    $timer.Add_Tick({
        # Drain log queue regardless of completion status
        $shared = $script:SharedBg
        if ($null -ne $shared -and $null -ne $shared['LogQueue']) {
            $entry = $null
            while ($shared['LogQueue'].TryDequeue([ref]$entry)) {
                $line  = $entry['Line']
                $level = $entry['Level']
                $col   = switch ($level) {
                    'Info'    { $DevConfig.LogColorInfo }
                    'Success' { $DevConfig.LogColorSuccess }
                    'Warning' { $DevConfig.LogColorWarning }
                    'Error'   { $DevConfig.LogColorError }
                    'Action'  { $DevConfig.LogColorAction }
                    default   { $DevConfig.LogColorInfo }
                }
                try {
                    $para = [System.Windows.Documents.Paragraph]::new()
                    $run  = [System.Windows.Documents.Run]::new($line)
                    $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                        [System.Windows.Media.ColorConverter]::ConvertFromString($col))
                    $para.Margin = [System.Windows.Thickness]::new(0,0,0,0)
                    $para.Inlines.Add($run)
                    $script:RtbLog.Document.Blocks.Add($para)
                    $script:RtbLog.ScrollToEnd()
                } catch {}
            }
        }
        if ($null -eq $shared -or $shared['Status'] -eq 'running') { return }

        $script:BgTimer.Stop()
        $script:Window.FindName('BtnStop').Visibility = 'Collapsed'
        try { $script:BgPs.EndInvoke($script:BgHandle) } catch {}
        try { $script:BgRs.Close() } catch {}
        try { $script:BgPs.Dispose() } catch {}
        if ($script:BgBtn) { $script:BgBtn.IsEnabled = $true }
        $script:Window.FindName('BtnStop').IsEnabled = $true
        $script:Window.FindName('BtnStop').Content = (New-StopBtnContent "Stop")

        if ($script:BgStopped) {
            Write-VerboseLog "Operation stopped by user." -Level Warning
            Show-Notification "Operation stopped by user." -BgColor '#FFF3CD' -FgColor '#7A4800'
            return
        }

        if ($shared['Status'] -eq 'error') {
            $errMsg = $shared['ErrorMessage']
            Write-VerboseLog "Background error: $errMsg" -Level Error
            Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
        } else {
            # Promote results from shared bag into $script:* for Done block
            $script:CreateValidated  = $shared['CreateValidated']
            $script:MemberValidated  = $shared['MemberValidated']
            $script:MemberExecResult = $shared['MemberExecResult']
            $script:ExportCount      = $shared['ExportCount']
            $script:CreateNewGroupId = $shared['CreateNewGroupId']
            $script:RenExistingId    = $shared['RenExistingId']
            $script:OwnerValidated       = $shared['OwnerValidated']
            $script:OwnerExecResult      = $shared['OwnerExecResult']
            $script:OwnerGroupsValidated = $shared['OwnerGroupsValidated']
            $script:UDValidated          = $shared['UDValidated']
            $script:UDExecResult      = $shared['UDExecResult']
            $script:FCResult          = $shared['FCResult']
            $script:FDResult          = $shared['FDResult']
            $script:SensitivityLabels = $shared['SensitivityLabels']
            $script:DynGroupState    = $shared['DynGroupState']
            $script:SearchResults    = $shared['SearchResults']
            $script:LMAllResults     = $shared['LMAllResults']
            $script:CGAllResults     = $shared['CGAllResults']
            $script:GPAResult         = $shared['GPAResult']
            $script:RGValidated  = $shared['RGValidated']
            $script:RGExecResult = $shared['RGExecResult']
            if ($script:BgDone) { & $script:BgDone }
        }
    })
    $timer.Start()
}


# ── GROUP SEARCH helper (reused across panels) ──
# All element references are resolved by name INSIDE each handler to avoid
# closure capture failure (local variables are out of scope when events fire).
# OnSelected callbacks are stored in a script-scope hashtable keyed by picker name.
function Wire-GroupPicker {
    param(
        [string]$SearchBoxName,
        [string]$SearchBtnName,
        [string]$ByIdBtnName,
        [string]$ResultsListName,
        [string]$ResultsBorderName,
        [string]$BadgeName,
        [string]$BadgeNameTxt,
        [string]$BadgeIdTxt,
        [string]$ScriptVarName,
        [scriptblock]$OnSelected
    )

    $script:PickerCallbacks[$SearchBoxName] = $OnSelected

    # ---- Search button & Enter key ----
    $searchBtn = $script:Window.FindName($SearchBtnName)
    $searchBtn.Tag = "$SearchBoxName|$ResultsListName|$ResultsBorderName"
    $searchBtn.Add_Click({
        param($sender, $e)
        try {
        $parts        = $sender.Tag -split '\|'
        $sBoxName     = $parts[0]
        $lstName      = $parts[1]
        $borderName   = $parts[2]
        $q = $script:Window.FindName($sBoxName).Text.Trim()
        if ([string]::IsNullOrWhiteSpace($q)) { return }
        Write-VerboseLog "Searching groups: $q" -Level Info
        $lst    = $script:Window.FindName($lstName)
        $border = $script:Window.FindName($borderName)
        $lst.Items.Clear()
        $groups = Search-Groups -Query $q
        if ($groups.Count -eq 0) {
            Write-VerboseLog "No groups found matching '$q'" -Level Warning
            $border.Visibility = 'Collapsed'
            return
        }
        foreach ($g in $groups) {
            $typeLabel = if ($g['groupTypes'] -contains 'Unified') { '[M365]' } else { '[Security]' }
            $lst.Items.Add([PSCustomObject]@{
                Display = "$($g['displayName'])  $typeLabel  |  $($g['id'])"
                Id = $g['id']; Name = $g['displayName']; Raw = $g
            })
        }
        $border.Visibility = 'Visible'
        } catch { Write-VerboseLog "Group search error: $($_.Exception.Message)" -Level Error }
    })

    $searchBox = $script:Window.FindName($SearchBoxName)
    $searchBox.Tag = "$SearchBtnName"
    $searchBox.Add_KeyDown({
        param($sender, $e)
        if ($e.Key -eq 'Return') {
            $script:Window.FindName($sender.Tag).RaiseEvent(
                [System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent)
            )
        }
    })

    # ---- Use as ID button ----
    $byIdBtn = $script:Window.FindName($ByIdBtnName)
    $byIdBtn.Tag = "$SearchBoxName|$BadgeNameTxt|$BadgeIdTxt|$BadgeName|$ResultsBorderName|$ScriptVarName"
    $byIdBtn.Add_Click({
        param($sender, $e)
        $parts        = $sender.Tag -split '\|'
        $sBoxName     = $parts[0]
        $badgeNameTxt = $parts[1]
        $badgeIdTxt   = $parts[2]
        $badgeName    = $parts[3]
        $borderName   = $parts[4]
        $varName      = $parts[5]
        $idVal = $script:Window.FindName($sBoxName).Text.Trim()
        if ([string]::IsNullOrWhiteSpace($idVal)) {
            Show-Notification "Enter a Group Object ID or display name in the text box first."
            return
        }
        Write-VerboseLog "Resolving group: $idVal" -Level Action
        $g = Resolve-GroupByNameOrId -GroupEntry $idVal
        if ($null -eq $g) {
            Write-VerboseLog "Group not found: $idVal" -Level Error
            Show-Notification "Group not found: $idVal" -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }
        Set-Variable -Name $varName -Value $g -Scope Script
        $script:Window.FindName($badgeNameTxt).Text = $g.DisplayName
        $script:Window.FindName($badgeIdTxt).Text   = "  ($($g.Id))"
        $script:Window.FindName($badgeName).Visibility  = 'Visible'
        $script:Window.FindName($borderName).Visibility = 'Collapsed'
        Write-VerboseLog "Group selected: $($g.DisplayName) ($($g.Id))" -Level Success
        $cb = $script:PickerCallbacks[$sBoxName]
        if ($cb) { & $cb $g }
    })

    # ---- Results list selection ----
    $lstResults = $script:Window.FindName($ResultsListName)
    $lstResults.Tag = "$ResultsListName|$BadgeNameTxt|$BadgeIdTxt|$BadgeName|$ResultsBorderName|$ScriptVarName|$SearchBoxName"
    $lstResults.Add_SelectionChanged({
        param($sender, $e)
        try {
        $parts        = $sender.Tag -split '\|'
        $lstName      = $parts[0]
        $badgeNameTxt = $parts[1]
        $badgeIdTxt   = $parts[2]
        $badgeName    = $parts[3]
        $borderName   = $parts[4]
        $varName      = $parts[5]
        $sBoxName     = $parts[6]
        $sel = $script:Window.FindName($lstName).SelectedItem
        if ($null -eq $sel) { return }
        $g    = $sel.Raw
        $gObj = [PSCustomObject]@{
            Id = $g['id']; DisplayName = $g['displayName']
            GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule']
        }
        Set-Variable -Name $varName -Value $gObj -Scope Script
        $script:Window.FindName($badgeNameTxt).Text = $gObj.DisplayName
        $script:Window.FindName($badgeIdTxt).Text   = "  ($($gObj.Id))"
        $script:Window.FindName($badgeName).Visibility  = 'Visible'
        $script:Window.FindName($borderName).Visibility = 'Collapsed'
        Write-VerboseLog "Group selected: $($gObj.DisplayName) ($($gObj.Id))" -Level Success
        $cb = $script:PickerCallbacks[$sBoxName]
        if ($cb) { & $cb $gObj }
        } catch { Write-VerboseLog "Group selection error: $($_.Exception.Message)" -Level Error }
    })
}

# Wire group pickers for each panel
Wire-GroupPicker `
    -SearchBoxName 'TxtGroupSearch' -SearchBtnName 'BtnGroupSearch' -ByIdBtnName 'BtnGroupById' `
    -ResultsListName 'LstGroupResults' -ResultsBorderName 'GroupSearchResults' `
    -BadgeName 'SelectedGroupBadge' -BadgeNameTxt 'TxtSelectedGroupName' -BadgeIdTxt 'TxtSelectedGroupId' `
    -ScriptVarName 'SelectedGroup' `
    -OnSelected {
        param($g)
        $cnt = Get-GroupMemberCount -GroupId $g.Id
        if ($null -ne $cnt) {
            $script:Window.FindName('TxtSelectedGroupMemCount').Text = "  Members: $cnt"
        }
    }

Wire-GroupPicker `
    -SearchBoxName 'TxtExportGroupSearch' -SearchBtnName 'BtnExportGroupSearch' -ByIdBtnName 'BtnExportGroupById' `
    -ResultsListName 'LstExportGroupResults' -ResultsBorderName 'ExportGroupSearchResults' `
    -BadgeName 'ExportSelectedGroupBadge' -BadgeNameTxt 'TxtExportGroupName' -BadgeIdTxt 'TxtExportGroupId' `
    -ScriptVarName 'ExportSelectedGroup'

Wire-GroupPicker `
    -SearchBoxName 'TxtDynGroupSearch' -SearchBtnName 'BtnDynGroupSearch' -ByIdBtnName 'BtnDynGroupById' `
    -ResultsListName 'LstDynGroupResults' -ResultsBorderName 'DynGroupSearchResults' `
    -BadgeName 'DynSelectedGroupBadge' -BadgeNameTxt 'TxtDynGroupName' -BadgeIdTxt 'TxtDynGroupId' `
    -ScriptVarName 'DynSelectedGroup' `
    -OnSelected {
        param($g)
        if ($g.MembershipRule) {
            $script:Window.FindName('TxtDynCurrentRule').Text = "  Current rule: $($g.MembershipRule)"
            $script:Window.FindName('TxtDynRule').Text        = $g.MembershipRule
        }
    }

Wire-GroupPicker `
    -SearchBoxName 'TxtRenGroupSearch' -SearchBtnName 'BtnRenGroupSearch' -ByIdBtnName 'BtnRenGroupById' `
    -ResultsListName 'LstRenGroupResults' -ResultsBorderName 'RenGroupSearchResults' `
    -BadgeName 'RenSelectedGroupBadge' -BadgeNameTxt 'TxtRenGroupCurrentName' -BadgeIdTxt 'TxtRenGroupId' `
    -ScriptVarName 'RenSelectedGroup'

# ── Export browse ──
$script:Window.FindName('BtnExportBrowse').Add_Click({
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title      = "Save Group Members XLSX"
    $dlg.Filter     = "Excel files (*.xlsx)|*.xlsx"
    $dlg.DefaultExt = ".xlsx"
    $dlg.FileName   = "GroupMembers_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    if ($dlg.ShowDialog()) {
        $script:Window.FindName('TxtExportPath').Text = $dlg.FileName
    }
})



# ── FIND GROUPS BY OWNERS  -  Tile Click ──────────────────────────────────────
$script:Window.FindName('BtnOpFindGroupsByOwners').Add_Click({
    $script:Window.FindName('PnlFGOResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtFGONoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlFGOProgress').Visibility  = 'Collapsed'
    $script:Window.FindName('TxtFGOInputList').Text       = ''
    $dg = $script:Window.FindName('DgFGOResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('BtnFGOCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnFGOCopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnFGOCopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtFGOFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFGOFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFGOFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtFGOFilter').Text       = ''
    $script:FGOAllData = $null
    $script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $false
    Show-Panel 'PanelFindGroupsByOwners'
    Hide-Notification
    Write-VerboseLog "Panel: Find Groups by Owners" -Level Info
})


# ── FIND GROUPS BY OWNERS  -  Run ─────────────────────────────────────────────
$script:Window.FindName('BtnFGORun').Add_Click({
    Hide-Notification

    $inputTxt = $script:Window.FindName('TxtFGOInputList').Text
    $entries  = @($inputTxt -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
    if ($entries.Count -eq 0) {
        Show-Notification 'Enter at least one owner UPN.' -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }

    # Reset UI
    $script:Window.FindName('PnlFGOResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtFGONoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnFGOCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnFGOCopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnFGOCopyAll').IsEnabled    = $false
    $script:Window.FindName('BtnFGOFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFGOFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtFGOFilter').Text       = ''
    $script:Window.FindName('TxtFGOFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $false
    $script:FGOAllData = $null
    $dg = $script:Window.FindName('DgFGOResults')
    if ($dg) { $dg.ItemsSource = $null }

    $script:Window.FindName('PnlFGOProgress').Visibility = 'Visible'
    $script:Window.FindName('TxtFGOProgressMsg').Text    = 'Resolving owners...'
    $script:Window.FindName('TxtFGOProgressDetail').Text = ''
    $script:Window.FindName('BtnFGORun').IsEnabled       = $false

    $script:FGOQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
    $script:FGOStop  = [hashtable]::Synchronized(@{ Stop = $false })

    $script:FGORs = [runspacefactory]::CreateRunspace()
    $script:FGORs.ApartmentState = 'STA'
    $script:FGORs.Open()
    $script:FGORs.SessionStateProxy.SetVariable('entries',   $entries)
    $script:FGORs.SessionStateProxy.SetVariable('queue',     $script:FGOQueue)
    $script:FGORs.SessionStateProxy.SetVariable('stopFlag',  $script:FGOStop)
    # VerboseLog not passed to runspace - uses queue pattern instead

    $script:FGOPs = [powershell]::Create()
    $script:FGOPs.Runspace = $script:FGORs
    $script:FGOPs.AddScript({
        param($entries, $queue, $stopFlag)

        $resultList = [System.Collections.Generic.List[PSObject]]::new()
        $resolved   = 0
        $failed     = 0

        foreach ($upn in $entries) {
            if ($stopFlag['Stop']) {
                $queue.Enqueue(@{ type = 'progress'; msg = 'Stopped by user.'; detail = '' })
                break
            }

            $queue.Enqueue(@{ type = 'progress'; msg = "Resolving owner: $upn ..."; detail = "$($resolved + $failed + 1) of $($entries.Count)" })

            try {
                # Resolve user
                $u = Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($upn))?`$select=id,userPrincipalName"
                $userId = $u['id']
                $ownerUpn = $u['userPrincipalName']

                # Get owned objects (groups only)
                $ownedGroups = [System.Collections.Generic.List[hashtable]]::new()
                $uri = "https://graph.microsoft.com/v1.0/users/$userId/ownedObjects/microsoft.graph.group?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRule&`$top=999"
                while ($uri) {
                    if ($stopFlag['Stop']) { break }
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    if ($resp['value']) {
                        foreach ($g in $resp['value']) { $ownedGroups.Add($g) }
                    }
                    $uri = $resp['@odata.nextLink']
                }

                if ($stopFlag['Stop']) { break }

                foreach ($g in $ownedGroups) {
                    if ($stopFlag['Stop']) { break }

                    # Determine group type
                    $gTypes = $g['groupTypes']
                    $mailEnabled = $g['mailEnabled']
                    $secEnabled  = $g['securityEnabled']
                    $gType = 'Other'
                    if ($gTypes -and $gTypes -contains 'Unified') { $gType = 'M365' }
                    elseif ($secEnabled -and -not $mailEnabled)   { $gType = 'Security' }
                    elseif ($secEnabled -and $mailEnabled)        { $gType = 'Mail-Enabled Security' }
                    elseif (-not $secEnabled -and $mailEnabled)   { $gType = 'Distribution' }

                    # Membership type
                    $mType = 'Assigned'
                    if ($gTypes -and $gTypes -contains 'DynamicMembership') { $mType = 'Dynamic' }

                    # Member count
                    $memberCount = '...'
                    try {
                        $cntResp = Invoke-MgGraphRequest -Method GET `
                            -Uri "https://graph.microsoft.com/v1.0/groups/$($g['id'])/members/`$count" `
                            -Headers @{ 'ConsistencyLevel' = 'eventual' }
                        $memberCount = [string]$cntResp
                    } catch {
                        $memberCount = 'N/A'
                    }

                    $resultList.Add([PSCustomObject]@{
                        Owner          = $ownerUpn
                        GroupName      = [string]$g['displayName']
                        GroupId        = [string]$g['id']
                        GroupType      = $gType
                        MembershipType = $mType
                        MemberCount    = $memberCount
                    })
                }

                $resolved++
                $queue.Enqueue(@{ type = 'progress'; msg = "Resolved: $ownerUpn ($($ownedGroups.Count) group(s))"; detail = "$($resolved + $failed) of $($entries.Count)" })

            } catch {
                $failed++
                $queue.Enqueue(@{ type = 'progress'; msg = "Failed: $upn - $($_.Exception.Message)"; detail = "$($resolved + $failed) of $($entries.Count)" })
            }
        }

        $queue.Enqueue(@{ type = 'done'; results = $resultList; resolved = $resolved; failed = $failed })
    }).AddArgument($entries).AddArgument($script:FGOQueue).AddArgument($script:FGOStop)

    $script:FGOHandle = $script:FGOPs.BeginInvoke()

    # Timer to poll queue
    $script:FGOTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:FGOTimer.Interval = [TimeSpan]::FromMilliseconds(200)
    $script:FGOTimer.Add_Tick({
        try {
            $msg = $null
            while ($script:FGOQueue.TryDequeue([ref]$msg)) {
                switch ($msg.type) {
                    'progress' {
                        $script:Window.FindName('TxtFGOProgressMsg').Text    = $msg.msg
                        $script:Window.FindName('TxtFGOProgressDetail').Text = $msg.detail
                        Write-VerboseLog "FGO: $($msg.msg)" -Level Info
                    }
                    'done' {
                        $script:FGOTimer.Stop()
                        $script:Window.FindName('PnlFGOProgress').Visibility = 'Collapsed'
                        $script:Window.FindName('BtnFGORun').IsEnabled       = $true
                        $results = $msg.results

                        if ($results -and $results.Count -gt 0) {
                            $dg = $script:Window.FindName('DgFGOResults')
                            $dg.ItemsSource = @($results)
                            $script:Window.FindName('TxtFGOCount').Text = "$($results.Count) owned group(s) found  |  Resolved: $($msg.resolved)  Failed: $($msg.failed)"
                            $script:Window.FindName('PnlFGOResults').Visibility = 'Visible'
                            $script:Window.FindName('BtnFGOCopyAll').IsEnabled    = $true
                            $script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $true
                            $script:Window.FindName('BtnFGOFilter').IsEnabled     = $true
                            $script:Window.FindName('BtnFGOFilterClear').IsEnabled = $true
                            $script:Window.FindName('TxtFGOFilter').IsEnabled     = $true
                            Show-Notification "$($results.Count) owned group(s) found." -BgColor '#D4EDDA' -FgColor '#155724'
                        } else {
                            $script:Window.FindName('TxtFGONoResults').Visibility = 'Visible'
                            Show-Notification "No owned groups found for the provided UPNs." -BgColor '#FFF3CD' -FgColor '#856404'
                        }
                        Write-VerboseLog "FGO: Complete. $($results.Count) result(s)." -Level Info
                    }
                }
            }
        } catch {
            Write-VerboseLog "FGO timer error: $($_.Exception.Message)" -Level Warning
        }
    })
    $script:FGOTimer.Start()
})

# ── FIND GROUPS BY OWNERS  -  DataGrid SelectedCellsChanged ───────────────────
$script:Window.FindName('DgFGOResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgFGOResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnFGOCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnFGOCopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── FIND GROUPS BY OWNERS  -  Filter ──────────────────────────────────────────
$script:Window.FindName('BtnFGOFilter').Add_Click({
    $dg      = $script:Window.FindName('DgFGOResults')
    $keyword = $script:Window.FindName('TxtFGOFilter').Text.Trim()
    if ($null -eq $script:FGOAllData) {
        $script:FGOAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:FGOAllData
        Show-Notification "Filter cleared - $($script:FGOAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:FGOAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:FGOAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "FGO: Filter applied - keyword='$keyword'" -Level Info
})

# ── FIND GROUPS BY OWNERS  -  Filter Clear ────────────────────────────────────
$script:Window.FindName('BtnFGOFilterClear').Add_Click({
    $script:Window.FindName('TxtFGOFilter').Text = ''
    $dg = $script:Window.FindName('DgFGOResults')
    if ($null -ne $script:FGOAllData) {
        $dg.ItemsSource = $script:FGOAllData
        Show-Notification "Filter cleared - $($script:FGOAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})

# ── FIND GROUPS BY OWNERS  -  Copy Value ──────────────────────────────────────
$script:Window.FindName('BtnFGOCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgFGOResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
})

# ── FIND GROUPS BY OWNERS  -  Copy Row ────────────────────────────────────────
$script:Window.FindName('BtnFGOCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgFGOResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $header = ($colDefs | ForEach-Object { $_.H }) -join "`t"
    $lines  = [System.Collections.Generic.List[string]]::new()
    $lines.Add($header)
    foreach ($r in $rows) {
        $vals = [System.Collections.Generic.List[string]]::new()
        foreach ($cd in $colDefs) {
            $v = ''
            if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
            $vals.Add($v)
        }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

# ── FIND GROUPS BY OWNERS  -  Copy All ────────────────────────────────────────
$script:Window.FindName('BtnFGOCopyAll').Add_Click({
    $dg = $script:Window.FindName('DgFGOResults')
    if ($null -eq $dg.ItemsSource) { return }
    $allRows = @($dg.ItemsSource)
    if ($allRows.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $header = ($colDefs | ForEach-Object { $_.H }) -join "`t"
    $lines  = [System.Collections.Generic.List[string]]::new()
    $lines.Add($header)
    foreach ($r in $allRows) {
        $vals = [System.Collections.Generic.List[string]]::new()
        foreach ($cd in $colDefs) {
            $v = ''
            if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
            $vals.Add($v)
        }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($allRows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
})

# ── FIND GROUPS BY OWNERS  -  Export XLSX ─────────────────────────────────────
$script:Window.FindName('BtnFGOExportXlsx').Add_Click({
    $dg = $script:Window.FindName('DgFGOResults')
    if ($null -eq $dg.ItemsSource) { return }
    $allRows = @($dg.ItemsSource)
    if ($allRows.Count -eq 0) { return }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title      = 'Save Groups by Owners XLSX'
    $dlg.Filter     = 'Excel files (*.xlsx)|*.xlsx'
    $dlg.DefaultExt = '.xlsx'
    $dlg.FileName   = "GroupsByOwners_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    if (-not $dlg.ShowDialog()) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    try {
        $exportRows = [System.Collections.Generic.List[PSObject]]::new()
        foreach ($r in $allRows) {
            $obj = [ordered]@{}
            foreach ($cd in $colDefs) {
                $v = ''
                if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
                $obj[$cd.H] = $v
            }
            $exportRows.Add([PSCustomObject]$obj)
        }
        $exportRows | Export-Excel -Path $dlg.FileName -AutoSize -FreezeTopRow -BoldTopRow
        Show-Notification "Exported $($allRows.Count) row(s) to XLSX." -BgColor '#D4EDDA' -FgColor '#155724'
        Write-VerboseLog "FGO: Exported to $($dlg.FileName)" -Level Info
    } catch {
        try {
            $csvPath = [System.IO.Path]::ChangeExtension($dlg.FileName, '.csv')
            $exportRows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not available - exported as CSV." -BgColor '#FFF3CD' -FgColor '#856404'
        } catch {
            Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        }
    }
})

# ── Set domain suffix label from DevConfig (or just "@" if not configured) ──
$domainSuffix = if ($DevConfig.M365MailDomain) { "@$($DevConfig.M365MailDomain)" } else { "@" }
$script:Window.FindName('TxtMailDomainSuffix').Text = $domainSuffix

# ── M365 panel visibility + lazy sensitivity label fetch ──
$script:Window.FindName('CmbCreateType').Add_SelectionChanged({
    $sel   = $script:Window.FindName('CmbCreateType').SelectedItem.Content
    $isM365 = $sel -like '*365*'
    $script:Window.FindName('PnlMailNick').Visibility = if ($isM365) { 'Visible' } else { 'Collapsed' }
})
$script:Window.FindName('PnlMailNick').Visibility = 'Collapsed'  # default hidden (Security selected)


# ── CREATE GROUP  -  Members ↔ Dynamic Rule mutual exclusion ──
# If the user populates the Members list, grey out the Dynamic Rule field (contradictory inputs).
# Conversely, if a Dynamic Rule is entered, grey out the Members field.
$script:Window.FindName('TxtCreateMembers').Add_TextChanged({
    $hasMembersText = -not [string]::IsNullOrWhiteSpace($script:Window.FindName('TxtCreateMembers').Text)
    $dynBox         = $script:Window.FindName('TxtCreateDynamic')
    $dynBox.IsEnabled = -not $hasMembersText
    $dynBox.Opacity   = if ($hasMembersText) { 0.4 } else { 1.0 }
    $dynBox.ToolTip   = if ($hasMembersText) {
        "Clear the Members field before entering a Dynamic Membership Rule  -  these settings are mutually exclusive."
    } else { $null }
})
$script:Window.FindName('TxtCreateDynamic').Add_TextChanged({
    $hasDynText = -not [string]::IsNullOrWhiteSpace($script:Window.FindName('TxtCreateDynamic').Text)
    $membersBox = $script:Window.FindName('TxtCreateMembers')
    $membersBox.IsEnabled = -not $hasDynText
    $membersBox.Opacity   = if ($hasDynText) { 0.4 } else { 1.0 }
    $membersBox.ToolTip   = if ($hasDynText) {
        "Clear the Dynamic Membership Rule field before adding initial members  -  these settings are mutually exclusive."
    } else { $null }
})


# ── CREATE GROUP  -  Validate & Preview ──
$script:Window.FindName('BtnCreateValidate').Add_Click({
    Hide-Notification
    $groupType  = $script:Window.FindName('CmbCreateType').SelectedItem.Content
    $dispName   = $script:Window.FindName('TxtCreateName').Text.Trim()
    $mailNick   = $script:Window.FindName('TxtCreateMailNick').Text.Trim()
    $desc       = $script:Window.FindName('TxtCreateDesc').Text.Trim()
    $ownerUpnsTxt = $script:Window.FindName('TxtCreateOwner').Text
    $membersTxt = $script:Window.FindName('TxtCreateMembers').Text
    $dynRule    = $script:Window.FindName('TxtCreateDynamic').Text.Trim()
    $isM365     = $groupType -like '*365*'

    # Read sensitivity label selection (M365 only)
    $selLabelItem = $script:Window.FindName('CmbSensitivityLabel').SelectedItem
    $script:CreateSensLabelId   = if ($selLabelItem -and $selLabelItem.Tag)     { $selLabelItem.Tag }     else { '' }
    $script:CreateSensLabelName = if ($selLabelItem -and $selLabelItem.Tag)     { $selLabelItem.Content } else { '' }

    if ([string]::IsNullOrWhiteSpace($dispName)) {
        Show-Notification "Display name is required." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    if ($isM365 -and [string]::IsNullOrWhiteSpace($mailNick)) {
        Show-Notification "Group email address (mail nickname) is required for M365 groups." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    # Capture all input into script-scope so the background worker and Done callback can access them
    $script:CreateParams = @{
        GroupType     = $groupType;  DispName    = $dispName;   MailNick   = $mailNick
        Desc          = $desc;       OwnerUpnsTxt = $ownerUpnsTxt;   MembersTxt = $membersTxt
        DynRule       = $dynRule;    IsM365      = $isM365
        SensLabelId   = $script:CreateSensLabelId
        SensLabelName = $script:CreateSensLabelName
        MailDomain    = $DevConfig.M365MailDomain
    }

    $btn = $script:Window.FindName('BtnCreateValidate')

    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Create Group: Validating ---" -Work {

        $p          = $script:CreateParams
        $dispName   = $p['DispName'];   $ownerUpnsTxt = $p['OwnerUpnsTxt']
        $membersTxt = $p['MembersTxt']; $dynRule      = $p['DynRule']
        $groupType  = $p['GroupType'];  $isM365       = $p['IsM365']
        $mailNick   = $p['MailNick'];   $mailDomain   = $p['MailDomain']

        Write-VerboseLog "Type: $groupType | Name: $dispName" -Level Info

        # Resolve owners (supports multiple UPNs, one per line)
        $ownerObjs     = [System.Collections.Generic.List[PSCustomObject]]::new()
        $invalidOwners = [System.Collections.Generic.List[string]]::new()
        $ownerEntries  = $ownerUpnsTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
        foreach ($upn in $ownerEntries) {
            $upn = $upn.Trim()
            Write-VerboseLog "Resolving owner: $upn" -Level Info
            try {
                $u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($upn))?`$select=id,displayName"
                $ownerObjs.Add([PSCustomObject]@{ Id = $u['id']; DisplayName = $u['displayName']; Type = 'User'; Found = $true; Original = $upn })
                Write-VerboseLog "Owner resolved: $($u['displayName']) ($($u['id']))" -Level Success
            } catch {
                $invalidOwners.Add($upn)
                Write-VerboseLog "Owner UPN not found: $upn" -Level Warning
            }
        }

        # Resolve members
        $validMembers   = [System.Collections.Generic.List[PSCustomObject]]::new()
        $invalidMembers = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace($membersTxt)) {
            $entries = $membersTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
            $valResult = Validate-InputList -Entries $entries
            foreach ($item in $valResult.Valid)   { $validMembers.Add([PSCustomObject]$item) }
            foreach ($item in $valResult.Invalid) { $invalidMembers.Add([string]$item) }
        }

        # M365: remove device members (not supported) and warn
        if ($isM365) {
            $deviceEntries = @($validMembers | Where-Object { $_.Type -eq 'Device' })
            foreach ($dev in $deviceEntries) {
                $validMembers.Remove($dev) | Out-Null
                $invalidMembers.Add("$($dev.DisplayName)  [skipped - devices are not supported as M365 group members]")
                Write-VerboseLog "Device skipped (M365): $($dev.DisplayName)" -Level Warning
            }
        }

        # Check display name uniqueness (warn only - duplicates are allowed)
        Write-VerboseLog "Checking for duplicate display name..." -Level Info
        $existingNameId = $null
        try {
            $safe_dispName = $dispName -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
            $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_dispName'`&`$select=id`&`$top=1"
            if ($resp['value'] -and $resp['value'].Count -gt 0) { $existingNameId = $resp['value'][0]['id'] }
        } catch {}
        if ($existingNameId) {
            Write-VerboseLog "WARNING: A group named '$dispName' already exists (ID: $existingNameId)" -Level Warning
        }

        # M365: check mail nickname uniqueness (hard block - email addresses must be unique)
        $emailConflictMsg = $null
        if ($isM365) {
            Write-VerboseLog "Checking mail nickname uniqueness..." -Level Info
            try {
                $nickResp = Invoke-MgGraphRequest -Method GET `
                    $safe_mailNick = $mailNick -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
                    -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=mailNickname eq '$safe_mailNick'`&`$select=id,displayName`&`$top=1"
                if ($nickResp['value'] -and $nickResp['value'].Count -gt 0) {
                    $conflict = $nickResp['value'][0]
                    $emailAddr = if ($mailDomain) { "$mailNick@$mailDomain" } else { $mailNick }
                    $emailConflictMsg = "Email address '$emailAddr' is already in use by group '$($conflict['displayName'])' (ID: $($conflict['id'])). Please choose a different mail nickname."
                    Write-VerboseLog "Email conflict: $emailConflictMsg" -Level Error
                } else {
                    Write-VerboseLog "Mail nickname is available." -Level Success
                }
            } catch {
                Write-VerboseLog "Could not verify mail nickname uniqueness: $($_.Exception.Message)" -Level Warning
            }
        }

        $script:CreateValidated = @{
            OwnerObjs       = $ownerObjs
            InvalidOwners   = $invalidOwners
            ValidMembers    = $validMembers
            InvalidMembers  = $invalidMembers
            ExistingNameId  = $existingNameId
            EmailConflict   = $emailConflictMsg
        }

    } -Done {

        $p    = $script:CreateParams
        $v    = $script:CreateValidated
        $ownerObjs      = @($v['OwnerObjs'])
        $invalidOwners  = @($v['InvalidOwners'])
        $validMembers   = $v['ValidMembers']
        $invalidMembers = $v['InvalidMembers']
        $existingNameId = $v['ExistingNameId']
        $emailConflict  = $v['EmailConflict']
        $dispName       = $p['DispName'];  $groupType    = $p['GroupType']
        $mailNick       = $p['MailNick'];  $mailDomain   = $p['MailDomain']
        $desc           = $p['Desc'];      $dynRule      = $p['DynRule']
        $isM365         = $p['IsM365'];    $sensLabelName = $p['SensLabelName']

        # Hard block on email conflict
        if ($emailConflict) {
            Show-Notification $emailConflict -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }

        if ($existingNameId) {
            Show-Notification "Warning: A group named '$dispName' already exists (ID: $existingNameId). Proceeding will create a duplicate display name." -BgColor '#FFF3CD' -FgColor '#7A4800'
        }

        $emailDisplay = if ($isM365 -and $mailDomain) { "$mailNick@$mailDomain" } elseif ($isM365) { $mailNick } else { '' }
        $extra = "Group type  : $groupType"
        if ($existingNameId) { $extra = "$([char]0x26A0) Duplicate name: A group named '$dispName' already exists (ID: $existingNameId).`n" + $extra }
        if ($emailDisplay)   { $extra += "`nEmail       : $emailDisplay" }
        if ($sensLabelName)  { $extra += "`nSens. label : $sensLabelName" }
        if ($dynRule)        { $extra += "`nDynamic rule: $dynRule" }
        if ($ownerObjs.Count -gt 0) {
            $ownerLines = ($ownerObjs | ForEach-Object { $_.DisplayName + ' (' + $_.Original + ')' }) -join ', '
            $extra += "`nOwners      : $ownerLines"
        }
        if ($invalidOwners.Count -gt 0) {
            $joined_invalidOwners = $invalidOwners -join ', '  # PS5-safe: extracted to avoid nested double-quotes in string
            $extra += "`nInvalid owner UPNs (will be skipped): $joined_invalidOwners"
        }

        $confirmed = Show-ConfirmationDialog `
            -Title "Create Group" `
            -OperationLabel "Create Group ($groupType)" `
            -TargetGroup $dispName -TargetGroupId "(new)" `
            -ValidObjects $validMembers -InvalidEntries $invalidMembers `
            -ExtraInfo $extra

        if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }

        $btn = $script:Window.FindName('BtnCreateValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Creating group: $dispName ---" -Work {

            $p        = $script:CreateParams
            $v        = $script:CreateValidated
            $dispName    = $p['DispName'];   $mailNick    = $p['MailNick']
            $desc        = $p['Desc'];       $dynRule     = $p['DynRule']
            $isM365      = $p['IsM365'];     $sensLabelId = $p['SensLabelId']
            $ownerObjs   = @($v['OwnerObjs']); $validMembers = $v['ValidMembers']

            $body = @{ displayName = $dispName; securityEnabled = $true; mailEnabled = $isM365 }
            if ($isM365) {
                $body.groupTypes   = @('Unified')
                $body.mailNickname = $mailNick
            } else {
                $body.groupTypes   = @()
                $body.mailNickname = ($dispName -replace '[^a-zA-Z0-9]','') + (Get-Random -Minimum 100 -Maximum 999)
            }
            if ($desc) { $body.description = $desc }
            if ($dynRule) {
                $body.membershipRule = $dynRule
                $body.membershipRuleProcessingState = 'On'
                # M365 dynamic requires both Unified AND DynamicMembership in groupTypes
                $body.groupTypes = if ($isM365) { @('Unified','DynamicMembership') } else { @('DynamicMembership') }
            }
            if ($isM365 -and -not [string]::IsNullOrWhiteSpace($sensLabelId)) {
                $body.assignedLabels = @(@{ labelId = $sensLabelId })
                Write-VerboseLog "Sensitivity label will be applied: $($p['SensLabelName']) ($sensLabelId)" -Level Info
            }

            $newGroup = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups" `
                -Body ($body | ConvertTo-Json -Depth 5) -ContentType "application/json"
            Write-VerboseLog "Group created: $($newGroup['displayName']) ($($newGroup['id']))" -Level Success

            if ($ownerObjs.Count -gt 0) {
                Start-Sleep -Seconds 2
                foreach ($ownerObj in $ownerObjs) {
                    try {
                        $ob = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/$($ownerObj.Id)" }
                        Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$($newGroup['id'])/owners/`$ref" `
                            -Body ($ob | ConvertTo-Json) -ContentType "application/json"
                        Write-VerboseLog "Owner set: $($ownerObj.DisplayName)" -Level Success
                     } catch {
                         $errMsg = $_.Exception.Message
                         if ($errMsg -match '400' -or $errMsg -match 'BadRequest' -or $errMsg -match 'already') {
                             Write-VerboseLog "Skipped owner '$($ownerObj.DisplayName)' - already an owner (auto-added as creator)" -Level Warning
                         } else {
                             Write-VerboseLog "Failed to set owner '$($ownerObj.DisplayName)': $errMsg" -Level Error
                         }
                     }
                }
            }

            if ($validMembers.Count -gt 0) {
                Start-Sleep -Seconds 2
                $memResult = Add-MembersToGroup -GroupId $newGroup['id'] -Members $validMembers
                Write-VerboseLog "Members added: $($memResult['Ok']) succeeded, $($memResult['Fail']) failed, $($memResult['Skipped']) skipped" -Level Info
            }

            $script:CreateNewGroupId = $newGroup['id']

        } -Done {
            $dispName = $script:CreateParams['DispName']
            Show-Notification "Group '$dispName' created successfully (ID: $($script:CreateNewGroupId))" -BgColor '#D4EDDA' -FgColor '#155724'
        }
    }
})


# ── ADD / REMOVE MEMBERS  -  Validate & Preview ──
$script:Window.FindName('BtnMemberValidate').Add_Click({
    Hide-Notification
    if ($null -eq $script:SelectedGroup) {
        Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $membersTxt = $script:Window.FindName('TxtMemberList').Text
    if ([string]::IsNullOrWhiteSpace($membersTxt)) {
        Show-Notification "Please enter at least one member entry." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    $isAdd  = ($script:CurrentMemberOp -eq 'Add')
    $opLabel = if ($isAdd) { "Add Members" } else { "Remove Members" }
    $script:MemberParams = @{ MembersTxt = $membersTxt; OpLabel = $opLabel; IsAdd = $isAdd }
    $btn = $script:Window.FindName('BtnMemberValidate')

    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- ${opLabel}: Validating ---" -Work {

        $entries = $script:MemberParams['MembersTxt'] -split "`n" | Where-Object { $_.Trim() -ne '' }
        Write-VerboseLog "Target group: $($script:SelectedGroup.DisplayName) ($($script:SelectedGroup.Id))" -Level Info
        $valResult = Validate-InputList -Entries $entries
        $script:MemberValidated = @{ Valid = $valResult.Valid; Invalid = $valResult.Invalid }

    } -Done {

        $valid   = $script:MemberValidated['Valid']
        $invalid = $script:MemberValidated['Invalid']
        $opLabel = $script:MemberParams['OpLabel']

        if ($valid.Count -eq 0) {
            Write-VerboseLog "No valid entries found. Cannot proceed." -Level Error
            Show-Notification "No valid Entra objects found. Correct entries and try again." -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }
        if ($invalid.Count -gt 0) { Write-VerboseLog "$($invalid.Count) invalid entries will be skipped." -Level Warning }

        $confirmed = Show-ConfirmationDialog `
            -Title $opLabel -OperationLabel $opLabel `
            -TargetGroup $script:SelectedGroup.DisplayName `
            -TargetGroupId $script:SelectedGroup.Id `
            -ValidObjects $valid -InvalidEntries $invalid

        if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }

        $btn = $script:Window.FindName('BtnMemberValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Executing: $opLabel ---" -Work {

            $valid   = $script:MemberValidated['Valid']
            $opLabel = $script:MemberParams['OpLabel']
            $isAdd   = $script:MemberParams['IsAdd']
            Write-VerboseLog "Operation: $(if ($isAdd) { 'ADD' } else { 'REMOVE' })  -  Group: $($script:SelectedGroup.DisplayName)" -Level Action
            if ($isAdd) {
                $memResult = Add-MembersToGroup -GroupId $script:SelectedGroup.Id -Members $valid
                $ok = $memResult['Ok']; $fail = $memResult['Fail']; $skipped = $memResult['Skipped']
            } else {
                $memResult = Remove-MembersFromGroup -GroupId $script:SelectedGroup.Id -Members $valid
                $ok = $memResult['Ok']; $fail = $memResult['Fail']; $skipped = 0
            }
            $script:MemberExecResult = @{ Ok = $ok; Fail = $fail; Skipped = $skipped }

        } -Done {
            $ok      = [int]$script:MemberExecResult['Ok']
            $fail    = [int]$script:MemberExecResult['Fail']
            $skipped = [int]$script:MemberExecResult['Skipped']
            $opLabel = $script:MemberParams['OpLabel']
            $msg = "$opLabel complete. Succeeded: $ok | Skipped (already member): $skipped | Failed: $fail"
            $vLevel  = if ($fail -gt 0) { 'Warning' } else { 'Success' }
            $bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
            $fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
            Write-VerboseLog $msg -Level $vLevel
            Show-Notification $msg -BgColor $bgColor -FgColor $fgColor
        }
    }
})


# ── EXPORT MEMBERS ──
$script:Window.FindName('BtnRunExport').Add_Click({
    Hide-Notification
    if ($null -eq $script:ExportSelectedGroup) {
        Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $outPath = $script:Window.FindName('TxtExportPath').Text.Trim()
    if ([string]::IsNullOrWhiteSpace($outPath)) {
        Show-Notification "Please specify an output XLSX path." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:ExportOutPath = $outPath
    $btn = $script:Window.FindName('BtnRunExport')

    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Export: $($script:ExportSelectedGroup.DisplayName) ---" -Work {
        $count = Export-GroupMembersToXlsx -GroupId $script:ExportSelectedGroup.Id `
            -GroupName $script:ExportSelectedGroup.DisplayName -OutputPath $script:ExportOutPath
        $script:ExportCount = $count
    } -Done {
        $msg = "Exported $($script:ExportCount) members to: $($script:ExportOutPath)"
        Write-VerboseLog $msg -Level Success
        Show-Notification $msg -BgColor '#D4EDDA' -FgColor '#155724'
    }
})


# ── SET DYNAMIC QUERY ──
$script:Window.FindName('BtnDynValidate').Add_Click({
    Hide-Notification
    if ($null -eq $script:DynSelectedGroup) {
        Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $rule = $script:Window.FindName('TxtDynRule').Text.Trim()
    if ([string]::IsNullOrWhiteSpace($rule)) {
        Show-Notification "Please enter a membership rule." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:DynRule = $rule
    $btn = $script:Window.FindName('BtnDynValidate')

    Invoke-OnBackground -DisableButton $btn -BusyMessage "Validating group state and rule syntax..." -Work {
        $rule    = $script:DynRule
        $groupId = $script:DynSelectedGroup.Id
        Write-VerboseLog "Group: $($script:DynSelectedGroup.DisplayName) ($groupId)" -Level Info
        Write-VerboseLog "Rule : $rule" -Level Info

        # ── Re-fetch group to get current groupTypes (avoids stale cached data) ──
        $freshGroupTypes = $null
        try {
            $fg = Invoke-MgGraphRequest -Method GET `
                -Uri "https://graph.microsoft.com/v1.0/groups/$groupId`?`$select=id,groupTypes"
            $freshGroupTypes = @($fg['groupTypes'])
        } catch {
            Write-VerboseLog "Could not re-fetch group type: $($_.Exception.Message) - using cached value." -Level Warning
            $freshGroupTypes = @($script:DynSelectedGroup.GroupTypes)
        }

        $isDynamic   = $freshGroupTypes -contains 'DynamicMembership'
        $memberCount = -1

        if ($isDynamic) {
            # Hard block - already dynamic; no further checks needed
            Write-VerboseLog "BLOCKED: Group is already a dynamic group (groupTypes: $($freshGroupTypes -join ', '))." -Level Warning
        } else {
            # Static group - check member count before allowing conversion
            try {
                $cntResp = Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/groups/$groupId/members/`$count" `
                    -Headers @{ ConsistencyLevel = 'eventual' }
                $memberCount = [int]$cntResp
                Write-VerboseLog "Member count: $memberCount" -Level Info
            } catch {
                Write-VerboseLog "Could not get count via `$count endpoint: $($_.Exception.Message)" -Level Warning
            }
            # Fallback: probe for at least 1 member if primary count endpoint failed
            if ($memberCount -eq -1) {
                try {
                    $mResp = Invoke-MgGraphRequest -Method GET `
                        -Uri "https://graph.microsoft.com/v1.0/groups/$groupId/members?`$select=id`&`$top=1"
                    if ($mResp['value'] -and $mResp['value'].Count -gt 0) {
                        $memberCount = 1
                    } else {
                        $memberCount = 0
                    }
                    Write-VerboseLog "Member count (fallback probe): $memberCount" -Level Info
                } catch {
                    Write-VerboseLog "Could not verify member count - will proceed with warning." -Level Warning
                }
            }

            if ($memberCount -gt 0) {
                Write-VerboseLog "BLOCKED: Static group has $memberCount active member(s) - conversion not allowed." -Level Warning
            } else {
                # Group is eligible - validate rule syntax
                try {
                    $valBody = @{ ruleExpression = $rule }
                    Invoke-MgGraphRequest -Method POST `
                        -Uri "https://graph.microsoft.com/v1.0/groups/validateProperties" `
                        -Body ($valBody | ConvertTo-Json) -ContentType "application/json"
                    Write-VerboseLog "Rule syntax: valid" -Level Success
                } catch {
                    Write-VerboseLog "Rule syntax warning: $($_.Exception.Message) (proceeding)" -Level Warning
                }
            }
        }

        $shared['DynGroupState'] = @{ IsDynamic = $isDynamic; MemberCount = $memberCount }
    } -Done {
        $state = $script:DynGroupState
        if ($null -eq $state) {
            Show-Notification "Validation did not complete - please try again." -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }

        # ── Hard block: group is already dynamic ──
        if ($state.IsDynamic) {
            Show-Notification "Cannot set dynamic rule: '$($script:DynSelectedGroup.DisplayName)' is already a dynamic group. To modify its existing membership rule, edit the group directly in the Entra admin portal." `
                -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }

        # ── Hard block: static group has active members ──
        if ($state.MemberCount -gt 0) {
            if ($state.MemberCount -eq 1) {
                $countLabel = "1 active member"
            } else {
                $countLabel = "$($state.MemberCount) active members"
            }
            Show-Notification "Cannot convert '$($script:DynSelectedGroup.DisplayName)' to dynamic membership: the group currently has $countLabel. Converting to dynamic would remove all existing members. This operation is only permitted on static groups with no members." `
                -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }

        # ── Soft warning: member count could not be verified ──
        if ($state.MemberCount -eq -1) {
            Show-Notification "Warning: could not verify the member count for '$($script:DynSelectedGroup.DisplayName)'. Ensure the group has no members before applying the rule." `
                -BgColor '#FFF3CD' -FgColor '#7A4800'
        }

        # ── Group passes all checks (static, 0 members) - show confirmation ──
        $rule = $script:DynRule
        $emptyValid   = [System.Collections.Generic.List[PSCustomObject]]::new()
        $emptyInvalid = [System.Collections.Generic.List[string]]::new()

        $confirmed = Show-ConfirmationDialog `
            -Title "Set Dynamic Membership Rule" -OperationLabel "Set Dynamic Membership Rule" `
            -TargetGroup $script:DynSelectedGroup.DisplayName `
            -TargetGroupId $script:DynSelectedGroup.Id `
            -ValidObjects $emptyValid -InvalidEntries $emptyInvalid `
            -ExtraInfo "New rule: $rule`n`nWARNING: This will convert the group to dynamic membership.`nAll static members will be removed."

        if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }

        $btn = $script:Window.FindName('BtnDynValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Applying dynamic rule ---" -Work {
            $body = @{
                membershipRule                = $script:DynRule
                membershipRuleProcessingState = "On"
                groupTypes                    = @("DynamicMembership")
            }
            Invoke-MgGraphRequest -Method PATCH `
                -Uri "https://graph.microsoft.com/v1.0/groups/$($script:DynSelectedGroup.Id)" `
                -Body ($body | ConvertTo-Json -Depth 3) -ContentType "application/json"
            Write-VerboseLog "Dynamic rule applied successfully." -Level Success
        } -Done {
            Show-Notification "Dynamic membership rule applied to '$($script:DynSelectedGroup.DisplayName)'" -BgColor '#D4EDDA' -FgColor '#155724'
        }
    }
})


# ── SEARCH ENTRA OBJECTS ──
$script:Window.FindName('BtnEntraSearch').Add_Click({
    Hide-Notification
    $kw = $script:Window.FindName('TxtSearchKeyword').Text.Trim()
    if ([string]::IsNullOrWhiteSpace($kw)) {
        Show-Notification "Please enter a search keyword." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $inclUsers   = $script:Window.FindName('ChkSrchUsers').IsChecked
    $inclDevices = $script:Window.FindName('ChkSrchDevices').IsChecked
    $inclSG      = $script:Window.FindName('ChkSrchSG').IsChecked
    $inclM365    = $script:Window.FindName('ChkSrchM365').IsChecked
    $script:SearchExactMatch = [bool]$script:Window.FindName('ChkSrchExactMatch').IsChecked
    $anyChecked  = $inclUsers -or $inclDevices -or $inclSG -or $inclM365
    $script:SearchKeyword = $kw
    $script:SearchTypes   = @{
        Users   = if ($anyChecked) { [bool]$inclUsers   } else { $true }
        Devices = if ($anyChecked) { [bool]$inclDevices } else { $true }
        SG      = if ($anyChecked) { [bool]$inclSG      } else { $true }
        M365    = if ($anyChecked) { [bool]$inclM365    } else { $true }
    }
    $script:Window.FindName('PnlSearchResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
    $btn = $script:Window.FindName('BtnEntraSearch')
    Invoke-OnBackground -DisableButton $btn -BusyMessage "Searching Entra objects..." -Work {
        $kw    = $script:SearchKeyword
        $types = $script:SearchTypes
        $hdrs  = @{ ConsistencyLevel = 'eventual' }
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
        Write-VerboseLog "Search keyword: '$kw'" -Level Info
        # Users - $search contains match, full pagination via @odata.nextLink
        if ($types['Users']) {
            try {
                $uri = "https://graph.microsoft.com/v1.0/users?`$search=`"displayName:$kw`" OR `"userPrincipalName:$kw`"`&`$select=id,displayName,userPrincipalName,mail`&`$count=true`&`$top=999"
                $userCount = 0
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
                    foreach ($u in @($resp['value'])) {
                        $det = if ($u['userPrincipalName']) { [string]$u['userPrincipalName'] } elseif ($u['mail']) { [string]$u['mail'] } else { '' }
                        $results.Add([PSCustomObject]@{ DisplayName = [string]$u['displayName']; Detail = $det; ObjectType = 'User'; ObjectId = [string]$u['id'] })
                    }
                    $userCount += @($resp['value']).Count
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
                Write-VerboseLog "Users found: $userCount" -Level Info
            } catch { Write-VerboseLog "User search error: $($_.Exception.Message)" -Level Warning }
        }
        # Devices - $search contains match, full pagination
        if ($types['Devices']) {
            try {
                $uri = "https://graph.microsoft.com/v1.0/devices?`$search=`"displayName:$kw`"`&`$select=id,displayName,operatingSystem,operatingSystemVersion,deviceId`&`$count=true`&`$top=999"
                $devCount = 0
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
                    foreach ($d in @($resp['value'])) {
                        $det = [string]$d['deviceId']
                        $results.Add([PSCustomObject]@{ DisplayName = [string]$d['displayName']; Detail = $det; ObjectType = 'Device'; ObjectId = [string]$d['id'] })
                    }
                    $devCount += @($resp['value']).Count
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
                Write-VerboseLog "Devices found: $devCount" -Level Info
            } catch { Write-VerboseLog "Device search error: $($_.Exception.Message)" -Level Warning }
        }
        # Groups - $search contains match, full pagination, split client-side into SG / M365
        if ($types['SG'] -or $types['M365']) {
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups?`$search=`"displayName:$kw`"`&`$select=id,displayName,groupTypes,mail`&`$count=true`&`$top=999"
                $grpCount = 0
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
                    foreach ($g in @($resp['value'])) {
                        $gTypes = @($g['groupTypes'])
                        $isM365 = $gTypes -contains 'Unified'
                        $isDyn  = $gTypes -contains 'DynamicMembership'
                        if ($isM365 -and $types['M365']) {
                            $results.Add([PSCustomObject]@{ DisplayName = [string]$g['displayName']; Detail = [string]$g['id']; ObjectType = 'M365 Group'; ObjectId = [string]$g['id'] })
                        } elseif (-not $isM365 -and $types['SG']) {
                            $tl = if ($isDyn) { 'Security Group (Dynamic)' } else { 'Security Group' }
                            $results.Add([PSCustomObject]@{ DisplayName = [string]$g['displayName']; Detail = [string]$g['id']; ObjectType = $tl; ObjectId = [string]$g['id'] })
                        }
                    }
                    $grpCount += @($resp['value']).Count
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
                Write-VerboseLog "Groups found: $grpCount" -Level Info
            } catch { Write-VerboseLog "Group search error: $($_.Exception.Message)" -Level Warning }
        }
         # Client-side exact-match filter when checkbox is checked
         if ($script:SearchExactMatch) {
             $kw = $script:SearchKeyword
             $results = [System.Collections.Generic.List[PSCustomObject]]($results | Where-Object { $_.DisplayName -ieq $kw })
         }
        $shared['SearchResults'] = $results
    } -Done {
        $results = $script:SearchResults
        if ($null -eq $results) {
            Show-Notification "Search did not complete - please try again." -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }
        if ($results.Count -eq 0) {
            $script:Window.FindName('TxtSearchNoResults').Visibility = 'Visible'
            $script:Window.FindName('PnlSearchResults').Visibility   = 'Collapsed'
            return
        }
        $dg = $script:Window.FindName('DgSearchResults')
        $dg.ItemsSource = $results
        $dg.UnselectAll()
        $n = $results.Count
        $script:Window.FindName('TxtSearchCount').Text = "$n result$(if ($n -ne 1) { 's' } else { '' })"
        $script:Window.FindName('BtnSearchCopyValue').IsEnabled  = $false
        $script:Window.FindName('BtnSearchCopyRow').IsEnabled    = $false
        $script:Window.FindName('BtnSearchCopyAll').IsEnabled    = $true
        $script:Window.FindName('TxtSearchFilter').IsEnabled  = $true
        $script:Window.FindName('BtnSearchFilter').IsEnabled  = $true
        $script:Window.FindName('BtnSearchFilterClear').IsEnabled = $true
        $script:Window.FindName('BtnSearchExportXlsx').IsEnabled = $true
        $script:Window.FindName('PnlSearchResults').Visibility   = 'Visible'
        $script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
        Write-VerboseLog "Search returned $n result(s)." -Level Success
    }
})

# DgSearchResults: update Copy Value / Copy Row buttons on CELL selection change
# Note: SelectionUnit="CellOrRowHeader" allows cell selection by default; Copy Row switches to full-row selection
$script:Window.FindName('DgSearchResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgSearchResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnSearchCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnSearchCopyRow').IsEnabled   = ($selCells -gt 0)
})

# BtnSearchCopyValue: copy exact value of the single selected cell
# ── BtnSearchFilter ──
$script:Window.FindName('BtnSearchFilter').Add_Click({
    $dg      = $script:Window.FindName('DgSearchResults')
    $keyword = $script:Window.FindName('TxtSearchFilter').Text.Trim()
    if ($null -eq $script:SearchAllData) {
        $script:SearchAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:SearchAllData
        Show-Notification "Filter cleared - $($script:SearchAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:SearchAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnSearchFilterClear ──
$script:Window.FindName('BtnSearchFilterClear').Add_Click({
    $script:Window.FindName('TxtSearchFilter').Text = ''
    $dg = $script:Window.FindName('DgSearchResults')
    if ($null -ne $script:SearchAllData) {
        $dg.ItemsSource = $script:SearchAllData
        Show-Notification "Filter cleared - $($script:SearchAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:SearchAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "Search: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnSearchCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgSearchResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "Copied cell value to clipboard." -Level Success
})

# BtnSearchCopyRow: copy full row(s) with headers for every row containing a selected cell
$script:Window.FindName('BtnSearchCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgSearchResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add("Object Name`tDetails`tObject Type")
    foreach ($row in $rows) { $lines.Add("$($row.DisplayName)`t$($row.Detail)`t$($row.ObjectType)") }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "Copied $($rows.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

# BtnSearchCopyAll: copy all result rows with headers
$script:Window.FindName('BtnSearchCopyAll').Add_Click({
    $dg = $script:Window.FindName('DgSearchResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add("Object Name`tDetails`tObject Type")
    foreach ($row in $allItems) { $lines.Add("$($row.DisplayName)`t$($row.Detail)`t$($row.ObjectType)") }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "Copied all $($allItems.Count) result row(s) to clipboard." -Level Success
})

# BtnSearchExportXlsx: export all search results via SaveFileDialog
$script:Window.FindName('BtnSearchExportXlsx').Add_Click({
    $dg = $script:Window.FindName('DgSearchResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title    = "Export Search Results"
    $dlg.Filter   = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
    $dlg.FileName = "EntraSearch_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
    $dlg.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | Select-Object DisplayName, Detail, ObjectType
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName "SearchResults" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found - saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "Exported $($allItems.Count) search results to: $outPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "Search export error: $($_.Exception.Message)" -Level Error
    }
})



# ════════════════════════════════════════════════════════════════════════════════
# LIST GROUP MEMBERS — helper functions and handlers
# ════════════════════════════════════════════════════════════════════════════════

function Set-LMColumns {
    param([string]$Schema, [System.Windows.Controls.DataGrid]$DataGrid)
    $DataGrid.Columns.Clear()
    $colDefs = switch ($Schema) {
        'Users'   { @(
            @{H='Object Name';         B='ObjectName'},
            @{H='Object ID';           B='ObjectID'},
            @{H='Object Type';         B='ObjectType'},
            @{H='UPN';                 B='UPN'},
            @{H='Member';           B='MemberOf'}
        )}
        'Devices' { @(
            @{H='Device Name';         B='ObjectName'},
            @{H='Device ObjectID';     B='DeviceObjectID'},
            @{H='Entra Device ID';     B='EntraDeviceID'},
            @{H='OS Platform';         B='OSPlatform'},
            @{H='OS Version';          B='OSVersion'},
            @{H='Ownership Type';      B='OwnershipType'},
            @{H='Registered User UPN'; B='RegisteredUserUPN'},
            @{H='Member';           B='MemberOf'}
        )}
        'Groups'  { @(
            @{H='Group Name';      B='ObjectName'},
            @{H='Group Type';      B='GroupType'},
            @{H='Group ID';        B='GroupID'},
            @{H='Membership Type'; B='MembershipType'},
            @{H='Member';       B='MemberOf'}
        )}
        default   { @(
            @{H='Object Name'; B='ObjectName'},
            @{H='Object Type'; B='ObjectType'},
            @{H='Object ID';   B='ObjectID'},
            @{H='Member';   B='MemberOf'}
        )}
    }
    foreach ($def in $colDefs) {
        $col            = [System.Windows.Controls.DataGridTextColumn]::new()
        $col.Header     = $def['H']
        $col.Binding    = [System.Windows.Data.Binding]::new($def['B'])
        $col.IsReadOnly = $true
        $col.Width      = [System.Windows.Controls.DataGridLength]::new(
            1, [System.Windows.Controls.DataGridLengthUnitType]::Star)
        $DataGrid.Columns.Add($col)
    }
}

function Apply-LMFilter {
    if (-not $script:LMAllResults -or $script:LMAllResults.Count -eq 0) { return }
    $showUsers   = $script:Window.FindName('ChkLMUsers').IsChecked
    $showDevices = $script:Window.FindName('ChkLMDevices').IsChecked
    $showGroups  = $script:Window.FindName('ChkLMGroups').IsChecked
    $allUnchecked = (-not $showUsers) -and (-not $showDevices) -and (-not $showGroups)
    $filtered = @($script:LMAllResults | Where-Object {
        $allUnchecked -or
        ($showUsers   -and $_.ObjectType -eq 'User')   -or
        ($showDevices -and $_.ObjectType -eq 'Device') -or
        ($showGroups  -and $_.ObjectType -eq 'Group')
    })
    $dg = $script:Window.FindName('DgLMResults')
    if ($filtered.Count -eq 0) {
        $dg.ItemsSource = $null
        $script:Window.FindName('PnlLMResults').Visibility   = 'Collapsed'
        $script:Window.FindName('TxtLMNoResults').Visibility = 'Visible'
        return
    }
    $types  = @($filtered | Select-Object -ExpandProperty ObjectType -Unique)
    $schema = if ($types.Count -eq 1) {
        switch ($types[0]) { 'User'{'Users'} 'Device'{'Devices'} 'Group'{'Groups'} default{'Mixed'} }
    } else { 'Mixed' }
    Set-LMColumns -Schema $schema -DataGrid $dg
    $dg.ItemsSource = $filtered
    $script:Window.FindName('TxtLMCount').Text           = "$($filtered.Count) member(s) $([char]0x2014) schema: $($schema)"
    $script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlLMResults').Visibility   = 'Visible'
    $script:Window.FindName('BtnLMCopyAll').IsEnabled    = $true
    $script:Window.FindName('TxtLMFilter').IsEnabled  = $true
    $script:Window.FindName('BtnLMFilter').IsEnabled  = $true
    $script:Window.FindName('BtnLMFilterClear').IsEnabled = $true
    $script:Window.FindName('BtnLMExportXlsx').IsEnabled = $true
    $script:Window.FindName('BtnLMCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnLMCopyRow').IsEnabled    = $false
}

# ── BtnLMQuery: List Group Members ──────────────────────────────────────────
$script:Window.FindName('BtnLMQuery').Add_Click({
    Hide-Notification
    $groupTxt = $script:Window.FindName('TxtLMGroupInput').Text
    if ([string]::IsNullOrWhiteSpace($groupTxt)) {
        Show-Notification "Please enter at least one group name or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:LMGroupInput = $groupTxt
    $script:LMAllResults = $null
    $script:Window.FindName('PnlLMResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlLMProgress').Visibility  = 'Visible'
    $script:Window.FindName('PbLMProgress').IsIndeterminate = $true
    $script:Window.FindName('TxtLMProgressMsg').Text    = 'Querying groups...'
    $script:Window.FindName('TxtLMProgressDetail').Text = ''

    # Start progress update timer (reads SharedBg written by background runspace)
    if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop() }
    $script:LMProgressTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:LMProgressTimer.Interval = [TimeSpan]::FromMilliseconds(400)
    $script:LMProgressTimer.Add_Tick({
        $prog = if ($script:SharedBg) { $script:SharedBg['LMProgress'] } else { $null }
        if ($prog) {
            $script:Window.FindName('TxtLMProgressMsg').Text    = [string]$prog['Message']
            $script:Window.FindName('TxtLMProgressDetail').Text = [string]$prog['Detail']
        }
    })
    $script:LMProgressTimer.Start()

    $btn = $script:Window.FindName('BtnLMQuery')
    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- List Group Members: Querying ---" -Work {

        $script:LMGroupInput = $Shared['LMGroupInput']
        $groupEntries = $script:LMGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
        $totalGroups  = $groupEntries.Count
        $allResults   = [System.Collections.Generic.List[PSCustomObject]]::new()

        $Shared['LMProgress'] = @{ Message = "Starting..."; Detail = "0 groups queued" }

        $gi = 0
        foreach ($entry in $groupEntries) {
            $gi++
            $Shared['LMProgress'] = @{ Message = "Resolving group $gi of $($totalGroups): $entry"; Detail = "$($allResults.Count) members so far" }

            # Resolve: GUID → direct GET, otherwise search by displayName
            $groupId   = $null
            $groupName = $entry
            if ($entry -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
                $groupId = $entry
                try {
                    $gInfo     = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$groupId?`$select=displayName"
                    $groupName = [string]$gInfo['displayName']
                } catch { Write-VerboseLog "Could not fetch display name for ID $entry" -Level Warning }
            } else {
                try {
                    $enc   = [Uri]::EscapeDataString($entry)
                    $safe_entry = $entry -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
                    $gResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_entry'`&`$select=id,displayName`&`$top=1"
                    if ($gResp['value'] -and $gResp['value'].Count -gt 0) {
                        $groupId   = [string]$gResp['value'][0]['id']
                        $groupName = [string]$gResp['value'][0]['displayName']
                    } else {
                        Write-VerboseLog "Group not found: $entry" -Level Warning
                        continue
                    }
                } catch { Write-VerboseLog "Error resolving group '$entry': $($_.Exception.Message)" -Level Error; continue }
            }
            Write-VerboseLog ('Querying members of: ' + $groupName + ' (' + $groupId + ')') -Level Info
            $Shared['LMProgress'] = @{ Message = "Querying group $gi of $($totalGroups): $groupName"; Detail = "$($allResults.Count) members so far" }

            # ── Users ──────────────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.user?`$select=id,displayName,userPrincipalName`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($u in @($resp['value'])) {
                        $allResults.Add([PSCustomObject]@{
                            ObjectName        = [string]$u['userPrincipalName']
                            ObjectType        = 'User'
                            ObjectID          = [string]$u['id']
                            MemberOf          = $groupName
                            UPN               = [string]$u['userPrincipalName']
                            DeviceObjectID    = ''
                            EntraDeviceID     = ''
                            OSPlatform        = ''
                            OSVersion         = ''
                            OwnershipType     = ''
                            RegisteredUserUPN = ''
                            GroupType         = ''
                            GroupID           = ''
                            MembershipType    = ''
                        })
                    }
                    $Shared['LMProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName  (users)"; Detail = "$($allResults.Count) members so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "Error querying users from '$groupName': $($_.Exception.Message)" -Level Warning }

            # ── Devices ────────────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.device?`$select=id,displayName,deviceId,operatingSystem,operatingSystemVersion,deviceOwnership`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($d in @($resp['value'])) {
                        $allResults.Add([PSCustomObject]@{
                            ObjectName        = [string]$d['displayName']
                            ObjectType        = 'Device'
                            ObjectID          = [string]$d['id']
                            MemberOf          = $groupName
                            UPN               = ''
                            DeviceObjectID    = [string]$d['id']
                            EntraDeviceID     = [string]$d['deviceId']
                            OSPlatform        = [string]$d['operatingSystem']
                            OSVersion         = [string]$d['operatingSystemVersion']
                            OwnershipType     = [string]$d['deviceOwnership']
                            RegisteredUserUPN = ''
                            GroupType         = ''
                            GroupID           = ''
                            MembershipType    = ''
                        })
                    }
                    $Shared['LMProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName  (devices)"; Detail = "$($allResults.Count) members so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "Error querying devices from '$groupName': $($_.Exception.Message)" -Level Warning }

            # ── Nested groups ──────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.group?`$select=id,displayName,groupTypes,membershipRuleProcessingState`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($g in @($resp['value'])) {
                        $gt = if ($g['groupTypes'] -and ($g['groupTypes'] -contains 'Unified')) { 'Microsoft 365' } else { 'Security' }
                        $mt = if ($g['membershipRuleProcessingState'] -eq 'On') { 'Dynamic' } else { 'Static' }
                        $allResults.Add([PSCustomObject]@{
                            ObjectName        = [string]$g['displayName']
                            ObjectType        = 'Group'
                            ObjectID          = [string]$g['id']
                            MemberOf          = $groupName
                            UPN               = ''
                            DeviceObjectID    = ''
                            EntraDeviceID     = ''
                            OSPlatform        = ''
                            OSVersion         = ''
                            OwnershipType     = ''
                            RegisteredUserUPN = ''
                            GroupType         = $gt
                            GroupID           = [string]$g['id']
                            MembershipType    = $mt
                        })
                    }
                    $Shared['LMProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName  (nested groups)"; Detail = "$($allResults.Count) members so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "Error querying nested groups from '$groupName': $($_.Exception.Message)" -Level Warning }
        }

        # If result set is devices-only → fetch RegisteredUser UPN per device
        $deviceRows    = @($allResults | Where-Object { $_.ObjectType -eq 'Device' })
        $nonDeviceRows = @($allResults | Where-Object { $_.ObjectType -ne 'Device' })
        if ($nonDeviceRows.Count -eq 0 -and $deviceRows.Count -gt 0) {
            Write-VerboseLog "Devices-only result set — fetching registered user UPNs..." -Level Info
            $di = 0
            foreach ($dev in $deviceRows) {
                $di++
                $Shared['LMProgress'] = @{ Message = ('Fetching registered users (' + $di + ' / ' + $deviceRows.Count + ')...'); Detail = ([string]$deviceRows.Count + ' devices total') }
                try {
                    $rResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$($dev.DeviceObjectID)/registeredUsers?`$select=userPrincipalName`&`$top=1"
                    if ($rResp['value'] -and $rResp['value'].Count -gt 0) {
                        $dev.RegisteredUserUPN = [string]$rResp['value'][0]['userPrincipalName']
                    }
                } catch { Write-VerboseLog "Could not get registered user for $($dev.ObjectName): $($_.Exception.Message)" -Level Warning }
            }
        }

        $Shared['LMProgress'] = @{ Message = "Processing complete."; Detail = "$($allResults.Count) member(s) collected" }
        Write-VerboseLog "LM query complete: $($allResults.Count) member(s) from $gi group(s)" -Level Success
        $script:LMAllResults = $allResults

    } -Done {
        if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop() }
        $script:Window.FindName('PnlLMProgress').Visibility = 'Collapsed'

        if (-not $script:LMAllResults -or $script:LMAllResults.Count -eq 0) {
            $script:Window.FindName('PnlLMResults').Visibility   = 'Collapsed'
            $script:Window.FindName('TxtLMNoResults').Visibility = 'Visible'
            Show-Notification "No members found for the specified group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
            return
        }
        Apply-LMFilter
    }
})

# ── DgLMResults: cell selection enables Copy Value / Copy Row ────────────────
$script:Window.FindName('DgLMResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgLMResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnLMCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnLMCopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnLMCopyValue ───────────────────────────────────────────────────────────
# ── BtnLMFilter ──
$script:Window.FindName('BtnLMFilter').Add_Click({
    $dg      = $script:Window.FindName('DgLMResults')
    $keyword = $script:Window.FindName('TxtLMFilter').Text.Trim()
    if ($null -eq $script:LMAllData) {
        $script:LMAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:LMAllData
        Show-Notification "Filter cleared - $($script:LMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:LMAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnLMFilterClear ──
$script:Window.FindName('BtnLMFilterClear').Add_Click({
    $script:Window.FindName('TxtLMFilter').Text = ''
    $dg = $script:Window.FindName('DgLMResults')
    if ($null -ne $script:LMAllData) {
        $dg.ItemsSource = $script:LMAllData
        Show-Notification "Filter cleared - $($script:LMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:LMAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "LM: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnLMCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgLMResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "LM: Copied cell value to clipboard." -Level Success
})

# ── BtnLMCopyRow ─────────────────────────────────────────────────────────────
$script:Window.FindName('BtnLMCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgLMResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $rows) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "LM: Copied $($rows.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnLMCopyAll ─────────────────────────────────────────────────────────────
$script:Window.FindName('BtnLMCopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgLMResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "LM: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnLMExportXlsx ──────────────────────────────────────────────────────────
$script:Window.FindName('BtnLMExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgLMResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title    = "Export Group Members"
    $dlg.Filter   = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
    $dlg.FileName = "GroupMembers_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
    $dlg.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $row = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName "GroupMembers" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "LM: Exported $($allItems.Count) members to: $outPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "LM: Export failed: $($_.Exception.Message)" -Level Error
    }
})

# ── Filter checkboxes re-apply filter without re-querying ────────────────────
foreach ($cbName in @('ChkLMUsers','ChkLMDevices','ChkLMGroups')) {
    $script:Window.FindName($cbName).Add_Click({
        if ($script:LMAllResults -and $script:LMAllResults.Count -gt 0) { Apply-LMFilter }
    })
}



# ════════════════════════════════════════════════════════════════════════════════
# COMPARE GROUPS — handlers
# ════════════════════════════════════════════════════════════════════════════════

# ── BtnCGCommon ── (Common Members) ─────────────────────────────────────────
$script:Window.FindName('BtnCGCommon').Add_Click({
    Hide-Notification
    $groupTxt = $script:Window.FindName('TxtCGGroupInput').Text
    if ([string]::IsNullOrWhiteSpace($groupTxt)) {
        Show-Notification "Please enter at least one group name or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:CGGroupInput = $groupTxt
    $script:CGAllResults = $null
    $script:Window.FindName('PnlCGResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlCGProgress').Visibility  = 'Visible'
    $script:Window.FindName('PbCGProgress').IsIndeterminate = $true
    $script:Window.FindName('TxtCGProgressMsg').Text    = 'Finding common members...'
    $script:Window.FindName('TxtCGProgressDetail').Text = ''

    if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
    $script:CGProgressTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:CGProgressTimer.Interval = [TimeSpan]::FromMilliseconds(400)
    $script:CGProgressTimer.Add_Tick({
        $prog = if ($script:SharedBg) { $script:SharedBg['CGProgress'] } else { $null }
        if ($prog) {
            $script:Window.FindName('TxtCGProgressMsg').Text    = [string]$prog['Message']
            $script:Window.FindName('TxtCGProgressDetail').Text = [string]$prog['Detail']
        }
    })
    $script:CGProgressTimer.Start()

    $script:Window.FindName('BtnCGDistinct').IsEnabled = $false
    $btn = $script:Window.FindName('BtnCGCommon')
    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Compare Groups: Common Members ---" -Work {

        $script:CGGroupInput = $Shared['CGGroupInput']
        $groupEntries = $script:CGGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
        $totalGroups  = $groupEntries.Count
        $allResults   = [System.Collections.Generic.List[PSCustomObject]]::new()

        $Shared['CGProgress'] = @{ Message = "Starting..."; Detail = "0 groups queued" }

        $gi = 0
        foreach ($entry in $groupEntries) {
            $gi++
            $Shared['CGProgress'] = @{ Message = "Resolving group $gi of $($totalGroups): $entry"; Detail = "$($allResults.Count) objects so far" }

            $groupId   = $null
            $groupName = $entry
            if ($entry -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
                $groupId = $entry
                try {
                    $gInfo     = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$groupId?`$select=displayName"
                    $groupName = [string]$gInfo['displayName']
                } catch { Write-VerboseLog "CG: Could not fetch display name for ID $entry" -Level Warning }
            } else {
                try {
                    $safe_entry = $entry -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
                    $gResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_entry'`&`$select=id,displayName`&`$top=1"
                    if ($gResp['value'] -and $gResp['value'].Count -gt 0) {
                        $groupId   = [string]$gResp['value'][0]['id']
                        $groupName = [string]$gResp['value'][0]['displayName']
                    } else {
                        Write-VerboseLog "CG: Group not found: $entry" -Level Warning
                        continue
                    }
                } catch { Write-VerboseLog "CG: Error resolving '$entry': $($_.Exception.Message)" -Level Error; continue }
            }

            $Shared['CGProgress'] = @{ Message = "Querying group $gi of $($totalGroups): $groupName"; Detail = "$($allResults.Count) objects so far" }
            Write-VerboseLog ('CG: Querying members of ' + $groupName + ' (' + $groupId + ')') -Level Info

            # ── Users ──────────────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.user?`$select=id,userPrincipalName`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($u in @($resp['value'])) {
                        $allResults.Add([PSCustomObject]@{
                            DisplayName = [string]$u['userPrincipalName']
                            ObjectID    = [string]$u['id']
                            ObjectType  = 'User'
                            SourceGroup = $groupName
                        })
                    }
                    $Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (users)"; Detail = "$($allResults.Count) objects so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "CG: Error querying users from '$groupName': $($_.Exception.Message)" -Level Warning }

            # ── Devices ────────────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.device?`$select=id,displayName`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($d in @($resp['value'])) {
                        $allResults.Add([PSCustomObject]@{
                            DisplayName = [string]$d['displayName']
                            ObjectID    = [string]$d['id']
                            ObjectType  = 'Device'
                            SourceGroup = $groupName
                        })
                    }
                    $Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (devices)"; Detail = "$($allResults.Count) objects so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "CG: Error querying devices from '$groupName': $($_.Exception.Message)" -Level Warning }

            # ── Nested Groups ──────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.group?`$select=id,displayName,groupTypes,membershipRuleProcessingState`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($g in @($resp['value'])) {
                        $isM365    = $g['groupTypes'] -and ($g['groupTypes'] -contains 'Unified')
                        $isDynamic = $g['membershipRuleProcessingState'] -eq 'On'
                        $gt = if ($isM365) { 'Microsoft 365' } else { 'Security' }
                        $mt = if ($isDynamic) { 'Dynamic' } else { 'Assigned' }
                        $allResults.Add([PSCustomObject]@{
                            DisplayName = [string]$g['displayName']
                            ObjectID    = [string]$g['id']
                            ObjectType  = ('Group (' + $gt + ', ' + $mt + ')')
                            SourceGroup = $groupName
                        })
                    }
                    $Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (nested groups)"; Detail = "$($allResults.Count) objects so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "CG: Error querying nested groups from '$groupName': $($_.Exception.Message)" -Level Warning }
        }

        $Shared['CGProgress'] = @{ Message = "Complete."; Detail = "$($allResults.Count) object(s) from $($totalGroups) group(s)" }
        Write-VerboseLog "CG: Complete — $($allResults.Count) object(s) from $gi group(s)" -Level Success
        $script:CGAllResults = $allResults
    } -Done {
        if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
        $script:Window.FindName('BtnCGDistinct').IsEnabled = $true
        $script:Window.FindName('PnlCGProgress').Visibility = 'Collapsed'

        if (-not $script:CGAllResults -or $script:CGAllResults.Count -eq 0) {
            $script:Window.FindName('TxtCGNoResults').Visibility = 'Visible'
            Show-Notification "No members found for the specified group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
            return
        }

        # Intersect: ObjectIDs that appear in every successfully resolved source group
        $resolvedGroups = @($script:CGAllResults | Select-Object -ExpandProperty SourceGroup | Sort-Object -Unique)
        $resolvedCount  = $resolvedGroups.Count
        $grouped        = $script:CGAllResults | Group-Object -Property ObjectID
        $commonItems    = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($grp in $grouped) {
            $distinctSrc = @($grp.Group | Select-Object -ExpandProperty SourceGroup | Sort-Object -Unique)
            if ($distinctSrc.Count -ge $resolvedCount) {
                $first = $grp.Group[0]
                $commonItems.Add([PSCustomObject]@{
                    DisplayName = $first.DisplayName
                    ObjectID    = $first.ObjectID
                    ObjectType  = $first.ObjectType
                    SourceGroup = 'Common'
                })
            }
        }

        $dg = $script:Window.FindName('DgCGResults')
        if ($commonItems.Count -eq 0) {
            $dg.ItemsSource = $null
            $script:Window.FindName('TxtCGNoResults').Visibility = 'Visible'
            Show-Notification "No members are common across all $resolvedCount queried group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
            return
        }

        $dg.ItemsSource = $commonItems
        $script:Window.FindName('TxtCGCount').Text           = "$($commonItems.Count) common member(s) across $resolvedCount group(s)"
        $script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
        $script:Window.FindName('PnlCGResults').Visibility   = 'Visible'
        $script:Window.FindName('BtnCGCopyAll').IsEnabled    = $true
        $script:Window.FindName('TxtCGFilter').IsEnabled  = $true
        $script:Window.FindName('BtnCGFilter').IsEnabled  = $true
        $script:Window.FindName('BtnCGFilterClear').IsEnabled = $true
        $script:Window.FindName('BtnCGExportXlsx').IsEnabled = $true
        $script:Window.FindName('BtnCGCopyValue').IsEnabled  = $false
        $script:Window.FindName('BtnCGCopyRow').IsEnabled    = $false
    }
})

# ── BtnCGDistinct ── (Distinct Members) ──────────────────────────────────────
$script:Window.FindName('BtnCGDistinct').Add_Click({
    Hide-Notification
    $groupTxt = $script:Window.FindName('TxtCGGroupInput').Text
    if ([string]::IsNullOrWhiteSpace($groupTxt)) {
        Show-Notification "Please enter at least one group name or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:CGGroupInput = $groupTxt
    $script:CGAllResults = $null
    $script:Window.FindName('PnlCGResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlCGProgress').Visibility  = 'Visible'
    $script:Window.FindName('PbCGProgress').IsIndeterminate = $true
    $script:Window.FindName('TxtCGProgressMsg').Text    = 'Querying distinct members...'
    $script:Window.FindName('TxtCGProgressDetail').Text = ''

    if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
    $script:CGProgressTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:CGProgressTimer.Interval = [TimeSpan]::FromMilliseconds(400)
    $script:CGProgressTimer.Add_Tick({
        $prog = if ($script:SharedBg) { $script:SharedBg['CGProgress'] } else { $null }
        if ($prog) {
            $script:Window.FindName('TxtCGProgressMsg').Text    = [string]$prog['Message']
            $script:Window.FindName('TxtCGProgressDetail').Text = [string]$prog['Detail']
        }
    })
    $script:CGProgressTimer.Start()

    $script:Window.FindName('BtnCGCommon').IsEnabled = $false
    $btn = $script:Window.FindName('BtnCGDistinct')
    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Compare Groups: Distinct Members ---" -Work {

        $script:CGGroupInput = $Shared['CGGroupInput']
        $groupEntries = $script:CGGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
        $totalGroups  = $groupEntries.Count
        $allResults   = [System.Collections.Generic.List[PSCustomObject]]::new()

        $Shared['CGProgress'] = @{ Message = "Starting..."; Detail = "0 groups queued" }

        $gi = 0
        foreach ($entry in $groupEntries) {
            $gi++
            $Shared['CGProgress'] = @{ Message = "Resolving group $gi of $($totalGroups): $entry"; Detail = "$($allResults.Count) objects so far" }

            $groupId   = $null
            $groupName = $entry
            if ($entry -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
                $groupId = $entry
                try {
                    $gInfo     = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$groupId?`$select=displayName"
                    $groupName = [string]$gInfo['displayName']
                } catch { Write-VerboseLog "CG: Could not fetch display name for ID $entry" -Level Warning }
            } else {
                try {
                    $safe_entry = $entry -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
                    $gResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_entry'`&`$select=id,displayName`&`$top=1"
                    if ($gResp['value'] -and $gResp['value'].Count -gt 0) {
                        $groupId   = [string]$gResp['value'][0]['id']
                        $groupName = [string]$gResp['value'][0]['displayName']
                    } else {
                        Write-VerboseLog "CG: Group not found: $entry" -Level Warning
                        continue
                    }
                } catch { Write-VerboseLog "CG: Error resolving '$entry': $($_.Exception.Message)" -Level Error; continue }
            }

            $Shared['CGProgress'] = @{ Message = "Querying group $gi of $($totalGroups): $groupName"; Detail = "$($allResults.Count) objects so far" }
            Write-VerboseLog ('CG: Querying members of ' + $groupName + ' (' + $groupId + ')') -Level Info

            # ── Users ──────────────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.user?`$select=id,userPrincipalName`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($u in @($resp['value'])) {
                        $allResults.Add([PSCustomObject]@{
                            DisplayName = [string]$u['userPrincipalName']
                            ObjectID    = [string]$u['id']
                            ObjectType  = 'User'
                            SourceGroup = $groupName
                        })
                    }
                    $Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (users)"; Detail = "$($allResults.Count) objects so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "CG: Error querying users from '$groupName': $($_.Exception.Message)" -Level Warning }

            # ── Devices ────────────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.device?`$select=id,displayName`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($d in @($resp['value'])) {
                        $allResults.Add([PSCustomObject]@{
                            DisplayName = [string]$d['displayName']
                            ObjectID    = [string]$d['id']
                            ObjectType  = 'Device'
                            SourceGroup = $groupName
                        })
                    }
                    $Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (devices)"; Detail = "$($allResults.Count) objects so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "CG: Error querying devices from '$groupName': $($_.Exception.Message)" -Level Warning }

            # ── Nested Groups ──────────────────────────────────────────────────
            try {
                $uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.group?`$select=id,displayName,groupTypes,membershipRuleProcessingState`&`$top=999"
                do {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $uri
                    foreach ($g in @($resp['value'])) {
                        $isM365    = $g['groupTypes'] -and ($g['groupTypes'] -contains 'Unified')
                        $isDynamic = $g['membershipRuleProcessingState'] -eq 'On'
                        $gt = if ($isM365) { 'Microsoft 365' } else { 'Security' }
                        $mt = if ($isDynamic) { 'Dynamic' } else { 'Assigned' }
                        $allResults.Add([PSCustomObject]@{
                            DisplayName = [string]$g['displayName']
                            ObjectID    = [string]$g['id']
                            ObjectType  = ('Group (' + $gt + ', ' + $mt + ')')
                            SourceGroup = $groupName
                        })
                    }
                    $Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (nested groups)"; Detail = "$($allResults.Count) objects so far" }
                    $uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
                } while ($uri)
            } catch { Write-VerboseLog "CG: Error querying nested groups from '$groupName': $($_.Exception.Message)" -Level Warning }
        }

        $Shared['CGProgress'] = @{ Message = "Complete."; Detail = "$($allResults.Count) object(s) from $($totalGroups) group(s)" }
        Write-VerboseLog "CG: Complete — $($allResults.Count) object(s) from $gi group(s)" -Level Success
        $script:CGAllResults = $allResults
    } -Done {
        if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
        $script:Window.FindName('BtnCGCommon').IsEnabled  = $true
        $script:Window.FindName('PnlCGProgress').Visibility = 'Collapsed'

        if (-not $script:CGAllResults -or $script:CGAllResults.Count -eq 0) {
            $script:Window.FindName('TxtCGNoResults').Visibility = 'Visible'
            Show-Notification "No members found for the specified group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
            return
        }

        $dg = $script:Window.FindName('DgCGResults')
        $dg.ItemsSource = $script:CGAllResults
        $groupCount = ($script:CGGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' }).Count
        $script:Window.FindName('TxtCGCount').Text           = "$($script:CGAllResults.Count) object(s) across $groupCount group(s)"
        $script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
        $script:Window.FindName('PnlCGResults').Visibility   = 'Visible'
        $script:Window.FindName('BtnCGCopyAll').IsEnabled    = $true
        $script:Window.FindName('TxtCGFilter').IsEnabled  = $true
        $script:Window.FindName('BtnCGFilter').IsEnabled  = $true
        $script:Window.FindName('BtnCGFilterClear').IsEnabled = $true
        $script:Window.FindName('BtnCGExportXlsx').IsEnabled = $true
        $script:Window.FindName('BtnCGCopyValue').IsEnabled  = $false
        $script:Window.FindName('BtnCGCopyRow').IsEnabled    = $false
    }
})

# ── DgCGResults: cell selection ───────────────────────────────────────────────
$script:Window.FindName('DgCGResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgCGResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnCGCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnCGCopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnCGCopyValue ────────────────────────────────────────────────────────────
# ── BtnCGFilter ──
$script:Window.FindName('BtnCGFilter').Add_Click({
    $dg      = $script:Window.FindName('DgCGResults')
    $keyword = $script:Window.FindName('TxtCGFilter').Text.Trim()
    if ($null -eq $script:CGAllData) {
        $script:CGAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:CGAllData
        Show-Notification "Filter cleared - $($script:CGAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:CGAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnCGFilterClear ──
$script:Window.FindName('BtnCGFilterClear').Add_Click({
    $script:Window.FindName('TxtCGFilter').Text = ''
    $dg = $script:Window.FindName('DgCGResults')
    if ($null -ne $script:CGAllData) {
        $dg.ItemsSource = $script:CGAllData
        Show-Notification "Filter cleared - $($script:CGAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:CGAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "CG: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnCGCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgCGResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "CG: Copied cell value to clipboard." -Level Success
})

# ── BtnCGCopyRow ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnCGCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgCGResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $rows) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "CG: Copied $($rows.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnCGCopyAll ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnCGCopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgCGResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "CG: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnCGExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnCGExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgCGResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title    = "Export Compare Groups Results"
    $dlg.Filter   = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
    $dlg.FileName = "CompareGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
    $dlg.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $row = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName "CompareGroups" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "CG: Exported $($allItems.Count) rows to: $outPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "CG: Export failed: $($_.Exception.Message)" -Level Error
    }
})

# ── RENAME GROUP ──
$script:Window.FindName('BtnRenValidate').Add_Click({
    Hide-Notification
    if ($null -eq $script:RenSelectedGroup) {
        Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $newName = $script:Window.FindName('TxtNewGroupName').Text.Trim()
    if ([string]::IsNullOrWhiteSpace($newName)) {
        Show-Notification "Please enter a new display name." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:RenNewName = $newName
    $btn = $script:Window.FindName('BtnRenValidate')

    Invoke-OnBackground -DisableButton $btn -BusyMessage "Checking name uniqueness..." -Work {
        $newName = $script:RenNewName
        Write-VerboseLog "Current: $($script:RenSelectedGroup.DisplayName) -> New: $newName" -Level Info
        $existing = $null
        try {
            $safe_newName = $newName -replace "'","''"  # PS5-safe: extracted to avoid nested double-quotes in string
            $resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_newName'`&`$select=id`&`$top=1"
            if ($resp['value'] -and $resp['value'].Count -gt 0) { $existing = $resp['value'][0]['id'] }
        } catch {}
        if ($existing) { Write-VerboseLog "WARNING: A group named '$newName' already exists." -Level Warning }
        $script:RenExistingId = $existing
    } -Done {
        $newName  = $script:RenNewName
        $existing = $script:RenExistingId
        if ($existing) {
            Show-Notification "Warning: A group named '$newName' already exists. Continuing will create a duplicate." -BgColor '#FFF3CD' -FgColor '#7A4800'
        }
        $emptyValid   = [System.Collections.Generic.List[PSCustomObject]]::new()
        $emptyInvalid = [System.Collections.Generic.List[string]]::new()

        $confirmed = Show-ConfirmationDialog `
            -Title "Rename Group" -OperationLabel "Rename Group" `
            -TargetGroup $script:RenSelectedGroup.DisplayName `
            -TargetGroupId $script:RenSelectedGroup.Id `
            -ValidObjects $emptyValid -InvalidEntries $emptyInvalid `
            -ExtraInfo "Current name : $($script:RenSelectedGroup.DisplayName)`nNew name     : $newName"

        if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }

        $btn = $script:Window.FindName('BtnRenValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Renaming group ---" -Work {
            Invoke-MgGraphRequest -Method PATCH `
                -Uri "https://graph.microsoft.com/v1.0/groups/$($script:RenSelectedGroup.Id)" `
                -Body (@{ displayName = $script:RenNewName } | ConvertTo-Json) -ContentType "application/json"
            Write-VerboseLog "Group renamed to '$($script:RenNewName)'" -Level Success
        } -Done {
            Show-Notification "Group renamed to '$($script:RenNewName)'" -BgColor '#D4EDDA' -FgColor '#155724'
            $script:Window.FindName('TxtRenGroupCurrentName').Text = $script:RenNewName
            $script:RenSelectedGroup = [PSCustomObject]@{
                Id = $script:RenSelectedGroup.Id; DisplayName = $script:RenNewName
                GroupTypes = $script:RenSelectedGroup.GroupTypes; MembershipRule = $script:RenSelectedGroup.MembershipRule
            }
        }
    }
})

# ── SET GROUP OWNER (multi-group) ──
$script:Window.FindName('BtnOwnerValidate').Add_Click({
    Hide-Notification
    $groupListTxt = $script:Window.FindName('TxtOwnerGroupList').Text
    if ([string]::IsNullOrWhiteSpace($groupListTxt)) {
        Show-Notification "Please enter at least one target group (name or ID)." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $ownerTxt = $script:Window.FindName('TxtOwnerList').Text
    if ([string]::IsNullOrWhiteSpace($ownerTxt)) {
        Show-Notification "Please enter at least one owner UPN." -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:OwnerGroupsTxt = $groupListTxt
    $script:OwnerUpnsTxt   = $ownerTxt
    $btn = $script:Window.FindName('BtnOwnerValidate')

    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Set Owner: Validating groups and UPNs ---" -Work {

        # -- Resolve target groups --
        $groupLines    = $script:OwnerGroupsTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
        $validGroups   = [System.Collections.Generic.List[PSCustomObject]]::new()
        $invalidGroups = [System.Collections.Generic.List[string]]::new()
        foreach ($line in $groupLines) {
            $entry = $line.Trim()
            if ([string]::IsNullOrWhiteSpace($entry)) { continue }
            Write-VerboseLog "Resolving group: $entry" -Level Info
            $g = Resolve-GroupByNameOrId -GroupEntry $entry
            if ($g) {
                $validGroups.Add($g)
                Write-VerboseLog "  $([char]0x2713) $($g.DisplayName) ($($g.Id))" -Level Success
            } else {
                $invalidGroups.Add($entry)
                Write-VerboseLog "  $([char]0x2717) Not found: $entry" -Level Warning
            }
        }

        # -- Resolve owner UPNs --
        $upns          = $script:OwnerUpnsTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
        $validOwners   = [System.Collections.Generic.List[PSCustomObject]]::new()
        $invalidOwners = [System.Collections.Generic.List[string]]::new()
        foreach ($upn in $upns) {
            $u = $upn.Trim()
            if ([string]::IsNullOrWhiteSpace($u)) { continue }
            Write-VerboseLog "Resolving owner UPN: $u" -Level Info
            try {
                $userObj = Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($u))?`$select=id,displayName"
                $validOwners.Add([PSCustomObject]@{ Id = $userObj['id']; DisplayName = $userObj['displayName']; Type = 'User'; Found = $true; Original = $u })
                Write-VerboseLog "  $([char]0x2713) $($userObj['displayName']) ($($userObj['id']))" -Level Success
            } catch {
                $invalidOwners.Add($u)
                Write-VerboseLog "  $([char]0x2717) Not found: $u" -Level Warning
            }
        }

        $script:OwnerGroupsValidated = @{ ValidGroups = $validGroups; InvalidGroups = $invalidGroups }
        $script:OwnerValidated       = @{ Valid = $validOwners; Invalid = $invalidOwners }

    } -Done {
        $validGroups   = $script:OwnerGroupsValidated['ValidGroups']
        $invalidGroups = $script:OwnerGroupsValidated['InvalidGroups']
        $validOwners   = $script:OwnerValidated['Valid']
        $invalidOwners = $script:OwnerValidated['Invalid']

        if ($validGroups.Count -eq 0) {
            Show-Notification "No valid target groups found. Cannot proceed." -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }
        if ($validOwners.Count -eq 0) {
            Show-Notification "No valid owner UPNs found. Cannot proceed." -BgColor '#F8D7DA' -FgColor '#721C24'
            return
        }

        # Build ExtraInfo block listing all target groups for the confirmation dialog
        $sbGroups = [System.Text.StringBuilder]::new()
        $null = $sbGroups.AppendLine(('TARGET GROUPS (' + $validGroups.Count + ' valid, ' + $invalidGroups.Count + ' not found):'))
        foreach ($g   in $validGroups)   { $null = $sbGroups.AppendLine("  $([char]0x2713) $($g.DisplayName) ($($g.Id))") }
        foreach ($inv in $invalidGroups) { $null = $sbGroups.AppendLine("  $([char]0x2717) $inv  (not found - will be skipped)") }
        $extraInfo = $sbGroups.ToString().TrimEnd()

        $confirmed = Show-ConfirmationDialog `
            -Title "Set Group Owner (Multi-Group)" -OperationLabel "Set Group Owner" `
            -TargetGroup "Multiple groups ($($validGroups.Count))" `
            -TargetGroupId "(see group list below)" `
            -ValidObjects $validOwners -InvalidEntries $invalidOwners `
            -ExtraInfo $extraInfo

        if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }

        $btn = $script:Window.FindName('BtnOwnerValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Adding owners to groups ---" -Work {
            $ok = 0; $fail = 0; $skipped = 0
            $validGroups = $script:OwnerGroupsValidated['ValidGroups']
            foreach ($grp in $validGroups) {
                Write-VerboseLog "Processing group: $($grp.DisplayName)" -Level Info
                foreach ($owner in $script:OwnerValidated['Valid']) {
                    try {
                        $ob = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/$($owner.Id)" }
                        Invoke-MgGraphRequest -Method POST `
                            -Uri "https://graph.microsoft.com/v1.0/groups/$($grp.Id)/owners/`$ref" `
                            -Body ($ob | ConvertTo-Json) -ContentType "application/json"
                        Write-VerboseLog "Owner added: $($owner.DisplayName) -> $($grp.DisplayName)" -Level Success
                        $ok++
                    } catch {
                        $errMsg    = $_.Exception.Message
                        $errDetail = if ($_.ErrorDetails) { $_.ErrorDetails.Message } else { '' }
                        if ($errMsg -match 'already exist' -or $errDetail -match 'already exist') {
                            Write-VerboseLog "Skipping $($owner.DisplayName) - already an owner of $($grp.DisplayName)" -Level Warning
                            $skipped++
                        } else {
                            Write-VerboseLog "Failed: $($owner.DisplayName) -> $($grp.DisplayName) - $errMsg" -Level Error
                            $fail++
                        }
                    }
                }
            }
            $script:OwnerExecResult = @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
        } -Done {
            $ok      = $script:OwnerExecResult['Ok']
            $fail    = $script:OwnerExecResult['Fail']
            $skipped = $script:OwnerExecResult['Skipped']
            $msg     = "Set Owner complete. Succeeded: $ok | Skipped (already owner): $skipped | Failed: $fail"
            $vLevel  = if ($fail -gt 0) { 'Warning' } else { 'Success' }
            $bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
            $fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
            Write-VerboseLog $msg -Level $vLevel
            Show-Notification $msg -BgColor $bgColor -FgColor $fgColor
        }
    }
})

# ── REMOVE GROUPS ─────────────────────────────────────────────────────────────

$script:Window.FindName('BtnOpRemoveGroups').Add_Click({
    $script:Window.FindName('TxtRGGroupList').Text            = ''
    $script:Window.FindName('PnlRGProgress').Visibility       = 'Collapsed'
    $script:Window.FindName('PnlRGWarning').Visibility        = 'Collapsed'
    $script:Window.FindName('PnlRGPreview').Visibility        = 'Collapsed'
    $script:Window.FindName('PnlRGExecProgress').Visibility   = 'Collapsed'
    $script:Window.FindName('PnlRGResults').Visibility        = 'Collapsed'
    $script:Window.FindName('TxtRGNoResults').Visibility      = 'Collapsed'
    $dg = $script:Window.FindName('DgRGPreview')
    if ($dg) { $dg.ItemsSource = $null }
    $dg2 = $script:Window.FindName('DgRGResults')
    if ($dg2) { $dg2.ItemsSource = $null }
    $script:Window.FindName('BtnRGExecute').IsEnabled         = $false
    $script:Window.FindName('BtnRGCopyAll').IsEnabled         = $false
    $script:Window.FindName('BtnRGExportXlsx').IsEnabled      = $false
    $script:RGGroupList  = $null
    $script:RGValidated  = $null
    $script:RGExecResult = $null
    Show-Panel 'PanelRemoveGroups'
    Hide-Notification
    Write-VerboseLog 'Panel: Remove Groups' -Level Info
})

$script:Window.FindName('BtnRGValidate').Add_Click({
    Hide-Notification
    $inputTxt = $script:Window.FindName('TxtRGGroupList').Text
    if ([string]::IsNullOrWhiteSpace($inputTxt)) {
        Show-Notification 'Please enter at least one group name or Object ID.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $script:RGGroupList = $inputTxt
    $dg = $script:Window.FindName('DgRGPreview')
    if ($dg) { $dg.ItemsSource = $null }
    $dg2 = $script:Window.FindName('DgRGResults')
    if ($dg2) { $dg2.ItemsSource = $null }
    $script:Window.FindName('PnlRGPreview').Visibility        = 'Collapsed'
    $script:Window.FindName('PnlRGResults').Visibility        = 'Collapsed'
    $script:Window.FindName('PnlRGWarning').Visibility        = 'Collapsed'
    $script:Window.FindName('TxtRGNoResults').Visibility      = 'Collapsed'
    $script:Window.FindName('PnlRGExecProgress').Visibility   = 'Collapsed'
    $script:Window.FindName('BtnRGExecute').IsEnabled         = $false
    $script:Window.FindName('BtnRGCopyAll').IsEnabled         = $false
    $script:Window.FindName('BtnRGExportXlsx').IsEnabled      = $false
    $script:Window.FindName('PnlRGProgress').Visibility       = 'Visible'
    $script:Window.FindName('TxtRGProgressMsg').Text          = 'Validating groups...'
    $script:Window.FindName('TxtRGProgressDetail').Text       = ''

    $btn = $script:Window.FindName('BtnRGValidate')
    Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Remove Groups: Validating ---' -Work {
        $inputLines = ($script:RGGroupList -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
        $validated  = [System.Collections.Generic.List[PSCustomObject]]::new()
        $total      = $inputLines.Count
        $idx        = 0

        foreach ($entry in $inputLines) {
            $idx++
            $Shared['LogQueue'].Enqueue(@{ Line = "RG: Resolving ($idx/$total): $entry"; Level = 'Info' })

            $grp = Resolve-GroupByNameOrId -GroupEntry $entry
            if ($null -eq $grp) {
                $validated.Add([PSCustomObject]@{
                    DisplayName = $entry
                    GroupID     = '(not found)'
                    GroupType   = [char]0x2014
                    MemberCount = [char]0x2014
                    HasMembers  = $false
                    IsResolved  = $false
                    OrigEntry   = $entry
                })
                $Shared['LogQueue'].Enqueue(@{ Line = "RG:   Not found: $entry"; Level = 'Warning' })
                continue
            }

            # Determine group type from Graph metadata
            $groupTypeName = 'Unknown'
            try {
                $meta         = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$($grp.Id)?`$select=mailEnabled,securityEnabled,groupTypes"
                $mailEnabled  = [bool]$meta['mailEnabled']
                $secEnabled   = [bool]$meta['securityEnabled']
                $gtArr        = @($meta['groupTypes'])
                $groupTypeName = if ($gtArr -contains 'Unified')             { 'Microsoft 365' }
                                 elseif ($mailEnabled -and -not $secEnabled) { 'Distribution' }
                                 elseif ($secEnabled  -and -not $mailEnabled) { 'Security' }
                                 elseif ($secEnabled  -and $mailEnabled)      { 'Mail-Enabled Security' }
                                 else                                          { 'Unknown' }
            } catch {}

            $memberCount = Get-GroupMemberCount -GroupId $grp.Id
            $mc          = if ($null -eq $memberCount) { '?' } else { [string]$memberCount }
            $hasMembers  = ($null -ne $memberCount -and $memberCount -gt 0)

            $validated.Add([PSCustomObject]@{
                DisplayName = $grp.DisplayName
                GroupID     = $grp.Id
                GroupType   = $groupTypeName
                MemberCount = $mc
                HasMembers  = $hasMembers
                IsResolved  = $true
                OrigEntry   = $entry
            })
            $Shared['LogQueue'].Enqueue(@{ Line = "RG:   Resolved: $($grp.DisplayName) [$($grp.Id)] | $groupTypeName | Members: $mc"; Level = 'Success' })
        }
        $script:RGValidated = $validated

    } -Done {
        $script:Window.FindName('PnlRGProgress').Visibility = 'Collapsed'
        $rows = $script:RGValidated

        if ($null -eq $rows -or $rows.Count -eq 0) {
            $script:Window.FindName('TxtRGNoResults').Visibility = 'Visible'
            Show-Notification 'No groups resolved from the provided input.' -BgColor '#FFF3CD' -FgColor '#7A4800'
            return
        }

        $resolved    = @($rows | Where-Object { $_.IsResolved })
        $withMembers = @($rows | Where-Object { $_.HasMembers })
        $notFound    = @($rows | Where-Object { -not $_.IsResolved })

        if ($resolved.Count -eq 0) {
            Show-Notification 'No groups could be resolved. Check the names/IDs and try again.' -BgColor '#FFF3CD' -FgColor '#7A4800'
            return
        }

        # Build ValidObjects and InvalidEntries for Show-ConfirmationDialog
        $validObjs      = @($resolved | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.DisplayName; Type = $_.GroupType; Id = $_.GroupID } })
        $invalidEntries = @($notFound  | ForEach-Object { $_.OrigEntry })

        # ExtraInfo — warn about groups that still have members
        $extra = ''
        if ($withMembers.Count -gt 0) {
            $memberLines = ($withMembers | ForEach-Object { "  $([char]0x26A0) $($_.DisplayName) ($($_.MemberCount) member(s))" }) -join "`n"
            $extra = "$([char]0x26A0) $($withMembers.Count) group$(if($withMembers.Count -eq 1){''}else{'s'}) still ha$(if($withMembers.Count -eq 1){'s'}else{'ve'}) members.`nMembers lose group access but are NOT deleted:`n$memberLines"
        }

        $msg = "Validation complete. $($resolved.Count) group(s) ready, $($notFound.Count) not found."
        Write-VerboseLog "RG: $msg" -Level Info

        $confirmed = Show-ConfirmationDialog `
            -Title 'Remove Groups' -OperationLabel 'Remove Groups' `
            -TargetGroup "$($resolved.Count) group(s) selected" -TargetGroupId '(see list below)' `
            -ValidObjects $validObjs -InvalidEntries $invalidEntries `
            -ExtraInfo $extra

        if (-not $confirmed) { Write-VerboseLog 'RG: Action cancelled by user.' -Level Warning; return }

        # Set up UI for execution progress
        $dg2 = $script:Window.FindName('DgRGResults')
        if ($dg2) { $dg2.ItemsSource = $null }
        $script:Window.FindName('PnlRGResults').Visibility      = 'Collapsed'
        $script:Window.FindName('BtnRGCopyAll').IsEnabled       = $false
        $script:Window.FindName('BtnRGExportXlsx').IsEnabled    = $false
        $script:Window.FindName('PnlRGExecProgress').Visibility = 'Visible'
        $script:Window.FindName('TxtRGExecProgressMsg').Text    = 'Removing groups...'
        $script:Window.FindName('TxtRGExecProgressDetail').Text = ''

        $btn = $script:Window.FindName('BtnRGValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Remove Groups: Executing ---' -Work {

            $toDelete = @($script:RGValidated | Where-Object { $_.IsResolved })
            $results  = [System.Collections.Generic.List[PSCustomObject]]::new()
            $total    = $toDelete.Count
            $idx      = 0

            foreach ($g in $toDelete) {
                $idx++
                $Shared['LogQueue'].Enqueue(@{ Line = "RG: Deleting ($idx/$total): $($g.DisplayName)"; Level = 'Action' })
                try {
                    $null = Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/v1.0/groups/$($g.GroupID)"
                    $results.Add([PSCustomObject]@{
                        DisplayName = $g.DisplayName
                        GroupID     = $g.GroupID
                        Status      = 'Removed'
                        Message     = 'Successfully deleted.'
                        IsSuccess   = $true
                    })
                    $Shared['LogQueue'].Enqueue(@{ Line = "RG:   Removed: $($g.DisplayName) [$($g.GroupID)]"; Level = 'Success' })
                } catch {
                    $errMsg = $_.Exception.Message
                    $results.Add([PSCustomObject]@{
                        DisplayName = $g.DisplayName
                        GroupID     = $g.GroupID
                        Status      = 'Failed'
                        Message     = $errMsg
                        IsSuccess   = $false
                    })
                    $Shared['LogQueue'].Enqueue(@{ Line = "RG:   Failed: $($g.DisplayName) - $errMsg"; Level = 'Error' })
                }
            }
            $script:RGExecResult = $results

        } -Done {
            $script:Window.FindName('PnlRGExecProgress').Visibility = 'Collapsed'
            $exRows = $script:RGExecResult

            if ($null -eq $exRows -or $exRows.Count -eq 0) {
                Show-Notification 'No results returned from deletion.' -BgColor '#FFF3CD' -FgColor '#7A4800'
                return
            }

            $dg2 = $script:Window.FindName('DgRGResults')
            $dg2.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($exRows)
            $ok   = @($exRows | Where-Object { $_.IsSuccess  }).Count
            $fail = @($exRows | Where-Object { -not $_.IsSuccess }).Count
            $script:Window.FindName('TxtRGResultCount').Text       = "Removed: $ok  |  Failed: $fail"
            $script:Window.FindName('PnlRGResults').Visibility     = 'Visible'
            $script:Window.FindName('BtnRGCopyAll').IsEnabled      = $true
            $script:Window.FindName('BtnRGExportXlsx').IsEnabled   = $true
            $rgMsg   = "Remove Groups complete. Removed: $ok | Failed: $fail"
            $bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
            $fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
            Show-Notification $rgMsg -BgColor $bgColor -FgColor $fgColor
            Write-VerboseLog "RG: $rgMsg" -Level $(if ($fail -gt 0) { 'Warning' } else { 'Success' })
        }
    }
})

# BtnRGExecute is no longer used as a separate button — Remove Groups validation now flows
# directly into Show-ConfirmationDialog and execution is triggered from BtnRGValidate Done.

# ── BtnRGCopyAll ───────────────────────────────────────────────────────────────
$script:Window.FindName('BtnRGCopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgRGResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "`r`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "RG: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnRGExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnRGExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgRGResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title            = 'Export Remove Groups Results'
    $dlg.Filter           = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
    $dlg.FileName         = "RemoveGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $rowObj = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName 'RemoveGroups' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "RG: Exported $($allItems.Count) rows to: $outPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "RG: Export failed: $($_.Exception.Message)" -Level Error
    }
})

# ── SESSION NOTES ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnSessionNotes').Add_Click({
    $script:Window.FindName('OverlaySessionNotes').Visibility = 'Visible'
    $script:Window.FindName('TxtSessionNotes').Focus() | Out-Null
})
$script:Window.FindName('BtnSessionNotesClose').Add_Click({
    $script:Window.FindName('OverlaySessionNotes').Visibility = 'Collapsed'
})
$script:Window.FindName('SnDimmer').Add_MouseLeftButtonDown({
    $script:Window.FindName('OverlaySessionNotes').Visibility = 'Collapsed'
})

# ── ADD USER DEVICES TO GROUP ──────────────────────────────────────────────

Wire-GroupPicker `
    -SearchBoxName 'TxtUDGroupSearch' -SearchBtnName 'BtnUDGroupSearch' -ByIdBtnName 'BtnUDGroupById' `
    -ResultsListName 'LstUDGroupResults' -ResultsBorderName 'UDGroupSearchResults' `
    -BadgeName 'UDSelectedGroupBadge' -BadgeNameTxt 'TxtUDGroupName' -BadgeIdTxt 'TxtUDGroupId' `
    -ScriptVarName 'UDSelectedGroup'

$script:Window.FindName('BtnOpUserDevices').Add_Click({
    $script:Window.FindName('TxtUDGroupSearch').Text            = ''
    $script:Window.FindName('TxtUDUpnList').Text                = ''
    $script:Window.FindName('UDSelectedGroupBadge').Visibility  = 'Collapsed'
    $script:Window.FindName('UDGroupSearchResults').Visibility  = 'Collapsed'
    $script:Window.FindName('ChkUDWindows').IsChecked           = $true
    $script:Window.FindName('ChkUDAndroid').IsChecked           = $true
    $script:Window.FindName('ChkUDiOS').IsChecked               = $true
    $script:Window.FindName('ChkUDMacOS').IsChecked             = $true
    $script:Window.FindName('CmbUDOwnership').SelectedIndex     = 0
    $script:Window.FindName('ChkUDIntuneOnly').IsChecked        = $false
    $script:UDSelectedGroup = $null
    Show-Panel 'PanelUserDevices'
    Hide-Notification
    Write-VerboseLog 'Panel: Add User Devices to Group' -Level Info
})

$script:Window.FindName('BtnUDValidate').Add_Click({
    Hide-Notification

    if ($null -eq $script:UDSelectedGroup) {
        Show-Notification 'Please select a target group first.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }
    $upnTxt = $script:Window.FindName('TxtUDUpnList').Text
    if ([string]::IsNullOrWhiteSpace($upnTxt)) {
        Show-Notification 'Please enter at least one user UPN.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    # Collect filter selections on the UI thread before handing off to background
    $selPlatforms = [System.Collections.Generic.List[string]]::new()
    if ($script:Window.FindName('ChkUDWindows').IsChecked) { $selPlatforms.Add('Windows') }
    if ($script:Window.FindName('ChkUDAndroid').IsChecked) { $selPlatforms.Add('Android') }
    if ($script:Window.FindName('ChkUDiOS').IsChecked)     { $selPlatforms.Add('iOS') }
    if ($script:Window.FindName('ChkUDMacOS').IsChecked)   { $selPlatforms.Add('macOS') }
    if ($selPlatforms.Count -eq 0) {
        Show-Notification 'Please select at least one device platform.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    $ownershipSel    = $script:Window.FindName('CmbUDOwnership').SelectedItem.Content
    $ownershipFilter = switch ($ownershipSel) {
        'Company only'  { 'Company' }
        'Personal only' { 'Personal' }
        default         { 'All' }
    }

    $intuneOnly = [bool]$script:Window.FindName('ChkUDIntuneOnly').IsChecked

    $script:UDParams = @{
        UpnTxt          = $upnTxt
        Platforms       = $selPlatforms
        OwnershipFilter = $ownershipFilter
        IntuneOnly      = $intuneOnly
    }
    $btn = $script:Window.FindName('BtnUDValidate')

    Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Add User Devices: Validating ---' -Work {
        $params          = $script:UDParams
        $upns            = $params['UpnTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
        $platforms       = $params['Platforms']
        $ownershipFilter = $params['OwnershipFilter']
        $intuneOnly      = $params['IntuneOnly']

        $validDevices   = [System.Collections.Generic.List[PSCustomObject]]::new()
        $invalidUpns    = [System.Collections.Generic.List[string]]::new()
        $skippedDevices = [System.Collections.Generic.List[string]]::new()

        Write-VerboseLog "Target group: $($script:UDSelectedGroup.DisplayName) ($($script:UDSelectedGroup.Id))" -Level Info
        Write-VerboseLog "Platform filter : $($platforms -join ', ')" -Level Info
        Write-VerboseLog "Ownership filter: $ownershipFilter" -Level Info
        Write-VerboseLog "Processing $($upns.Count) UPN(s)..." -Level Info

        foreach ($upn in $upns) {
            Write-VerboseLog "Resolving user: $upn" -Level Info

            $userId          = $null
            $userDisplayName = $upn
            try {
                $userObj         = Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($upn))?`$select=id,displayName"
                $userId          = $userObj['id']
                $userDisplayName = $userObj['displayName']
                Write-VerboseLog "  $([char]0x2713) User found: $userDisplayName ($userId)" -Level Success
            } catch {
                Write-VerboseLog "  $([char]0x2717) User not found: $upn" -Level Warning
                $invalidUpns.Add($upn)
                continue
            }

            # ── Choose data source based on IntuneOnly filter ──────────────────
            # IntuneOnly = $true  → Intune managedDevices endpoint:
#                 - Only returns devices enrolled in Intune (filter is implicit)
#                 - operatingSystem values are clean: iOS, Windows, Android, macOS
            # IntuneOnly = $false → Entra ownedDevices endpoint:
#                 - Returns all Entra-registered devices (enrolled or not)
#                 - operatingSystem strings vary: iPhone, iPadOS, MacMDM, AndroidForWork, etc.
#                    -  normalised below before comparison.
            if ($intuneOnly) {
                # Intune endpoint  -  requires DeviceManagementManagedDevices.Read.All
                $deviceUri = "https://graph.microsoft.com/v1.0/users/$userId/managedDevices" +
                             "?`$select=id,deviceName,operatingSystem,managedDeviceOwnerType,azureADDeviceId`&`$top=999"
            } else {
                # Entra endpoint  -  requires Device.Read.All (already consented)
                $deviceUri = "https://graph.microsoft.com/v1.0/users/$userId/ownedDevices" +
                             "?`$select=id,displayName,operatingSystem,deviceOwnership`&`$top=999"
            }

            $devicesRaw = @()
            try {
                $devResp    = Invoke-MgGraphRequest -Method GET -Uri $deviceUri
                $devicesRaw = @($devResp['value'])
            } catch {
                Write-VerboseLog "  [!] Could not fetch devices for $upn : $($_.Exception.Message)" -Level Warning
                continue
            }

            if ($devicesRaw.Count -eq 0) {
                Write-VerboseLog "  No owned devices found for $upn" -Level Info
                continue
            }
            $srcLabel = if ($intuneOnly) { 'Intune' } else { 'Entra' }
            Write-VerboseLog "  Found $($devicesRaw.Count) device(s) from $srcLabel  -  applying filters..." -Level Info

            foreach ($dev in $devicesRaw) {
                # Field names differ between the two endpoints
                if ($intuneOnly) {
                    $devId        = $dev['id']
                    $devName      = $dev['deviceName']
                    $devOS        = $dev['operatingSystem']          # already clean: iOS/Windows/Android/macOS
                    $devOwnership = $dev['managedDeviceOwnerType']   # 'company' or 'personal'
                    $mgmtLabel    = 'Intune'
                } else {
                    $devId        = $dev['id']
                    $devName      = $dev['displayName']
                    $devOS        = $dev['operatingSystem']
                    $devOwnership = $dev['deviceOwnership']          # 'Company' or 'Personal'
                    $mgmtLabel    = 'Entra'
                }

                # Platform filter
                # Entra stores OS with varying strings  -  normalise to the four UI values.
                # Intune values are already clean so the switch still matches correctly.
                $osNorm = switch -Wildcard ($devOS) {
                    'Windows*'      { 'Windows' }
                    'Android*'      { 'Android' }
                    'iOS'           { 'iOS' }
                    'iPadOS'        { 'iOS' }    # Entra: iPads enrolled as iPadOS
                    'iPhone'        { 'iOS' }    # Entra: iPhones stored as 'iPhone'
                    'MacMDM'        { 'macOS' }  # Entra: Macs enrolled via MDM profile
                    'Mac OS X'      { 'macOS' }  # Entra: legacy macOS string
                    'macOS'         { 'macOS' }
                    default         { $devOS }
                }
                if ($osNorm -notin $platforms) {
                    $skippedDevices.Add(($devName + ' (' + $devOS + '  -  not in platform filter)'))
                    Write-VerboseLog "    Skipped (platform): $devName  [$devOS]" -Level Info
                    continue
                }

                # Ownership filter
                # Intune uses lowercase ('company'/'personal'); Entra uses title-case ('Company'/'Personal').
                # Normalise both to title-case for the comparison.
                $ownerNorm = if ($devOwnership) {
                    [System.Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($devOwnership.ToLower())
                } else { '' }
                if ($ownershipFilter -ne 'All' -and $ownerNorm -ne $ownershipFilter) {
                    $skippedDevices.Add("$devName (ownership: $devOwnership  -  filter: $ownershipFilter)")
                    Write-VerboseLog "    Skipped (ownership): $devName  [$devOwnership]" -Level Info
                    continue
                }
                $_devSummary = $osNorm + ' | ' + $ownerNorm + ' | ' + $mgmtLabel + ' | owner: ' + $userDisplayName
                Write-VerboseLog "    $([char]0x2713) Matched: $devName  [$_devSummary]" -Level Success
                # ── For Intune devices: resolve the Entra directory object ID ────────────────────────
                # Intune managedDevices returns its own managed-device ID, which is NOT a valid Entra
                # directory object ID. We use azureADDeviceId to look up the Entra device object ID.
                if ($intuneOnly) {
                    $aadDevId = $dev['azureADDeviceId']
                    if ([string]::IsNullOrEmpty($aadDevId)) {
                        Write-VerboseLog "    [!] No azureADDeviceId on Intune record for $devName — skipping." -Level Warning
                        $skippedDevices.Add("$devName (Intune record missing azureADDeviceId)")
                        continue
                    }
                    try {
                        $entraLookup = Invoke-MgGraphRequest -Method GET `
                            -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadDevId'&`$select=id"
                        $entraObjId = $entraLookup['value'][0]['id']
                        if ([string]::IsNullOrEmpty($entraObjId)) {
                            Write-VerboseLog "    [!] Entra device not found for azureADDeviceId=$aadDevId ($devName) — skipping." -Level Warning
                            $skippedDevices.Add("$devName (device not found in Entra directory)")
                            continue
                        }
                        $devId = $entraObjId   # override with Entra object ID for group membership add
                        Write-VerboseLog "    Resolved Entra object ID for $devName : $devId" -Level Info
                    } catch {
                        Write-VerboseLog "    [!] Entra ID lookup failed for $devName : $($_.Exception.Message) — skipping." -Level Warning
                        $skippedDevices.Add("$devName (Entra ID resolution failed: $($_.Exception.Message))")
                        continue
                    }
                }

                $validDevices.Add([PSCustomObject]@{
                    Id          = $devId
                    DisplayName = "$devName  [$_devSummary]"
                    Type        = 'Device'
                    Found       = $true
                    Original    = $devName
                })
            }
        }

        $script:UDValidated = @{
            ValidDevices   = $validDevices
            InvalidUpns    = $invalidUpns
            SkippedDevices = $skippedDevices
        }

    } -Done {
        $validDevices   = $script:UDValidated['ValidDevices']
        $invalidUpns    = $script:UDValidated['InvalidUpns']
        $skippedDevices = $script:UDValidated['SkippedDevices']
        $params         = $script:UDParams

        if ($validDevices.Count -eq 0) {
            $msg = 'No devices matched the selected filters. Check UPNs, platform and ownership selections.'
            Write-VerboseLog $msg -Level Warning
            Show-Notification $msg -BgColor '#FFF3CD' -FgColor '#7A4800'
            return
        }

        $extraLines = [System.Collections.Generic.List[string]]::new()
        $extraLines.Add("Platform filter  : $($params['Platforms'] -join ', ')")
        $extraLines.Add("Ownership filter : $($params['OwnershipFilter'])")
        $extraLines.Add("Intune only      : $(if ($params['IntuneOnly']) { 'Yes' } else { 'No' })")
        if ($invalidUpns.Count -gt 0)    { $extraLines.Add("Users not found  : $($invalidUpns.Count) UPN(s) could not be resolved") }
        if ($skippedDevices.Count -gt 0) { $extraLines.Add("Devices skipped (filter mismatch): $($skippedDevices.Count)") }

        $invalidForDlg = [System.Collections.Generic.List[string]]::new()
        foreach ($u in $invalidUpns) { $invalidForDlg.Add($u) }

        $confirmed = Show-ConfirmationDialog `
            -Title 'Add User Devices to Group' -OperationLabel 'Add User Devices to Group' `
            -TargetGroup $script:UDSelectedGroup.DisplayName `
            -TargetGroupId $script:UDSelectedGroup.Id `
            -ValidObjects $validDevices -InvalidEntries $invalidForDlg `
            -ExtraInfo ($extraLines -join "`n")

        if (-not $confirmed) { Write-VerboseLog 'Action cancelled by user.' -Level Warning; return }

        $btn = $script:Window.FindName('BtnUDValidate')
        Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Adding devices to group ---' -Work {
            $devices = $script:UDValidated['ValidDevices']
            $groupId = $script:UDSelectedGroup.Id
            $ok = 0; $fail = 0

            foreach ($dev in $devices) {
                if ($null -ne $Shared -and $Shared['StopRequested']) {
                    Write-VerboseLog 'Stop requested  -  halting.' -Level Warning
                    break
                }
                Write-VerboseLog "Adding device: $($dev.Original)..." -Level Action
                try {
                    $body = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($dev.Id)" }
                    Invoke-MgGraphRequest -Method POST `
                        -Uri "https://graph.microsoft.com/v1.0/groups/$groupId/members/`$ref" `
                        -Body ($body | ConvertTo-Json) -ContentType 'application/json'
                    Write-VerboseLog "  $([char]0x2713) Added: $($dev.Original)" -Level Success
                    $ok++
                } catch {
                    $errMsg = $_.Exception.Message
                    if ($errMsg -match 'already exist') {
                        Write-VerboseLog "  Already a member: $($dev.Original)" -Level Warning
                    } else {
                        Write-VerboseLog "  $([char]0x2717) Failed: $($dev.Original)  -  $errMsg" -Level Error
                        $fail++
                    }
                }
            }
            $script:UDExecResult = @{ Ok = $ok; Fail = $fail }

        } -Done {
            $ok   = $script:UDExecResult['Ok']
            $fail = $script:UDExecResult['Fail']
            $msg  = "Add User Devices complete. Added: $ok | Failed: $fail"
            $vLevel  = if ($fail -gt 0) { 'Warning' } else { 'Success' }
            $bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
            $fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
            Write-VerboseLog $msg -Level $vLevel
            Show-Notification $msg -BgColor $bgColor -FgColor $fgColor
        }
    }
})





# ── GET POLICY ASSIGNMENTS ───────────────────────────────────────────────────

# ── Sub-type normalisation ──
function Get-GPASubType {
    param([string]$PolicyCollection, [string]$ODataType, [string]$TemplateDisplayName)
    switch ($PolicyCollection) {
        'configurationPolicies' {
            if (-not [string]::IsNullOrEmpty($TemplateDisplayName)) {
                switch -Wildcard ($TemplateDisplayName) {
                    '*Antivirus exclusion*'              { return 'Microsoft Defender Antivirus Exclusions' }
                    '*Defender Antivirus*'               { return 'Microsoft Defender Antivirus' }
                    '*Defender Update*'                  { return 'Defender Update Controls' }
                    '*Attack Surface*'                   { return 'Attack Surface Reduction Rules' }
                    '*BitLocker*'                        { return 'BitLocker' }
                    '*Windows Firewall Rules*'           { return 'Windows Firewall Rules' }
                    '*Windows Firewall*'                 { return 'Windows Firewall' }
                    '*MDE*onboard*'                      { return 'MDE Onboarding Policy' }
                    '*Exploit Protection*'               { return 'Exploit Protection' }
                    '*App Control*'                      { return 'App Control for Business' }
                    '*Windows Security Experience*'      { return 'Windows Security Experience' }
                    '*Identity Protection*'              { return 'Identity Protection' }
                    '*Local user group*'                 { return 'Local User Group Membership' }
                    '*LAPS*'                             { return 'Local Admin Password Solution (Windows LAPS)' }
                    '*Health Monitoring*'                { return 'Health Monitoring' }
                    '*Windows operating system recovery*'{ return 'Windows OS Recovery' }
                    '*Update ring*'                      { return 'Update Rings' }
                    '*Software update*'                  { return 'Software Update' }
                    '*Endpoint detection*'               { return 'Endpoint Protection' }
                    '*Extension*'                        { return 'Extensions' }
                    default                               { return $TemplateDisplayName }
                }
            } else { return 'Settings Catalog' }
        }
        'deviceConfigurations' {
            $c = $ODataType -replace '^#microsoft\.graph\.', ''
            switch -Wildcard ($c) {
                '*trustedRoot*'          { return 'Trusted Certificate' }
                '*scepCertificate*'      { return 'SCEP Certificate' }
                '*pkcsCertificate*'      { return 'Certificate' }
                '*pkcs12Import*'         { return 'Certificate' }
                '*vpn*'                  { return 'VPN' }
                '*wifiConfiguration*'    { return 'Wi-Fi' }
                '*wiFiConfiguration*'    { return 'Wi-Fi' }
                '*wifi*'                 { return 'Wi-Fi' }
                '*sharedPC*'             { return 'Shared PC' }
                '*editionUpgrade*'       { return 'Edition Upgrade' }
                '*kiosk*'               { return 'Kiosk' }
                '*updateForBusiness*'    { return 'Update Rings' }
                '*softwareUpdate*'       { return 'Software Update' }
                '*endpointProtection*'   { return 'Endpoint Protection' }
                '*bitLocker*'            { return 'BitLocker' }
                '*deviceFirmware*'       { return 'Device Features' }
                '*deviceFeatures*'       { return 'Device Features' }
                '*windowsHealthMonitoring*' { return 'Health Monitoring' }
                '*localAdminPassword*'   { return 'Local Admin Password Solution (Windows LAPS)' }
                '*custom*'              { return 'Custom' }
                '*general*'             { return 'General Device' }
                default                  { return $c }
            }
        }
        'groupPolicyConfigurations'     { return 'Administrative Templates' }
        'deviceManagementScripts'       { return 'PowerShell' }
        'deviceHealthScripts'           { return 'Remediation' }
        'windowsAutopilotDeploymentProfiles' { return 'Device Preparation' }
        default                          { return '' }
    }
}

# ── OS Platform normalisation ──
function Get-GPAPlatform {
    param([string]$PolicyCollection, [string]$ODataType, [string]$Platforms)
    switch ($PolicyCollection) {
        'configurationPolicies' {
            switch -Wildcard ($Platforms) {
                'windows10'    { return 'Windows' }
                'windows*'     { return 'Windows' }
                'iOS'          { return 'iOS/iPadOS' }
                'macOS'        { return 'macOS' }
                'android'      { return 'Android' }
                'androidAOSP'  { return 'AOSP' }
                'linux'        { return 'Linux' }
                default         { return $Platforms }
            }
        }
        'deviceConfigurations' {
            $c = $ODataType -replace '^#microsoft\.graph\.', ''
            switch -Wildcard ($c) {
                'windows*'         { return 'Windows' }
                'sharedPC*'        { return 'Windows' }
                'editionUpgrade*'  { return 'Windows' }
                'windows81*'       { return 'Windows' }
                'windowsPhone*'    { return 'Windows' }
                'ios*'             { return 'iOS/iPadOS' }
                'macOS*'           { return 'macOS' }
                'android*'         { return 'Android' }
                'aosp*'            { return 'AOSP' }
                'linux*'           { return 'Linux' }
                default             { return '' }
            }
        }
        'groupPolicyConfigurations'     { return 'Windows' }
        'deviceManagementScripts'       { return 'Windows' }
        'deviceHealthScripts'           { return 'Windows' }
        'windowsAutopilotDeploymentProfiles' { return 'Windows' }
        default { return '' }
    }
}

# ── Panel open ──
$script:Window.FindName('BtnOpGetPolicyAssignments').Add_Click({
    $script:Window.FindName('TxtGPANameFilter').Text            = ''
    $script:Window.FindName('CmbGPAType').SelectedIndex         = 0
    $script:Window.FindName('CmbGPASubType').SelectedIndex      = 0
    $script:Window.FindName('CmbGPAPlatform').SelectedIndex     = 0
    $script:Window.FindName('PnlGPAProgress').Visibility        = 'Collapsed'
    $script:Window.FindName('PnlGPAResults').Visibility         = 'Collapsed'
    $script:Window.FindName('TxtGPANoResults').Visibility       = 'Collapsed'
    foreach ($b in @('BtnGPACopyValue','BtnGPACopyRow','BtnGPACopyAll','BtnGPAExportXlsx')) {
        $script:Window.FindName($b).IsEnabled = $false
    }
    $dg = $script:Window.FindName('DgGPAResults'); if ($dg) { $dg.ItemsSource = $null }
    $script:GPAResult = $null
    Show-Panel 'PanelGetPolicyAssignments'
    Hide-Notification
    Write-VerboseLog 'Panel: Get Policy Assignments' -Level Info
})

# ── Shared launch function (called by both Get Selected and Get All buttons) ──
function Start-GPAQuery {
    param([bool]$ApplyFilters)

    Hide-Notification
    $script:Window.FindName('PnlGPAResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtGPANoResults').Visibility = 'Collapsed'
    foreach ($b in @('BtnGPACopyValue','BtnGPACopyRow','BtnGPACopyAll','BtnGPAExportXlsx')) {
        $script:Window.FindName($b).IsEnabled = $false
    }
    $dg = $script:Window.FindName('DgGPAResults'); if ($dg) { $dg.ItemsSource = $null }

    $nameKw   = if ($ApplyFilters) { $script:Window.FindName('TxtGPANameFilter').Text.Trim() } else { '' }
    $typeF    = if ($ApplyFilters) { $script:Window.FindName('CmbGPAType').SelectedItem.Content }  else { '(All types)' }
    $subTypeF = if ($ApplyFilters) { $script:Window.FindName('CmbGPASubType').SelectedItem.Content } else { '(All sub-types)' }
    $platF    = if ($ApplyFilters) { $script:Window.FindName('CmbGPAPlatform').SelectedItem.Content } else { '(All platforms)' }

    $script:GPAParams = @{
        NameKeyword = $nameKw
        TypeFilter  = $typeF
        SubTypeFilter = $subTypeF
        PlatFilter  = $platF
    }

    $script:Window.FindName('PnlGPAProgress').Visibility  = 'Visible'
    $script:Window.FindName('TxtGPAProgressMsg').Text     = 'Loading policies...'
    $script:Window.FindName('TxtGPAProgressDetail').Text  = ''

    foreach ($b in @('BtnGPAGetSelected','BtnGPAGetAll')) {
        $script:Window.FindName($b).IsEnabled = $false
    }
    Write-VerboseLog ('--- Get Policy Assignments: starting (ApplyFilters=' + $ApplyFilters + ') ---') -Level Action

    Invoke-OnBackground -BusyMessage '--- Get Policy Assignments ---' -Work {

        $params     = $script:GPAParams
        $nameKw     = $params['NameKeyword']
        $typeFilter = $params['TypeFilter']
        $subFilter  = $params['SubTypeFilter']
        $platFilter = $params['PlatFilter']

        # ── Helper: page through Graph collection ──────────────────────────────
        function GPA-Page { param([string]$Uri)
            $all = [System.Collections.Generic.List[object]]::new()
            $next = $Uri
            while ($next) {
                $r = Invoke-MgGraphRequest -Method GET -Uri $next
                if ($r['value']) { foreach ($v in @($r['value'])) { $all.Add($v) } }
                $next = $r['@odata.nextLink']
            }
            return $all
        }

        # ── Load assignment filters lookup ─────────────────────────────────────
        Write-VerboseLog 'GPA: Loading assignment filters...' -Level Info
        $filterLookup = @{}
        try {
            $filters = GPA-Page -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?$select=id,displayName'
            foreach ($f in $filters) { if ($f['id']) { $filterLookup[$f['id']] = $f['displayName'] } }
            Write-VerboseLog "GPA: $($filterLookup.Count) assignment filter(s) loaded" -Level Info
        } catch { Write-VerboseLog "GPA: Could not load assignment filters: $($_.Exception.Message)" -Level Warning }

        # ── Group display name cache ───────────────────────────────────────────
        $groupCache = @{}
        function GPA-GroupName { param([string]$GroupId)
            if (-not $GroupId) { return '' }
            if ($groupCache.ContainsKey($GroupId)) { return $groupCache[$GroupId] }
            try {
                $enc = [Uri]::EscapeDataString($GroupId)
                $g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/${enc}?`$select=id,displayName"
                $groupCache[$GroupId] = $g['displayName']; return $g['displayName']
            } catch { $groupCache[$GroupId] = $GroupId; return $GroupId }
        }

        # ── Decide which collections to query ──────────────────────────────────
        $collections = @(
            @{ Key='deviceConfigurations';              Label='Device Configuration';                  Uri='https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$select=id,displayName' }
            @{ Key='configurationPolicies';             Label='Configuration Policy';                  Uri='https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$select=id,name,platforms,technologies,templateReference' }
            @{ Key='groupPolicyConfigurations';         Label='Administrative Templates';              Uri='https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations?$select=id,displayName' }
            @{ Key='deviceManagementScripts';           Label='Platform Script (Windows PowerShell)'; Uri='https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$select=id,displayName' }
            @{ Key='deviceHealthScripts';               Label='Detection/Remediation Script';         Uri='https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' }
            @{ Key='windowsAutopilotDeploymentProfiles';Label='Autopilot Deployment Profile';         Uri='https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName' }
        )

        # Skip collections not matching the type filter
        if ($typeFilter -ne '(All types)') {
            $collections = $collections | Where-Object { $_['Label'] -eq $typeFilter }
        }

        # ── Platform-based collection pre-filter (skip Windows-only endpoints) ──
        $winOnlyKeys = @('groupPolicyConfigurations','deviceManagementScripts',
                         'deviceHealthScripts','windowsAutopilotDeploymentProfiles')
        if ($platFilter -ne '(All platforms)' -and $platFilter -ne 'Windows') {
            $before = @($collections).Count
            $skippedNames = @($collections | Where-Object { $winOnlyKeys -contains $_['Key'] }) | ForEach-Object { $_['Label'] }
            $collections  = @($collections | Where-Object { $winOnlyKeys -notcontains $_['Key'] })
            if ($skippedNames.Count -gt 0) {
                Write-VerboseLog ("GPA: Skipped " + $skippedNames.Count + " Windows-only collection(s): " + ($skippedNames -join ', ')) -Level Info
            }
        }

        # ── Server-side platform filter for configurationPolicies ──
        $platApiMap = @{ 'Windows'='windows10'; 'macOS'='macOS'; 'iOS/iPadOS'='iOS';
                         'Android'='android'; 'AOSP'='androidAOSP'; 'Linux'='linux' }
        if ($platFilter -ne '(All platforms)' -and $platApiMap.ContainsKey($platFilter)) {
            $apiPlat = $platApiMap[$platFilter]
            foreach ($c in $collections) {
                if ($c['Key'] -eq 'configurationPolicies') {
                    $c['OriginalUri'] = $c['Uri']
                    $c['Uri'] += "&`$filter=platforms has '$apiPlat'"
                    Write-VerboseLog "GPA: Server-side platform filter added for Configuration Policy ($apiPlat)" -Level Info
                    break
                }
            }
        }

        $rows = [System.Collections.Generic.List[PSCustomObject]]::new()

        foreach ($col in $collections) {
            Write-VerboseLog "GPA: Loading $($col['Label'])..." -Level Info
            $policies = @()
            try { $policies = @(GPA-Page -Uri $col['Uri']) }
            catch {
                if ($col['OriginalUri']) {
                    Write-VerboseLog "GPA: Server-side filter failed, falling back to full list..." -Level Warning
                    try { $policies = @(GPA-Page -Uri $col['OriginalUri']) }
                    catch { Write-VerboseLog "GPA: Failed to load $($col['Key']): $($_.Exception.Message)" -Level Warning; continue }
                } else { Write-VerboseLog "GPA: Failed to load $($col['Key']): $($_.Exception.Message)" -Level Warning; continue }
            }
            Write-VerboseLog "GPA: $($policies.Count) $($col['Label']) policy/policies found" -Level Info

            $pIdx = 0
            foreach ($policy in $policies) {
                $pIdx++

                # Get display name (field differs per endpoint)
                $pName = if ($col['Key'] -eq 'configurationPolicies') { [string]$policy['name'] }
                         else { [string]$policy['displayName'] }

                # Keyword filter
                if ($nameKw -and ($pName -notlike "*$nameKw*")) { continue }

                # Derive sub-type and platform
                $odataType  = [string]$policy['@odata.type']
                $tmplName   = if ($policy['templateReference']) { [string]$policy['templateReference']['templateDisplayName'] } else { '' }
                $platforms  = [string]$policy['platforms']
                $subType    = Get-GPASubType -PolicyCollection $col['Key'] -ODataType $odataType -TemplateDisplayName $tmplName
                $platform   = Get-GPAPlatform -PolicyCollection $col['Key'] -ODataType $odataType -Platforms $platforms

                # Sub-type filter
                if ($subFilter -ne '(All sub-types)' -and $subType -ne $subFilter) { continue }
                # Platform filter
                if ($platFilter -ne '(All platforms)' -and $platform -ne $platFilter) { continue }

                # Get assignments for this policy
                $assignUri = "https://graph.microsoft.com/beta/deviceManagement/$($col['Key'])/$($policy['id'])/assignments"
                $assignments = @()
                try { $assignments = @(GPA-Page -Uri $assignUri) }
                catch { Write-VerboseLog "GPA: Could not get assignments for '$pName': $($_.Exception.Message)" -Level Warning; continue }

                if ($assignments.Count -eq 0) {
                    $rows.Add([PSCustomObject]@{
                        PolicyName   = $pName
                        PolicyType   = $col['Label']
                        PolicySubType = $subType
                        OSPlatform   = $platform
                        IncludedGroup = ''
                        ExcludedGroup = ''
                        IncludeFilter = ''
                        ExcludeFilter = ''
                    }); continue
                }

                foreach ($asgn in $assignments) {
                    $target    = $asgn['target']
                    if (-not $target) { continue }

                    $otype     = [string]$target['@odata.type']
                    $isExclude = $otype -match 'exclusionGroup'
                    $groupId   = if ($target['groupId']) { [string]$target['groupId'] } else { [string]$target['entraObjectId'] }

                    $targetName = ''
                    if ($groupId) {
                        $targetName = GPA-GroupName -GroupId $groupId
                    } elseif ($otype -match 'allDevices') {
                        $targetName = '[All devices]'
                    } elseif ($otype -match 'allLicensedUsers|allUsers') {
                        $targetName = '[All users]'
                    } else { continue }

                    $filterId   = [string]$target['deviceAndAppManagementAssignmentFilterId']
                    $filterType = [string]$target['deviceAndAppManagementAssignmentFilterType']
                    $filterName = if ($filterId -and $filterLookup.ContainsKey($filterId)) { $filterLookup[$filterId] } else { $filterId }

                    $rows.Add([PSCustomObject]@{
                        PolicyName    = $pName
                        PolicyType    = $col['Label']
                        PolicySubType = $subType
                        OSPlatform    = $platform
                        IncludedGroup = if (-not $isExclude) { $targetName } else { '' }
                        ExcludedGroup = if ($isExclude) { $targetName } else { '' }
                        IncludeFilter = if ($filterType -eq 'include') { $filterName } else { '' }
                        ExcludeFilter = if ($filterType -eq 'exclude') { $filterName } else { '' }
                    })
                }
            }
        }

        $script:GPAResult = $rows
        Write-VerboseLog "GPA: Complete. $($rows.Count) assignment row(s) returned." -Level Info

    } -Done {

        foreach ($b in @('BtnGPAGetSelected','BtnGPAGetAll')) {
            $script:Window.FindName($b).IsEnabled = $true
        }
        $script:Window.FindName('PnlGPAProgress').Visibility = 'Collapsed'

        $rows = $script:GPAResult
        if (-not $rows -or $rows.Count -eq 0) {
            $script:Window.FindName('TxtGPANoResults').Visibility = 'Visible'
            Write-VerboseLog 'GPA: No results to display.' -Level Warning
            return
        }

        $sorted = $rows | Sort-Object PolicyName, PolicyType, IncludedGroup, ExcludedGroup
        $dg = $script:Window.FindName('DgGPAResults')
        $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($sorted)

        $script:Window.FindName('TxtGPACount').Text = "$($rows.Count) assignment row(s)"
        $script:Window.FindName('BtnGPACopyAll').IsEnabled    = $true
        $script:Window.FindName('TxtGPAFilter').IsEnabled  = $true
        $script:Window.FindName('BtnGPAFilter').IsEnabled  = $true
        $script:Window.FindName('BtnGPAFilterClear').IsEnabled = $true
        $script:Window.FindName('BtnGPAExportXlsx').IsEnabled = $true
        $script:Window.FindName('PnlGPAResults').Visibility   = 'Visible'

        # Enable Copy Value / Copy Row on selection
        $dg.Add_SelectedCellsChanged({
            $selCells = $script:Window.FindName('DgGPAResults').SelectedCells.Count
            $script:Window.FindName('BtnGPACopyValue').IsEnabled = ($selCells -ge 1)
            $script:Window.FindName('BtnGPACopyRow').IsEnabled   = ($selCells -gt 0)
        })
    }
}

$script:Window.FindName('BtnGPAGetSelected').Add_Click({ Start-GPAQuery -ApplyFilters $true })
$script:Window.FindName('BtnGPAGetAll').Add_Click(     { Start-GPAQuery -ApplyFilters $false })

# ── Sub-Type filter: grey-out Policy Type when a specific sub-type is chosen ──
$script:Window.FindName('CmbGPASubType').Add_SelectionChanged({
    $sub  = $script:Window.FindName('CmbGPASubType')
    $type = $script:Window.FindName('CmbGPAType')
    if ($sub.SelectedIndex -le 0) {
        # Back to "All sub-types" — restore Policy Type filter
        $type.IsEnabled = $true
    } else {
        # Specific sub-type chosen — clear and disable Policy Type to avoid contradictions
        $type.SelectedIndex = 0
        $type.IsEnabled     = $false
    }
})

# ── Copy / Export buttons ──
# ── BtnGPAFilter ──
$script:Window.FindName('BtnGPAFilter').Add_Click({
    $dg      = $script:Window.FindName('DgGPAResults')
    $keyword = $script:Window.FindName('TxtGPAFilter').Text.Trim()
    if ($null -eq $script:GPAAllData) {
        $script:GPAAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:GPAAllData
        Show-Notification "Filter cleared - $($script:GPAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:GPAAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnGPAFilterClear ──
$script:Window.FindName('BtnGPAFilterClear').Add_Click({
    $script:Window.FindName('TxtGPAFilter').Text = ''
    $dg = $script:Window.FindName('DgGPAResults')
    if ($null -ne $script:GPAAllData) {
        $dg.ItemsSource = $script:GPAAllData
        Show-Notification "Filter cleared - $($script:GPAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:GPAAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "GPA: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnGPACopyValue').Add_Click({
    $dg = $script:Window.FindName('DgGPAResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
})

$script:Window.FindName('BtnGPACopyRow').Add_Click({
    $dg   = $script:Window.FindName('DgGPAResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $lines = foreach ($r in $rows) {
        "$($r.PolicyName)`t$($r.PolicyType)`t$($r.PolicySubType)`t$($r.OSPlatform)`t$($r.IncludedGroup)`t$($r.ExcludedGroup)`t$($r.IncludeFilter)`t$($r.ExcludeFilter)"
    }
    [System.Windows.Clipboard]::SetText(($lines -join [Environment]::NewLine))
    Show-Notification "Copied $($rows.Count) row(s)." -BgColor '#D4EDDA' -FgColor '#155724'
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

$script:Window.FindName('BtnGPACopyAll').Add_Click({
    $dg   = $script:Window.FindName('DgGPAResults')
    $src  = @($dg.ItemsSource)
    $hdr  = "Policy Name`tType`tSub-Type`tOS Platform`tIncluded Group`tExcluded Group`tInclude Filter`tExclude Filter"
    $body = foreach ($r in $src) {
        "$($r.PolicyName)`t$($r.PolicyType)`t$($r.PolicySubType)`t$($r.OSPlatform)`t$($r.IncludedGroup)`t$($r.ExcludedGroup)`t$($r.IncludeFilter)`t$($r.ExcludeFilter)"
    }
    [System.Windows.Clipboard]::SetText(($hdr, ($body -join [Environment]::NewLine) -join [Environment]::NewLine))
    Show-Notification "Copied $($src.Count) row(s) with headers." -BgColor '#D4EDDA' -FgColor '#155724'
})

$script:Window.FindName('BtnGPAExportXlsx').Add_Click({
    $dg  = $script:Window.FindName('DgGPAResults')
    $src = @($dg.ItemsSource)
    if ($src.Count -eq 0) { Show-Notification 'No data to export.' -BgColor '#FFF3CD' -FgColor '#7A4800'; return }

    $stamp    = Get-Date -Format 'yyyyMMdd_HHmmss'
    $xlsxPath = [System.IO.Path]::Combine($env:USERPROFILE, 'Downloads', "GPAExport_$stamp.xlsx")

    try {
        $headers = @('Policy Name','Type','Sub-Type','OS Platform','Included Group','Excluded Group','Include Filter','Exclude Filter')
        $props   = @('PolicyName','PolicyType','PolicySubType','OSPlatform','IncludedGroup','ExcludedGroup','IncludeFilter','ExcludeFilter')
        Export-ToXlsx -Rows $src -Headers $headers -Properties $props -Path $xlsxPath
        Show-Notification "Exported $($src.Count) rows → $([System.IO.Path]::GetFileName($xlsxPath))" -BgColor '#D4EDDA' -FgColor '#155724'
        Write-VerboseLog "GPA: XLSX exported to $xlsxPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "GPA: XLSX export error: $($_.Exception.Message)" -Level Error
    }
})


# ── FIND COMMON GROUPS ────────────────────────────────────────────────────

# Update the input label when the input type RadioButton changes
$script:Window.FindName('RbFCUsers').Add_Checked({
    $script:Window.FindName('TxtFCInputLabel').Text = 'USER UPNs  -  one per line'
    $script:Window.FindName('TxtFCInputList').Tag   = 'user1@domain.com'
})
$script:Window.FindName('RbFCDevices').Add_Checked({
    $script:Window.FindName('TxtFCInputLabel').Text = 'DEVICE NAMES or Object IDs  -  one per line'
    $script:Window.FindName('TxtFCInputList').Tag   = 'LAPTOP-001'
})
$script:Window.FindName('RbFCGroups').Add_Checked({
    $script:Window.FindName('TxtFCInputLabel').Text = 'GROUP NAMES or Object IDs  -  one per line'
    $script:Window.FindName('TxtFCInputList').Tag   = 'SG-Finance-All'
})

$script:Window.FindName('BtnOpFindCommon').Add_Click({
    $script:Window.FindName('TxtFCInputList').Text  = ''
    $script:Window.FindName('RbFCUsers').IsChecked  = $true
    $script:Window.FindName('TxtFCInputLabel').Text = 'USER UPNs  -  one per line'
    $script:Window.FindName('TxtFCInputList').Tag   = 'user1@domain.com'
    Show-Panel 'PanelFindCommon'
    Hide-Notification
    Write-VerboseLog 'Panel: Find Common Groups' -Level Info
})

$script:Window.FindName('BtnFCFind').Add_Click({
    Hide-Notification

    $inputTxt = $script:Window.FindName('TxtFCInputList').Text
    if ([string]::IsNullOrWhiteSpace($inputTxt)) {
        Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    $inputType = if ($script:Window.FindName('RbFCUsers').IsChecked)      { 'Users' }
                 elseif ($script:Window.FindName('RbFCDevices').IsChecked) { 'Devices' }
                 else { 'Groups' }

    $script:FCParams = @{ InputTxt = $inputTxt; InputType = $inputType }
    $btn = $script:Window.FindName('BtnFCFind')
    $script:Window.FindName('PnlFCResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtFCNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlFCProgress').Visibility  = 'Visible'
    $script:Window.FindName('TxtFCProgressMsg').Text     = "Resolving $inputType..."
    $script:Window.FindName('TxtFCProgressDetail').Text  = ''
    $script:Window.FindName('BtnFCCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnFCCopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnFCCopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtFCFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFCFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFCFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtFCFilter').Text       = ''
    $script:FCAllData = $null
    $script:Window.FindName('BtnFCExportXlsx').IsEnabled = $false

    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Find Common Groups: resolving $inputType ---" -Work {
        $params    = $script:FCParams
        $inputType = $params['InputType']
        $entries   = $params['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }

        Write-VerboseLog "Input type : $inputType" -Level Info
        Write-VerboseLog "Entries    : $($entries.Count)" -Level Info

        if ($entries.Count -lt 2) {
            throw 'Please enter at least two identifiers to find common groups.'
        }

        # ── Step 1: resolve each entry to an Entra Object ID ─────────────────
        $resolvedIds    = [System.Collections.Generic.List[string]]::new()
        $resolvedLabels = [System.Collections.Generic.List[string]]::new()
        $failedEntries  = [System.Collections.Generic.List[string]]::new()
        $isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'

        foreach ($entry in $entries) {
            $objId = $null; $label = $entry
            try {
                switch ($inputType) {
                    'Users' {
                        $u = Invoke-MgGraphRequest -Method GET `
                            -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($entry))?`$select=id,displayName"
                        $objId = $u['id']; $label = $u['displayName']
                    }
                    'Devices' {
                        if ($entry -match $isGuidRx) {
                            $d = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/devices/$entry`?`$select=id,displayName"
                            $objId = $d['id']; $label = $d['displayName']
                        } else {
                            $safe = $entry -replace "'","''"
                            $dr = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
                            if ($dr['value'] -and $dr['value'].Count -gt 0) {
                                $objId = $dr['value'][0]['id']; $label = $dr['value'][0]['displayName']
                            } else { throw "Device not found: $entry" }
                        }
                    }
                    'Groups' {
                        if ($entry -match $isGuidRx) {
                            $g = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/groups/$entry`?`$select=id,displayName"
                            $objId = $g['id']; $label = $g['displayName']
                        } else {
                            $safe = $entry -replace "'","''"
                            $gr = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
                            if ($gr['value'] -and $gr['value'].Count -gt 0) {
                                $objId = $gr['value'][0]['id']; $label = $gr['value'][0]['displayName']
                            } else { throw "Group not found: $entry" }
                        }
                    }
                }
                Write-VerboseLog "  $([char]0x2713) $label  ($objId)" -Level Success
                $resolvedIds.Add($objId)
                $resolvedLabels.Add($label)
            } catch {
                Write-VerboseLog "  $([char]0x2717) Could not resolve: $entry  -  $($_.Exception.Message)" -Level Warning
                $failedEntries.Add($entry)
            }
        }

        if ($resolvedIds.Count -eq 0) {
            throw 'None of the entries could be resolved in Entra. Check identifiers and try again.'
        }
        if ($resolvedIds.Count -lt 2) {
            throw "Only one entry was resolved ($($resolvedLabels[0])). At least two are needed to find common groups."
        }

        # ── Step 2: fetch group memberships for each object ──────────────────
        $membershipSets = [System.Collections.Generic.List[System.Collections.Generic.HashSet[string]]]::new()
        $groupMeta      = [System.Collections.Generic.Dictionary[string,hashtable]]::new()

        for ($i = 0; $i -lt $resolvedIds.Count; $i++) {
            $objId  = $resolvedIds[$i]
            $objLbl = $resolvedLabels[$i]
            Write-VerboseLog "Fetching memberships for: $objLbl" -Level Info

            $groupIds     = [System.Collections.Generic.HashSet[string]]::new()
            $baseEndpoint = if ($inputType -eq 'Users') {
                "https://graph.microsoft.com/v1.0/users/$objId/transitiveMemberOf"
            } else {
                "https://graph.microsoft.com/v1.0/directoryObjects/$objId/transitiveMemberOf"
            }
            $nextUri = $baseEndpoint + "?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRuleProcessingState`&`$top=999"
            do {
                try {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                    foreach ($obj in @($resp['value'])) {
                        $odataType = [string]$obj['@odata.type']
                        if ($odataType -and $odataType -ne '#microsoft.graph.group') { continue }
                        $gid = $obj['id']
                        $null = $groupIds.Add($gid)
                        if (-not $groupMeta.ContainsKey($gid)) {
                            $groupMeta[$gid] = @{
                                DisplayName                   = [string]$obj['displayName']
                                GroupTypes                    = @($obj['groupTypes'])
                                MailEnabled                   = [bool]$obj['mailEnabled']
                                SecurityEnabled               = [bool]$obj['securityEnabled']
                                MembershipRuleProcessingState = [string]$obj['membershipRuleProcessingState']
                            }
                        }
                    }
                    $nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
                } catch {
                    Write-VerboseLog "  [!] Membership fetch error for $objLbl : $($_.Exception.Message)" -Level Warning
                    $nextUri = $null
                }
            } while ($nextUri)

            Write-VerboseLog "  $($groupIds.Count) group(s)" -Level Info
            $membershipSets.Add($groupIds)
        }

        # ── Step 3: intersect ────────────────────────────────────────────────
        $commonIds = [System.Collections.Generic.HashSet[string]]::new($membershipSets[0])
        for ($i = 1; $i -lt $membershipSets.Count; $i++) {
            $commonIds.IntersectWith($membershipSets[$i])
        }

        # ── Step 4: build flat result list ────────────────────────────────────
        $fcList = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($gid in $commonIds) {
            $meta = $groupMeta[$gid]
            if ($meta['GroupTypes'] -contains 'Unified') {
                $gType = 'M365'
            } elseif ($meta['MailEnabled'] -and $meta['SecurityEnabled']) {
                $gType = 'Mail-Enabled Security'
            } elseif ($meta['SecurityEnabled']) {
                $gType = 'Security'
            } else {
                $gType = 'Other'
            }
            $mType = if ($meta['MembershipRuleProcessingState'] -eq 'On') { 'Dynamic' } else { 'Assigned' }
            $fcList.Add([PSCustomObject]@{
                GroupName      = $meta['DisplayName']
                GroupID        = $gid
                GroupType      = $gType
                MembershipType = $mType
            })
        }
        $script:FCResult = [System.Collections.Generic.List[PSCustomObject]]($fcList | Sort-Object GroupType, GroupName)

        $cgLevel = if ($commonIds.Count -gt 0) { 'Success' } else { 'Warning' }
        Write-VerboseLog "Common groups total: $($commonIds.Count)" -Level $cgLevel
        if ($failedEntries.Count -gt 0) {
            Write-VerboseLog "  Skipped (unresolved): $($failedEntries -join ', ')" -Level Warning
        }

    } -Done {
        $script:Window.FindName('PnlFCProgress').Visibility = 'Collapsed'
        $resultList = $script:FCResult
        $count = if ($resultList) { $resultList.Count } else { 0 }

        if ($count -gt 0) {
            $script:Window.FindName('DgFCResults').ItemsSource    = $resultList
            $script:Window.FindName('TxtFCCount').Text            = "$count common group(s) found"
            $script:Window.FindName('PnlFCResults').Visibility    = 'Visible'
            $script:Window.FindName('TxtFCNoResults').Visibility  = 'Collapsed'
            $script:Window.FindName('BtnFCCopyAll').IsEnabled     = $true
            $script:Window.FindName('TxtFCFilter').IsEnabled  = $true
            $script:Window.FindName('BtnFCFilter').IsEnabled  = $true
            $script:Window.FindName('BtnFCFilterClear').IsEnabled = $true
            $script:Window.FindName('BtnFCExportXlsx').IsEnabled  = $true
            Write-VerboseLog "Find Common Groups complete. Found $count group(s)." -Level Success
        } else {
            $script:Window.FindName('PnlFCResults').Visibility    = 'Collapsed'
            $script:Window.FindName('TxtFCNoResults').Visibility  = 'Visible'
            Write-VerboseLog "Find Common Groups complete. No common groups found." -Level Warning
        }
    }
})

# ── DgFCResults: cell selection ───────────────────────────────────────────────
$script:Window.FindName('DgFCResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgFCResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnFCCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnFCCopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnFCCopyValue ────────────────────────────────────────────────────────────
# ── BtnFCFilter ──
$script:Window.FindName('BtnFCFilter').Add_Click({
    $dg      = $script:Window.FindName('DgFCResults')
    $keyword = $script:Window.FindName('TxtFCFilter').Text.Trim()
    if ($null -eq $script:FCAllData) {
        $script:FCAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:FCAllData
        Show-Notification "Filter cleared - $($script:FCAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:FCAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnFCFilterClear ──
$script:Window.FindName('BtnFCFilterClear').Add_Click({
    $script:Window.FindName('TxtFCFilter').Text = ''
    $dg = $script:Window.FindName('DgFCResults')
    if ($null -ne $script:FCAllData) {
        $dg.ItemsSource = $script:FCAllData
        Show-Notification "Filter cleared - $($script:FCAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:FCAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "FC: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnFCCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgFCResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "FC: Copied cell value to clipboard." -Level Success
})

# ── BtnFCCopyRow ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFCCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgFCResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $rows) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "FC: Copied $($rows.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnFCCopyAll ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFCCopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgFCResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "FC: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnFCExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnFCExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgFCResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title            = "Export Find Common Groups Results"
    $dlg.Filter           = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
    $dlg.FileName         = "FindCommonGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $row = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName "CommonGroups" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "FC: Exported $($allItems.Count) rows to: $outPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "FC: Export failed: $($_.Exception.Message)" -Level Error
    }
})



# ── FIND DISTINCT GROUPS ────────────────────────────────────────────────────

# Update the input label when the input type RadioButton changes
$script:Window.FindName('RbFDUsers').Add_Checked({
    $script:Window.FindName('TxtFDInputLabel').Text = 'USER UPNs  -  one per line'
    $script:Window.FindName('TxtFDInputList').Tag   = 'user1@domain.com'
})
$script:Window.FindName('RbFDDevices').Add_Checked({
    $script:Window.FindName('TxtFDInputLabel').Text = 'DEVICE NAMES or Object IDs  -  one per line'
    $script:Window.FindName('TxtFDInputList').Tag   = 'LAPTOP-001'
})
$script:Window.FindName('RbFDGroups').Add_Checked({
    $script:Window.FindName('TxtFDInputLabel').Text = 'GROUP NAMES or Object IDs  -  one per line'
    $script:Window.FindName('TxtFDInputList').Tag   = 'SG-Finance-All'
})

$script:Window.FindName('BtnOpFindDistinct').Add_Click({
    $script:Window.FindName('TxtFDInputList').Text  = ''
    $script:Window.FindName('RbFDUsers').IsChecked  = $true
    $script:Window.FindName('TxtFDInputLabel').Text = 'USER UPNs  -  one per line'
    $script:Window.FindName('TxtFDInputList').Tag   = 'user1@domain.com'
    Show-Panel 'PanelFindDistinct'
    Hide-Notification
    Write-VerboseLog 'Panel: Find Distinct Groups' -Level Info
})

$script:Window.FindName('BtnFDFind').Add_Click({
    Hide-Notification

    $inputTxt = $script:Window.FindName('TxtFDInputList').Text
    if ([string]::IsNullOrWhiteSpace($inputTxt)) {
        Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    $inputType = if ($script:Window.FindName('RbFDUsers').IsChecked)      { 'Users' }
                 elseif ($script:Window.FindName('RbFDDevices').IsChecked) { 'Devices' }
                 else { 'Groups' }

    $script:FDParams = @{ InputTxt = $inputTxt; InputType = $inputType }
    $btn = $script:Window.FindName('BtnFDFind')
    $script:Window.FindName('PnlFDResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtFDNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlFDProgress').Visibility  = 'Visible'
    $script:Window.FindName('TxtFDProgressMsg').Text     = "Resolving $inputType..."
    $script:Window.FindName('TxtFDProgressDetail').Text  = ''
    $script:Window.FindName('BtnFDCopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnFDCopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnFDCopyAll').IsEnabled    = $false
    $script:Window.FindName('BtnFDFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFDFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtFDFilter').Text       = ''
    $script:FDAllData = $null
    $script:Window.FindName('TxtFDFilter').IsEnabled  = $false
    $script:Window.FindName('BtnFDExportXlsx').IsEnabled = $false

    Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Find Distinct Groups: resolving $inputType ---" -Work {
        $params    = $script:FDParams
        $inputType = $params['InputType']
        $entries   = $params['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }

        Write-VerboseLog "Input type : $inputType" -Level Info
        Write-VerboseLog "Entries    : $($entries.Count)" -Level Info

        if ($entries.Count -lt 2) {
            throw 'Please enter at least two identifiers to find distinct groups.'
        }

        # ── Step 1: resolve each entry to an Entra Object ID ─────────────────
        $resolvedIds    = [System.Collections.Generic.List[string]]::new()
        $resolvedLabels = [System.Collections.Generic.List[string]]::new()
        $failedEntries  = [System.Collections.Generic.List[string]]::new()
        $isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'

        foreach ($entry in $entries) {
            $objId = $null; $label = $entry
            try {
                switch ($inputType) {
                    'Users' {
                        $u = Invoke-MgGraphRequest -Method GET `
                            -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($entry))?`$select=id,displayName,userPrincipalName"
                        $objId = $u['id']; $label = $u['userPrincipalName']
                    }
                    'Devices' {
                        if ($entry -match $isGuidRx) {
                            $d = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/devices/$entry`?`$select=id,displayName"
                            $objId = $d['id']; $label = $d['displayName']
                        } else {
                            $safe = $entry -replace "'","''"
                            $dr = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
                            if ($dr['value'] -and $dr['value'].Count -gt 0) {
                                $objId = $dr['value'][0]['id']; $label = $dr['value'][0]['displayName']
                            } else { throw "Device not found: $entry" }
                        }
                    }
                    'Groups' {
                        if ($entry -match $isGuidRx) {
                            $g = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/groups/$entry`?`$select=id,displayName"
                            $objId = $g['id']; $label = $g['displayName']
                        } else {
                            $safe = $entry -replace "'","''"
                            $gr = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
                            if ($gr['value'] -and $gr['value'].Count -gt 0) {
                                $objId = $gr['value'][0]['id']; $label = $gr['value'][0]['displayName']
                            } else { throw "Group not found: $entry" }
                        }
                    }
                }
                Write-VerboseLog "  $([char]0x2713) $label  ($objId)" -Level Success
                $resolvedIds.Add($objId)
                $resolvedLabels.Add($label)
            } catch {
                Write-VerboseLog "  $([char]0x2717) Could not resolve: $entry  -  $($_.Exception.Message)" -Level Warning
                $failedEntries.Add($entry)
            }
        }

        if ($resolvedIds.Count -eq 0) {
            throw 'None of the entries could be resolved in Entra. Check identifiers and try again.'
        }
        if ($resolvedIds.Count -lt 2) {
            throw ('Only one entry was resolved (' + $resolvedLabels[0] + '). At least two are needed to find distinct groups.')
        }

        # ── Step 2: fetch group memberships for each object ──────────────────
        $membershipSets = [System.Collections.Generic.List[System.Collections.Generic.HashSet[string]]]::new()
        $groupMeta      = [System.Collections.Generic.Dictionary[string,hashtable]]::new()

        for ($i = 0; $i -lt $resolvedIds.Count; $i++) {
            $objId  = $resolvedIds[$i]
            $objLbl = $resolvedLabels[$i]
            Write-VerboseLog "Fetching memberships for: $objLbl" -Level Info

            $groupIds     = [System.Collections.Generic.HashSet[string]]::new()
            $baseEndpoint = if ($inputType -eq 'Users') {
                "https://graph.microsoft.com/v1.0/users/$objId/transitiveMemberOf"
            } else {
                "https://graph.microsoft.com/v1.0/directoryObjects/$objId/transitiveMemberOf"
            }
            $nextUri = $baseEndpoint + "?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRuleProcessingState`&`$top=999"
            do {
                try {
                    $resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                    foreach ($obj in @($resp['value'])) {
                        $odataType = [string]$obj['@odata.type']
                        if ($odataType -and $odataType -ne '#microsoft.graph.group') { continue }
                        $gid = $obj['id']
                        $null = $groupIds.Add($gid)
                        if (-not $groupMeta.ContainsKey($gid)) {
                            $groupMeta[$gid] = @{
                                DisplayName                   = [string]$obj['displayName']
                                GroupTypes                    = @($obj['groupTypes'])
                                MailEnabled                   = [bool]$obj['mailEnabled']
                                SecurityEnabled               = [bool]$obj['securityEnabled']
                                MembershipRuleProcessingState = [string]$obj['membershipRuleProcessingState']
                            }
                        }
                    }
                    $nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
                } catch {
                    Write-VerboseLog "  [!] Membership fetch error for $objLbl : $($_.Exception.Message)" -Level Warning
                    $nextUri = $null
                }
            } while ($nextUri)

            Write-VerboseLog "  $($groupIds.Count) group(s)" -Level Info
            $membershipSets.Add($groupIds)
        }

        # ── Step 3: compute union minus intersection (distinct groups) ────────
        $unionIds = [System.Collections.Generic.HashSet[string]]::new($membershipSets[0])
        for ($i = 1; $i -lt $membershipSets.Count; $i++) {
            $unionIds.UnionWith($membershipSets[$i])
        }
        $intersectIds = [System.Collections.Generic.HashSet[string]]::new($membershipSets[0])
        for ($i = 1; $i -lt $membershipSets.Count; $i++) {
            $intersectIds.IntersectWith($membershipSets[$i])
        }
        # Distinct = groups in the union but NOT in the intersection
        # (i.e. groups that belong to SOME but NOT ALL input objects)
        $distinctIds = [System.Collections.Generic.HashSet[string]]::new($unionIds)
        $distinctIds.ExceptWith($intersectIds)


        # ── Step 3b: map distinct groups → input object labels ──────────────
        $groupOwnerMap = [System.Collections.Generic.Dictionary[string,System.Collections.Generic.List[string]]]::new()
        foreach ($gid in $distinctIds) {
            $owners = [System.Collections.Generic.List[string]]::new()
            for ($i = 0; $i -lt $membershipSets.Count; $i++) {
                if ($membershipSets[$i].Contains($gid)) { $owners.Add($resolvedLabels[$i]) }
            }
            $groupOwnerMap[$gid] = $owners
        }
        # ── Step 4: build flat result list ────────────────────────────────────
        $fdList = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($gid in $distinctIds) {
            $meta = $groupMeta[$gid]
            if ($meta['GroupTypes'] -contains 'Unified') {
                $gType = 'M365'
            } elseif ($meta['MailEnabled'] -and $meta['SecurityEnabled']) {
                $gType = 'Mail-Enabled Security'
            } elseif ($meta['SecurityEnabled']) {
                $gType = 'Security'
            } else {
                $gType = 'Other'
            }
            $mType = if ($meta['MembershipRuleProcessingState'] -eq 'On') { 'Dynamic' } else { 'Assigned' }
            $fdList.Add([PSCustomObject]@{
                GroupName      = $meta['DisplayName']
                GroupID        = $gid
                GroupType      = $gType
                MembershipType = $mType
                MemberOf       = ($groupOwnerMap[$gid] -join ', ')
            })
        }
        $script:FDResult = [System.Collections.Generic.List[PSCustomObject]]($fdList | Sort-Object GroupType, GroupName)

        $fdLevel = if ($distinctIds.Count -gt 0) { 'Success' } else { 'Warning' }
        Write-VerboseLog "Distinct groups total: $($distinctIds.Count)  (union: $($unionIds.Count)  common: $($intersectIds.Count))" -Level $fdLevel
        if ($failedEntries.Count -gt 0) {
            Write-VerboseLog "  Skipped (unresolved): $($failedEntries -join ', ')" -Level Warning
        }

    } -Done {
        $script:Window.FindName('PnlFDProgress').Visibility = 'Collapsed'
        $resultList = $script:FDResult
        $count = if ($resultList) { $resultList.Count } else { 0 }

        if ($count -gt 0) {
            $script:Window.FindName('DgFDResults').ItemsSource    = $resultList
            $script:Window.FindName('TxtFDCount').Text            = "$count distinct group(s) found"
            $script:Window.FindName('PnlFDResults').Visibility    = 'Visible'
            $script:Window.FindName('TxtFDNoResults').Visibility  = 'Collapsed'
            $script:Window.FindName('BtnFDCopyAll').IsEnabled     = $true
            $script:Window.FindName('TxtFDFilter').IsEnabled  = $true
            $script:Window.FindName('BtnFDFilter').IsEnabled  = $true
            $script:Window.FindName('BtnFDFilterClear').IsEnabled = $true
            $script:Window.FindName('BtnFDExportXlsx').IsEnabled  = $true
            Write-VerboseLog "Find Distinct Groups complete. Found $count group(s)." -Level Success
        } else {
            $script:Window.FindName('PnlFDResults').Visibility    = 'Collapsed'
            $script:Window.FindName('TxtFDNoResults').Visibility  = 'Visible'
            Write-VerboseLog "Find Distinct Groups complete. No distinct groups found." -Level Warning
        }
    }
})

# ── DgFDResults: cell selection ───────────────────────────────────────────────
$script:Window.FindName('DgFDResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgFDResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnFDCopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnFDCopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnFDCopyValue ────────────────────────────────────────────────────────────
# ── BtnFDFilter ──
$script:Window.FindName('BtnFDFilter').Add_Click({
    $dg      = $script:Window.FindName('DgFDResults')
    $keyword = $script:Window.FindName('TxtFDFilter').Text.Trim()
    if ($null -eq $script:FDAllData) {
        $script:FDAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:FDAllData
        Show-Notification "Filter cleared - $($script:FDAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:FDAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnFDFilterClear ──
$script:Window.FindName('BtnFDFilterClear').Add_Click({
    $script:Window.FindName('TxtFDFilter').Text = ''
    $dg = $script:Window.FindName('DgFDResults')
    if ($null -ne $script:FDAllData) {
        $dg.ItemsSource = $script:FDAllData
        Show-Notification "Filter cleared - $($script:FDAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:FDAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "FD: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnFDCopyValue').Add_Click({
    $dg = $script:Window.FindName('DgFDResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "FD: Copied cell value to clipboard." -Level Success
})

# ── BtnFDCopyRow ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFDCopyRow').Add_Click({
    $dg = $script:Window.FindName('DgFDResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $rows) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "FD: Copied $($rows.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnFDCopyAll ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFDCopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgFDResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $lines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($lines -join "`r`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "FD: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnFDExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnFDExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgFDResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc    = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title            = "Export Find Distinct Groups Results"
    $dlg.Filter           = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
    $dlg.FileName         = "FindDistinctGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $row = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName "DistinctGroups" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "FD: Exported $($allItems.Count) rows to: $outPath" -Level Success
    } catch {
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
        Write-VerboseLog "FD: Export failed: $($_.Exception.Message)" -Level Error
    }
})


# ── DEVICE MANAGEMENT BLADE ─────────────────────────────────────────

# Blade toggle
$script:BladeDevExpanded = $false
$script:Window.FindName('BtnBladeDevMgmt').Add_Click({
    $script:BladeDevExpanded = -not $script:BladeDevExpanded
    $content = $script:Window.FindName('BladeDevMgmtContent')
    $divider = $script:Window.FindName('BladeDevDivider')
    $arrow   = $script:Window.FindName('TxtBladeDevArrow')
    if ($script:BladeDevExpanded) {
        $content.Visibility = 'Visible'
        $divider.Visibility = 'Visible'
        $arrow.Text = [char]0x2212
    } else {
        $content.Visibility = 'Collapsed'
        $divider.Visibility = 'Collapsed'
        $arrow.Text = '+'
    }
})

# ÄÄ Collapse Device Mgmt blade on startup ÄÄ
$script:Window.FindName('BladeDevMgmtContent').Visibility = 'Collapsed'
$script:Window.FindName('BladeDevDivider').Visibility = 'Collapsed'
$script:Window.FindName('TxtBladeDevArrow').Text = '+'

# Input type ComboBox → update label
$script:Window.FindName('CmbGDIInputType').Add_SelectionChanged({
    $sel = $script:Window.FindName('CmbGDIInputType').SelectedItem.Content
    $lbl = switch ($sel) {
        'User UPNs (resolve owned devices)' { 'USER UPNs  -  one per line' }
        'Device names'                       { 'DEVICE NAMES  -  one per line' }
        'Device serial numbers'              { 'SERIAL NUMBERS  -  one per line' }
        'Entra Device Object IDs'            { 'ENTRA DEVICE OBJECT IDs  -  one per line (GUIDs)' }
        'Intune Device IDs'                  { 'INTUNE DEVICE IDs  -  one per line (GUIDs)' }
        'Groups (Users and Devices)'         { 'GROUP NAMES or Object IDs  -  one per line' }
        default                              { 'INPUT  -  one per line' }
    }
    $script:Window.FindName('TxtGDIInputLabel').Text = $lbl
})

# Panel open
$script:Window.FindName('BtnOpGetDeviceInfo').Add_Click({
    $script:Window.FindName('TxtGDIInputList').Text          = ''
    $script:Window.FindName('CmbGDIInputType').SelectedIndex = 0
    $script:Window.FindName('CmbGDIOwnership').SelectedIndex = 0
    $script:Window.FindName('TxtGDIInputLabel').Text         = 'USER UPNs  -  one per line'
    foreach ($chk in @('ChkGDIWindows','ChkGDIAndroid','ChkGDIiOS','ChkGDIMacOS')) {
        $script:Window.FindName($chk).IsChecked = $false
    }
    $script:Window.FindName('PnlGDIProgress').Visibility  = 'Collapsed'
    $script:Window.FindName('PnlGDIResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnGDICopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnGDICopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnGDICopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtGDIFilter').IsEnabled  = $false
    $script:Window.FindName('BtnGDIFilter').IsEnabled  = $false
    $script:Window.FindName('BtnGDIFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtGDIFilter').Text       = ''
    $script:GDIAllData = $null
    $script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgGDIResults')
    if ($dg) { $dg.ItemsSource = $null }
    Show-Panel 'PanelGetDeviceInfo'
    Hide-Notification
    Write-VerboseLog 'Panel: Get Device Info' -Level Info
})


# Panel open: Get Discovered Apps
$script:Window.FindName('BtnOpGetDiscoveredApps').Add_Click({
    $script:Window.FindName('TxtDAInputList').Text          = ''
    $script:Window.FindName('TxtDAKeyword').Text            = ''
    $script:Window.FindName('CmbDAInputType').SelectedIndex = 0
    $script:Window.FindName('CmbDAOwnership').SelectedIndex = 0
    $script:Window.FindName('TxtDAInputLabel').Text         = 'USER UPNs  -  one per line'
    foreach ($chk in @('ChkDAWindows','ChkDAAndroid','ChkDAiOS','ChkDAMacOS')) {
        $script:Window.FindName($chk).IsChecked = $false
    }
    $script:Window.FindName('PnlDAProgress').Visibility  = 'Collapsed'
    $script:Window.FindName('PnlDAResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnDACopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnDACopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnDACopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtDAFilter').IsEnabled  = $false
    $script:Window.FindName('BtnDAFilter').IsEnabled  = $false
    $script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtDAFilter').Text       = ''
    $script:DAAllData = $null
    $script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgDAResults')
    if ($dg) { $dg.ItemsSource = $null }
    Show-Panel 'PanelGetDiscoveredApps'
    Hide-Notification
    Write-VerboseLog 'Panel: Get Discovered Apps' -Level Info
})

$script:Window.FindName('CmbDAInputType').Add_SelectionChanged({
    $sel = $script:Window.FindName('CmbDAInputType').SelectedItem.Content
    $label = switch ($sel) {
        'User UPNs (resolve owned devices)' { 'USER UPNs  -  one per line' }
        'Device names'                       { 'DEVICE NAMES  -  one per line' }
        'Device IDs (Entra)'                 { 'ENTRA DEVICE IDs  -  one per line' }
        'Serial numbers'                     { 'SERIAL NUMBERS  -  one per line' }
        'Groups (names or IDs)'              { 'GROUP names or IDs  -  one per line' }
        default                              { 'IDENTIFIERS  -  one per line' }
    }
    $script:Window.FindName('TxtDAInputLabel').Text = $label
})


# ── GET DISCOVERED APPS  -  Run ────────────────────────────────────────
$script:Window.FindName('BtnDARun').Add_Click({
    Hide-Notification

    $inputTxt   = $script:Window.FindName('TxtDAInputList').Text
    $inputTypeSel = $script:Window.FindName('CmbDAInputType').SelectedItem.Content

    if ([string]::IsNullOrWhiteSpace($inputTxt)) {
        Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    # Collect filters on UI thread
    $platFilter = [System.Collections.Generic.List[string]]::new()
    if ($script:Window.FindName('ChkDAWindows').IsChecked) { $platFilter.Add('Windows') }
    if ($script:Window.FindName('ChkDAAndroid').IsChecked) { $platFilter.Add('Android') }
    if ($script:Window.FindName('ChkDAiOS').IsChecked)     { $platFilter.Add('iOS') }
    if ($script:Window.FindName('ChkDAMacOS').IsChecked)   { $platFilter.Add('macOS') }

    $ownershipSel = $script:Window.FindName('CmbDAOwnership').SelectedItem.Content
    $ownershipFilter = switch ($ownershipSel) {
        'Company only'  { 'Company' }
        'Personal only' { 'Personal' }
        default         { 'All' }
    }

    $keywordTxt = $script:Window.FindName('TxtDAKeyword').Text

    $script:DAParams = @{
        InputTxt        = $inputTxt
        InputType       = $inputTypeSel
        PlatformFilter  = $platFilter
        OwnershipFilter = $ownershipFilter
        Keyword         = $keywordTxt
    }

    # Reset UI
    $script:Window.FindName('PnlDAResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnDACopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnDACopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnDACopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtDAFilter').Text       = ''
    $script:DAAllData = $null
    $script:Window.FindName('TxtDAFilter').IsEnabled  = $false
    $script:Window.FindName('BtnDAFilter').IsEnabled  = $false
    $script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
    $script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgDAResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('PnlDAProgress').Visibility      = 'Visible'
    $script:Window.FindName('TxtDAProgressMsg').Text         = 'Resolving devices...'
    $script:Window.FindName('TxtDAProgressDetail').Text      = ''

    $script:Window.FindName('BtnDARun').IsEnabled = $false
    $script:Window.FindName('BtnDARunAll').IsEnabled = $false
    $script:Window.FindName('BtnStop').Visibility = 'Visible'
    $script:Window.FindName('BtnStop').IsEnabled  = $true
    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
    $script:BgStopped = $false
    Write-VerboseLog '--- Get Discovered Apps: starting ---' -Level Action

    $script:DaQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    $script:DaStop  = [hashtable]::Synchronized(@{ Stop = $false })

    $script:DaRs = [runspacefactory]::CreateRunspace()
    $script:DaRs.ApartmentState = 'STA'
    $script:DaRs.ThreadOptions  = 'ReuseThread'
    $script:DaRs.Open()
    $script:DaRs.SessionStateProxy.SetVariable('DaQueue',  $script:DaQueue)
    $script:DaRs.SessionStateProxy.SetVariable('DaStop',   $script:DaStop)
    $script:DaRs.SessionStateProxy.SetVariable('DaParams', $script:DAParams)
    $script:DaRs.SessionStateProxy.SetVariable('LogFile',  $script:LogFile)

    $script:DaPs = [powershell]::Create()
    $script:DaPs.Runspace = $script:DaRs

    $null = $script:DaPs.AddScript({
        function DLog {
            param([string]$Msg, [string]$Level = 'Info')
            $prefix = switch ($Level) {
                "Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
                'Action'  { '> ' } default   { '  ' }
            }
            $line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
            $DaQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
            if ($LogFile) {
                try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
            }
        }

        function Get-GraphAll([string]$Uri) {
            $items = [System.Collections.Generic.List[object]]::new()
            $next  = $Uri
            do {
                $r = Invoke-MgGraphRequest -Method GET -Uri $next
                if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
                $next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
            } while ($next)
            return ,$items
        }

        $isGuidRx = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'

        function Normalize-OS([string]$os) {
            switch -Wildcard ($os) {
                'Windows*' { 'Windows' } 'Android*' { 'Android' }
                'iPhone'   { 'iOS'     } 'iPadOS'   { 'iOS'     }
                'iOS'      { 'iOS'     } 'MacMDM'   { 'macOS'   }
                'Mac OS X' { 'macOS'   } 'macOS'    { 'macOS'   }
                default    { $os }
            }
        }

        try {
            $p = $DaParams
            $entries = @($p['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
            $inputType = $p['InputType']
            $platFilter = $p['PlatformFilter']
            $ownerFilter = $p['OwnershipFilter']
            $keyword = $p['Keyword']
            $intuneCache = @{}

            DLog "Processing $($entries.Count) input(s) - Type: $inputType" 'Action'

            # ── STEP 1: Resolve inputs to Intune device records ──
            $deviceRecords = [System.Collections.Generic.List[hashtable]]::new()
            $seenIntuneIds = [System.Collections.Generic.HashSet[string]]::new()
            $idx = 0

            foreach ($entry in $entries) {
                if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
                $idx++
                $DaQueue.Enqueue(@{ Type='progress'; Msg="Step 1/3: Resolving input $idx of $($entries.Count)..."; Detail=$entry })
                DLog "Resolving: $entry" 'Info'

                switch ($inputType) {
                    'User UPNs (resolve owned devices)' {
                        try {
                            $enc = [Uri]::EscapeDataString($entry)
                            $uDevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$enc/ownedDevices?`$select=deviceId,displayName`&`$top=999"
                            DLog "  User owns $($uDevs.Count) device(s)" 'Info'
                            foreach ($ud in $uDevs) {
                                $azDevId = [string]$ud['deviceId']
                                if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
                                try {
                                    $encDev = [Uri]::EscapeDataString($azDevId)
                                    $iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
                                    if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
                                        $iDev = $iResp['value'][0]
                                        $iId = [string]$iDev['id']
                                        if ($seenIntuneIds.Add($iId)) {
                                            $deviceRecords.Add(@{
                                                IntuneId      = $iId
                                                DeviceName    = [string]$iDev['deviceName']
                                                EntraDeviceId = $azDevId
                                                OS            = [string]$iDev['operatingSystem']
                                                OSVersion     = [string]$iDev['osVersion']
                                                Ownership     = [string]$iDev['managedDeviceOwnerType']
                                                UPN           = [string]$iDev['userPrincipalName']
                                            })
                                        }
                                    }
                                } catch {}
                            }
                        } catch { DLog "  User resolve failed: $($_.Exception.Message)" 'Warning' }
                    }
                    'Device names' {
                        try {
                            $safe = $entry -replace "'","''"
                            $dResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=deviceId,displayName`&`$top=5"
                            foreach ($d in @($dResp['value'])) {
                                $azDevId = [string]$d['deviceId']
                                if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
                                $encDev = [Uri]::EscapeDataString($azDevId)
                                try {
                                    $iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
                                    if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
                                        $iDev = $iResp['value'][0]
                                        $iId = [string]$iDev['id']
                                        if ($seenIntuneIds.Add($iId)) {
                                            $deviceRecords.Add(@{
                                                IntuneId      = $iId
                                                DeviceName    = [string]$iDev['deviceName']
                                                EntraDeviceId = $azDevId
                                                OS            = [string]$iDev['operatingSystem']
                                                OSVersion     = [string]$iDev['osVersion']
                                                Ownership     = [string]$iDev['managedDeviceOwnerType']
                                                UPN           = [string]$iDev['userPrincipalName']
                                            })
                                        }
                                    }
                                } catch {}
                            }
                        } catch { DLog "  Device name resolve failed: $($_.Exception.Message)" 'Warning' }
                    }
                    'Device IDs (Entra)' {
                        try {
                            $encDev = [Uri]::EscapeDataString($entry)
                            $iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
                            if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
                                $iDev = $iResp['value'][0]
                                $iId = [string]$iDev['id']
                                if ($seenIntuneIds.Add($iId)) {
                                    $deviceRecords.Add(@{
                                        IntuneId      = $iId
                                        DeviceName    = [string]$iDev['deviceName']
                                        EntraDeviceId = [string]$iDev['azureADDeviceId']
                                        OS            = [string]$iDev['operatingSystem']
                                        OSVersion     = [string]$iDev['osVersion']
                                        Ownership     = [string]$iDev['managedDeviceOwnerType']
                                        UPN           = [string]$iDev['userPrincipalName']
                                    })
                                }
                            } else { DLog "  No Intune record for Entra ID: $entry" 'Warning' }
                        } catch { DLog "  Entra ID resolve failed: $($_.Exception.Message)" 'Warning' }
                    }
                    'Serial numbers' {
                        try {
                            $encSer = [Uri]::EscapeDataString($entry)
                            $iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$encSer'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
                            if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
                                $iDev = $iResp['value'][0]
                                $iId = [string]$iDev['id']
                                if ($seenIntuneIds.Add($iId)) {
                                    $deviceRecords.Add(@{
                                        IntuneId      = $iId
                                        DeviceName    = [string]$iDev['deviceName']
                                        EntraDeviceId = [string]$iDev['azureADDeviceId']
                                        OS            = [string]$iDev['operatingSystem']
                                        OSVersion     = [string]$iDev['osVersion']
                                        Ownership     = [string]$iDev['managedDeviceOwnerType']
                                        UPN           = [string]$iDev['userPrincipalName']
                                    })
                                }
                            } else { DLog "  No Intune record for serial: $entry" 'Warning' }
                        } catch { DLog "  Serial resolve failed: $($_.Exception.Message)" 'Warning' }
                    }
                    'Groups (names or IDs)' {
                        try {
                            $groupId = $null
                            if ($entry -match $isGuidRx) {
                                $groupId = $entry
                            } else {
                                $safe = $entry -replace "'","''"
                                $gr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id`&`$top=1"
                                if ($gr['value'] -and $gr['value'].Count -gt 0) {
                                    $groupId = [string]$gr['value'][0]['id']
                                }
                            }
                            if (-not $groupId) { DLog "  Group not found: $entry" 'Warning'; continue }

                            DLog "  Scanning group members (transitive)..." 'Info'
                            $members = Get-GraphAll "https://graph.microsoft.com/v1.0/groups/$groupId/transitiveMembers?`$select=id&`$top=999"
                            DLog "  $($members.Count) member(s) found" 'Info'

                            foreach ($mem in $members) {
                                if ($DaStop['Stop']) { break }
                                $odataType = [string]$mem['@odata.type']
                                if ($odataType -eq '#microsoft.graph.device') {
                                    # Direct device member - resolve via Entra deviceId
                                    $devObjId = [string]$mem['id']
                                    try {
                                        $entraDevRaw = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$devObjId`?`$select=deviceId"
                                        $azDevId = [string]$entraDevRaw['deviceId']
                                        if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
                                        $encDev = [Uri]::EscapeDataString($azDevId)
                                        $iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName&`$top=1"
                                        if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
                                            $iDev = $iResp['value'][0]
                                            $iId = [string]$iDev['id']
                                            if ($seenIntuneIds.Add($iId)) {
                                                $deviceRecords.Add(@{
                                                    IntuneId      = $iId
                                                    DeviceName    = [string]$iDev['deviceName']
                                                    EntraDeviceId = $azDevId
                                                    OS            = [string]$iDev['operatingSystem']
                                                    OSVersion     = [string]$iDev['osVersion']
                                                    Ownership     = [string]$iDev['managedDeviceOwnerType']
                                                    UPN           = [string]$iDev['userPrincipalName']
                                                })
                                            }
                                        }
                                    } catch {}
                                } elseif ($odataType -eq '#microsoft.graph.user') {
                                    # User member - resolve their owned devices
                                    $uid = [string]$mem['id']
                                    try {
                                        $uDevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=deviceId,displayName&`$top=999"
                                        DLog "    User $uid owns $($uDevs.Count) device(s)" 'Info'
                                        foreach ($ud in $uDevs) {
                                            if ($DaStop['Stop']) { break }
                                            $azDevId = [string]$ud['deviceId']
                                            if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
                                            try {
                                                $encDev = [Uri]::EscapeDataString($azDevId)
                                                $iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName&`$top=1"
                                                if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
                                                    $iDev = $iResp['value'][0]
                                                    $iId = [string]$iDev['id']
                                                    if ($seenIntuneIds.Add($iId)) {
                                                        $deviceRecords.Add(@{
                                                            IntuneId      = $iId
                                                            DeviceName    = [string]$iDev['deviceName']
                                                            EntraDeviceId = $azDevId
                                                            OS            = [string]$iDev['operatingSystem']
                                                            OSVersion     = [string]$iDev['osVersion']
                                                            Ownership     = [string]$iDev['managedDeviceOwnerType']
                                                            UPN           = [string]$iDev['userPrincipalName']
                                                        })
                                                    }
                                                }
                                            } catch {}
                                        }
                                    } catch { DLog "    User device resolve failed for $uid : $($_.Exception.Message)" 'Warning' }
                                }
                            }
                        } catch { DLog "  Group resolve failed: $($_.Exception.Message)" 'Warning' }
                    }
                }
            }

            DLog "Step 1 complete: $($deviceRecords.Count) unique Intune device(s) resolved" 'Info'

            if ($deviceRecords.Count -eq 0) {
                $DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
                return
            }

            # ── STEP 2: Apply platform and ownership filters ──
            $DaQueue.Enqueue(@{ Type='progress'; Msg="Step 2/3: Applying filters..."; Detail='' })
            $filtered = [System.Collections.Generic.List[hashtable]]::new()
            foreach ($rec in $deviceRecords) {
                $osNorm = Normalize-OS $rec['OS']
                if ($platFilter.Count -gt 0 -and $osNorm -notin $platFilter) { continue }
                if ($ownerFilter -ne 'All' -and $rec['Ownership'] -ne $ownerFilter) { continue }
                $filtered.Add($rec)
            }
            DLog "Step 2 complete: $($filtered.Count) device(s) after filtering (from $($deviceRecords.Count))" 'Info'

            if ($filtered.Count -eq 0) {
                $DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices matched the platform/ownership filters.' })
                return
            }

            # ── STEP 3: Fetch discovered apps per device ──
            $allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
            $devIdx = 0

            foreach ($rec in $filtered) {
                if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
                $devIdx++
                $DaQueue.Enqueue(@{ Type='progress'; Msg="Step 3/3: Fetching apps for device $devIdx of $($filtered.Count)..."; Detail=$rec['DeviceName'] })
                DLog "[$devIdx] Fetching apps for: $($rec['DeviceName'])" 'Info'

                try {
                    $appsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($rec['IntuneId'])/detectedApps?`$select=displayName,version,platform,publisher"
                    $apps = Get-GraphAll $appsUri
                    DLog "  $($apps.Count) app(s) found" 'Info'

                    $collectTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                    foreach ($app in $apps) {
                        $appName = [string]$app['displayName']
                        # Apply keyword filter
                        if (-not [string]::IsNullOrWhiteSpace($keyword)) {
                            if ($appName -notlike "*$keyword*") { continue }
                        }

                        $allRows.Add([PSCustomObject]@{
                            DeviceName     = [string]$rec['DeviceName']
                            EntraDeviceId  = [string]$rec['EntraDeviceId']
                            PrimaryUserUPN = [string]$rec['UPN']
                            OSVersion      = [string]$rec['OSVersion']
                            DiscoveredApp  = $appName
                            AppVersion     = [string]$app['version']
                            AppPublisher   = [string]$app['publisher']
                            CollectedAt    = $collectTime
                        })
                    }
                } catch {
                    DLog "  App fetch failed for $($rec['DeviceName']): $($_.Exception.Message)" 'Warning'
                }
            }

            DLog "Completed: $($allRows.Count) app record(s) across $($filtered.Count) device(s)." 'Success'
            $DaQueue.Enqueue(@{ Type='done'; Rows=$allRows; Error=$null })

        } catch {
            $DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
        }
    })

    $script:DaHandle = $script:DaPs.BeginInvoke()

    # Timer to drain DaQueue on the UI thread
    $script:DaTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:DaTimer.Interval = [TimeSpan]::FromMilliseconds(300)
    $script:DaTimer.Add_Tick({
        $entry = $null
        while ($script:DaQueue.TryDequeue([ref]$entry)) {
            switch ($entry['Type']) {
                'log' {
                    $lvl = $entry['Level']
                    $col = switch ($lvl) {
                        'Success' { $DevConfig.LogColorSuccess }
                        'Warning' { $DevConfig.LogColorWarning }
                        'Error'   { $DevConfig.LogColorError   }
                        'Action'  { $DevConfig.LogColorAction  }
                        default   { $DevConfig.LogColorInfo    }
                    }
                    try {
                        $para = [System.Windows.Documents.Paragraph]::new()
                        $run  = [System.Windows.Documents.Run]::new($entry['Line'])
                        $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                            [System.Windows.Media.ColorConverter]::ConvertFromString($col))
                        $para.Margin = [System.Windows.Thickness]::new(0)
                        $para.Inlines.Add($run)
                        $script:RtbLog.Document.Blocks.Add($para)
                        $script:RtbLog.ScrollToEnd()
                    } catch {}
                }
                'progress' {
                    $script:Window.FindName('PnlDAProgress').Visibility       = 'Visible'
                    $script:Window.FindName('TxtDAProgressMsg').Text          = $entry['Msg']
                    $script:Window.FindName('TxtDAProgressDetail').Text       = $entry['Detail']
                    Update-StatusBar -Text "Get Discovered Apps: $($entry['Msg'])"
                }
                'done' {
                    $script:DaTimer.Stop()
                    try { $script:DaPs.EndInvoke($script:DaHandle) } catch {}
                    try { $script:DaRs.Close() }                      catch {}
                    try { $script:DaPs.Dispose() }                    catch {}
                    $script:DaPs = $null; $script:DaRs = $null
                    $script:Window.FindName('BtnStop').Visibility = 'Collapsed'
                    $script:Window.FindName('BtnStop').IsEnabled  = $true
                    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
                    $script:Window.FindName('BtnDARun').IsEnabled = $true
                    $script:Window.FindName('BtnDARunAll').IsEnabled = $true
                    $script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'

                    $errMsg = $entry['Error']
                    if ($errMsg) {
                        Write-VerboseLog "Get Discovered Apps error: $errMsg" -Level Error
                        Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    $rows = @($entry['Rows'])
                    if ($rows.Count -eq 0) {
                        Write-VerboseLog 'Get Discovered Apps: no results.' -Level Warning
                        $script:Window.FindName('TxtDANoResults').Visibility = 'Visible'
                        Show-Notification 'No discovered apps matched the filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    $dg = $script:Window.FindName('DgDAResults')
                    $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
                    $script:Window.FindName('TxtDACount').Text            = "$($rows.Count) app record(s) found"
                    $script:Window.FindName('PnlDAResults').Visibility     = 'Visible'
                    $script:Window.FindName('BtnDACopyAll').IsEnabled      = $true
                    $script:Window.FindName('TxtDAFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnDAFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnDAFilterClear').IsEnabled = $true
                    $script:Window.FindName('BtnDAExportXlsx').IsEnabled   = $true
                    Write-VerboseLog "Get Discovered Apps: $($rows.Count) record(s) loaded." -Level Success
                    Show-Notification "$($rows.Count) app record(s) ready." -BgColor '#D4EDDA' -FgColor '#155724'
                    Update-StatusBar -Text 'Connected'
                }
            }
        }
    })
    $script:DaTimer.Start()
})

# -- BtnDARunAll: Get Discovered Apps from All Devices --
$script:Window.FindName('BtnDARunAll').Add_Click({
    Hide-Notification

    # Collect filters on UI thread (no input list or keyword needed)
    $platFilter = [System.Collections.Generic.List[string]]::new()
    if ($script:Window.FindName('ChkDAWindows').IsChecked) { $platFilter.Add('Windows') }
    if ($script:Window.FindName('ChkDAAndroid').IsChecked) { $platFilter.Add('Android') }
    if ($script:Window.FindName('ChkDAiOS').IsChecked)     { $platFilter.Add('iOS') }
    if ($script:Window.FindName('ChkDAMacOS').IsChecked)   { $platFilter.Add('macOS') }

    $ownershipSel = $script:Window.FindName('CmbDAOwnership').SelectedItem.Content
    $ownershipFilter = switch ($ownershipSel) {
        'Company only'  { 'Company' }
        'Personal only' { 'Personal' }
        default         { 'All' }
    }

    $script:DAParams = @{
        AllDevices      = $true
        PlatformFilter  = $platFilter
        OwnershipFilter = $ownershipFilter
    }

    # Reset UI
    $script:Window.FindName('PnlDAResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnDACopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnDACopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnDACopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtDAFilter').IsEnabled  = $false
    $script:Window.FindName('BtnDAFilter').IsEnabled  = $false
    $script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtDAFilter').Text       = ''
    $script:DAAllData = $null
    $script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgDAResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('PnlDAProgress').Visibility      = 'Visible'
    $script:Window.FindName('TxtDAProgressMsg').Text         = 'Fetching all managed devices...'
    $script:Window.FindName('TxtDAProgressDetail').Text      = ''

    $script:Window.FindName('BtnDARun').IsEnabled    = $false
    $script:Window.FindName('BtnDARunAll').IsEnabled = $false
    $script:Window.FindName('BtnStop').Visibility = 'Visible'
    $script:Window.FindName('BtnStop').IsEnabled  = $true
    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
    $script:BgStopped = $false
    Write-VerboseLog '--- Get Discovered Apps (All Devices): starting ---' -Level Action

    $script:DaQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    $script:DaStop  = [hashtable]::Synchronized(@{ Stop = $false })

    $script:DaRs = [runspacefactory]::CreateRunspace()
    $script:DaRs.ApartmentState = 'STA'
    $script:DaRs.ThreadOptions  = 'ReuseThread'
    $script:DaRs.Open()
    $script:DaRs.SessionStateProxy.SetVariable('DaQueue',  $script:DaQueue)
    $script:DaRs.SessionStateProxy.SetVariable('DaStop',   $script:DaStop)
    $script:DaRs.SessionStateProxy.SetVariable('DaParams', $script:DAParams)
    $script:DaRs.SessionStateProxy.SetVariable('LogFile',  $script:LogFile)

    $script:DaPs = [powershell]::Create()
    $script:DaPs.Runspace = $script:DaRs
    $null = $script:DaPs.AddScript({
        function DLog {
            param([string]$Msg, [string]$Level = 'Info')
            $prefix = switch ($Level) {
                "Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
                'Action'  { '> ' } default   { '  ' }
            }
            $line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
            $DaQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
            if ($LogFile) {
                try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
            }
        }

        function Get-GraphAll([string]$Uri) {
            $items = [System.Collections.Generic.List[object]]::new()
            $next  = $Uri
            do {
                $r = Invoke-MgGraphRequest -Method GET -Uri $next
                if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
                $next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
            } while ($next)
            return ,$items
        }

        function Normalize-OS([string]$os) {
            switch -Wildcard ($os) {
                'Windows*' { 'Windows' } 'Android*' { 'Android' }
                'iPhone'   { 'iOS'     } 'iPadOS'   { 'iOS'     }
                'iOS'      { 'iOS'     } 'MacMDM'   { 'macOS'   }
                'Mac OS X' { 'macOS'   } 'macOS'    { 'macOS'   }
                default    { $os }
            }
        }

        try {
            $p = $DaParams
            $platFilter  = $p['PlatformFilter']
            $ownerFilter = $p['OwnershipFilter']

            DLog "Fetching all managed devices from Intune..." 'Action'
            $DaQueue.Enqueue(@{ Type='progress'; Msg="Step 1/3: Fetching all managed devices..."; Detail='' })

            $allDevices = Get-GraphAll "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName&`$top=999"
            DLog "Step 1 complete: $($allDevices.Count) managed device(s) retrieved" 'Info'

            if ($allDevices.Count -eq 0) {
                $DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No managed devices found in Intune.' })
                return
            }

            $deviceRecords = [System.Collections.Generic.List[hashtable]]::new()
            foreach ($iDev in $allDevices) {
                $deviceRecords.Add(@{
                    IntuneId      = [string]$iDev['id']
                    DeviceName    = [string]$iDev['deviceName']
                    EntraDeviceId = [string]$iDev['azureADDeviceId']
                    OS            = [string]$iDev['operatingSystem']
                    OSVersion     = [string]$iDev['osVersion']
                    Ownership     = [string]$iDev['managedDeviceOwnerType']
                    UPN           = [string]$iDev['userPrincipalName']
                })
            }

            $DaQueue.Enqueue(@{ Type='progress'; Msg="Step 2/3: Applying filters..."; Detail='' })
            $filtered = [System.Collections.Generic.List[hashtable]]::new()
            foreach ($rec in $deviceRecords) {
                $osNorm = Normalize-OS $rec['OS']
                if ($platFilter.Count -gt 0 -and $osNorm -notin $platFilter) { continue }
                if ($ownerFilter -ne 'All' -and $rec['Ownership'] -ne $ownerFilter) { continue }
                $filtered.Add($rec)
            }
            DLog "Step 2 complete: $($filtered.Count) device(s) after filtering (from $($deviceRecords.Count))" 'Info'

            if ($filtered.Count -eq 0) {
                $DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices matched the platform/ownership filters.' })
                return
            }
            $allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
            $devIdx = 0

            foreach ($rec in $filtered) {
                if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
                $devIdx++
                $DaQueue.Enqueue(@{ Type='progress'; Msg="Step 3/3: Fetching apps for device $devIdx of $($filtered.Count)..."; Detail=$rec['DeviceName'] })
                DLog "[$devIdx] Fetching apps for: $($rec['DeviceName'])" 'Info'

                try {
                    $appsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($rec['IntuneId'])/detectedApps?`$select=displayName,version,platform,publisher"
                    $apps = Get-GraphAll $appsUri
                    DLog "  $($apps.Count) app(s) found" 'Info'

                    $collectTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                    foreach ($app in $apps) {
                        $allRows.Add([PSCustomObject]@{
                            DeviceName     = [string]$rec['DeviceName']
                            EntraDeviceId  = [string]$rec['EntraDeviceId']
                            PrimaryUserUPN = [string]$rec['UPN']
                            OSVersion      = [string]$rec['OSVersion']
                            DiscoveredApp  = [string]$app['displayName']
                            AppVersion     = [string]$app['version']
                            AppPublisher   = [string]$app['publisher']
                            CollectedAt    = $collectTime
                        })
                    }
                } catch {
                    DLog "  App fetch failed for $($rec['DeviceName']): $($_.Exception.Message)" 'Warning'
                }
            }

            DLog "Completed: $($allRows.Count) app record(s) across $($filtered.Count) device(s)." 'Success'
            $DaQueue.Enqueue(@{ Type='done'; Rows=$allRows; Error=$null })

        } catch {
            $DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
        }
    })

    $script:DaHandle = $script:DaPs.BeginInvoke()
    # Timer to drain DaQueue on the UI thread
    $script:DaTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:DaTimer.Interval = [TimeSpan]::FromMilliseconds(300)
    $script:DaTimer.Add_Tick({
        $entry = $null
        while ($script:DaQueue.TryDequeue([ref]$entry)) {
            switch ($entry['Type']) {
                'log' {
                    $lvl = $entry['Level']
                    $col = switch ($lvl) {
                        'Success' { $DevConfig.LogColorSuccess }
                        'Warning' { $DevConfig.LogColorWarning }
                        'Error'   { $DevConfig.LogColorError   }
                        'Action'  { $DevConfig.LogColorAction  }
                        default   { $DevConfig.LogColorInfo    }
                    }
                    try {
                        $para = [System.Windows.Documents.Paragraph]::new()
                        $run  = [System.Windows.Documents.Run]::new($entry['Line'])
                        $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                            [System.Windows.Media.ColorConverter]::ConvertFromString($col))
                        $para.Margin = [System.Windows.Thickness]::new(0)
                        $para.Inlines.Add($run)
                        $script:RtbLog.Document.Blocks.Add($para)
                        $script:RtbLog.ScrollToEnd()
                    } catch {}
                }
                'progress' {
                    $script:Window.FindName('PnlDAProgress').Visibility       = 'Visible'
                    $script:Window.FindName('TxtDAProgressMsg').Text          = $entry['Msg']
                    $script:Window.FindName('TxtDAProgressDetail').Text       = $entry['Detail']
                    Update-StatusBar -Text "Get Discovered Apps (All): $($entry['Msg'])"
                }
                'done' {
                    $script:DaTimer.Stop()
                    try { $script:DaPs.EndInvoke($script:DaHandle) } catch {}
                    try { $script:DaRs.Close() }                      catch {}
                    try { $script:DaPs.Dispose() }                    catch {}
                    $script:DaPs = $null; $script:DaRs = $null
                    $script:Window.FindName('BtnStop').Visibility = 'Collapsed'
                    $script:Window.FindName('BtnStop').IsEnabled  = $true
                    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
                    $script:Window.FindName('BtnDARun').IsEnabled    = $true
                    $script:Window.FindName('BtnDARunAll').IsEnabled = $true
                    $script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'

                    $errMsg = $entry['Error']
                    if ($errMsg) {
                        Write-VerboseLog "Get Discovered Apps (All) error: $errMsg" -Level Error
                        Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    $rows = @($entry['Rows'])
                    if ($rows.Count -eq 0) {
                        Write-VerboseLog 'Get Discovered Apps (All): no results.' -Level Warning
                        $script:Window.FindName('TxtDANoResults').Visibility = 'Visible'
                        Show-Notification 'No discovered apps matched the filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    $dg = $script:Window.FindName('DgDAResults')
                    $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
                    $script:Window.FindName('TxtDACount').Text            = "$($rows.Count) app record(s) found (all devices)"
                    $script:Window.FindName('PnlDAResults').Visibility     = 'Visible'
                    $script:Window.FindName('BtnDACopyAll').IsEnabled      = $true
                    $script:Window.FindName('TxtDAFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnDAFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnDAFilterClear').IsEnabled = $true
                    $script:Window.FindName('BtnDAExportXlsx').IsEnabled   = $true
                    Write-VerboseLog "Get Discovered Apps (All): $($rows.Count) record(s) loaded." -Level Success
                    Show-Notification "$($rows.Count) app record(s) ready (all devices)." -BgColor '#D4EDDA' -FgColor '#155724'
                    Update-StatusBar -Text 'Connected'
                }
            }
        }
    })
    $script:DaTimer.Start()
})

# ── DgDAResults: cell selection ──
$script:Window.FindName('DgDAResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgDAResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnDACopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnDACopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnDACopyValue ──
# ── BtnDAFilter ──
$script:Window.FindName('BtnDAFilter').Add_Click({
    $dg      = $script:Window.FindName('DgDAResults')
    $keyword = $script:Window.FindName('TxtDAFilter').Text.Trim()
    if ($null -eq $script:DAAllData) {
        $script:DAAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:DAAllData
        Show-Notification "Filter cleared - $($script:DAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:DAAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnDAFilterClear ──
$script:Window.FindName('BtnDAFilterClear').Add_Click({
    $script:Window.FindName('TxtDAFilter').Text = ''
    $dg = $script:Window.FindName('DgDAResults')
    if ($null -ne $script:DAAllData) {
        $dg.ItemsSource = $script:DAAllData
        Show-Notification "Filter cleared - $($script:DAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:DAAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "DA: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnDACopyValue').Add_Click({
    $dg = $script:Window.FindName('DgDAResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification '$($vals.Count) value(s) copied to clipboard.' -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog 'DA: Copied cell value to clipboard.' -Level Success
})

# ── BtnDACopyRow ──
$script:Window.FindName('BtnDACopyRow').Add_Click({
    $dg = $script:Window.FindName('DgDAResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rowObjs = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rowObjs.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $rowObjs) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "`n")
    Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "DA: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnDACopyAll ──
$script:Window.FindName('BtnDACopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgDAResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "`t")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "`n")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "DA: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnDAExportXlsx ──
$script:Window.FindName('BtnDAExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgDAResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title            = 'Export Discovered Apps Results'
    $dlg.Filter           = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
    $dlg.FileName         = "DiscoveredApps_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $rowObj = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName 'DiscoveredApps' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found - saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "DA: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
    } catch {
        Write-VerboseLog "DA Export failed: $($_.Exception.Message)" -Level Error
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
    }
})

$script:Window.FindName('BtnOpCompareGroups').Add_Click({
    $script:Window.FindName('PnlCGResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
    $script:Window.FindName('PnlCGProgress').Visibility  = 'Collapsed'
    Show-Panel 'PanelCompareGroups'
    Hide-Notification
    Write-VerboseLog 'Panel: Compare Groups' -Level Info
})

# ── GET DEVICE INFO  -  Run ────────────────────────────────────────────
$script:Window.FindName('BtnGDIRun').Add_Click({
    Hide-Notification

    $inputTxt   = $script:Window.FindName('TxtGDIInputList').Text
    $inputTypeSel = $script:Window.FindName('CmbGDIInputType').SelectedItem.Content

    if ([string]::IsNullOrWhiteSpace($inputTxt)) {
        Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
        return
    }

    # Collect filters on UI thread
    $platFilter = [System.Collections.Generic.List[string]]::new()
    if ($script:Window.FindName('ChkGDIWindows').IsChecked) { $platFilter.Add('Windows') }
    if ($script:Window.FindName('ChkGDIAndroid').IsChecked) { $platFilter.Add('Android') }
    if ($script:Window.FindName('ChkGDIiOS').IsChecked)     { $platFilter.Add('iOS') }
    if ($script:Window.FindName('ChkGDIMacOS').IsChecked)   { $platFilter.Add('macOS') }

    $ownershipSel = $script:Window.FindName('CmbGDIOwnership').SelectedItem.Content
    $ownershipFilter = switch ($ownershipSel) {
        'Company only'  { 'Company' }
        'Personal only' { 'Personal' }
        default         { 'All' }
    }

    $script:GDIParams = @{
        InputTxt        = $inputTxt
        InputType       = $inputTypeSel
        PlatformFilter  = $platFilter
        OwnershipFilter = $ownershipFilter
    }

    # Reset table state
    $script:Window.FindName('PnlGDIResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnGDICopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnGDICopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnGDICopyAll').IsEnabled    = $false
    $script:Window.FindName('TxtGDIFilter').IsEnabled  = $false
    $script:Window.FindName('BtnGDIFilter').IsEnabled  = $false
    $script:Window.FindName('BtnGDIFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtGDIFilter').Text       = ''
    $script:GDIAllData = $null
    $script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgGDIResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('PnlGDIProgress').Visibility      = 'Visible'
    $script:Window.FindName('TxtGDIProgressMsg').Text         = 'Resolving devices...'
    $script:Window.FindName('TxtGDIProgressDetail').Text      = ''

    $script:Window.FindName('BtnGDIRun').IsEnabled = $false
    $script:Window.FindName('BtnGDIRunAll').IsEnabled = $false
    $script:Window.FindName('BtnStop').Visibility = 'Visible'
    $script:Window.FindName('BtnStop').IsEnabled  = $true
    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
    $script:BgStopped = $false
    Write-VerboseLog '--- Get Device Info: starting ---' -Level Action

    # ── Dedicated runspace  -  all state in script scope so DispatcherTimer
    #    tick can access it reliably (local closure capture is unreliable in WPF handlers)
    $script:GdiQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    $script:GdiStop  = [hashtable]::Synchronized(@{ Stop = $false })

    $script:GdiRs = [runspacefactory]::CreateRunspace()
    $script:GdiRs.ApartmentState = 'STA'
    $script:GdiRs.ThreadOptions  = 'ReuseThread'
    $script:GdiRs.Open()
    $script:GdiRs.SessionStateProxy.SetVariable('GdiQueue',  $script:GdiQueue)
    $script:GdiRs.SessionStateProxy.SetVariable('GdiStop',   $script:GdiStop)
    $script:GdiRs.SessionStateProxy.SetVariable('GdiParams', $script:GDIParams)
    $script:GdiRs.SessionStateProxy.SetVariable('LogFile',   $script:LogFile)

    $script:GdiPs = [powershell]::Create()
    $script:GdiPs.Runspace = $script:GdiRs

    $null = $script:GdiPs.AddScript({
        # ── Helper: enqueue a log message ──
        function GLog {
            param([string]$Msg, [string]$Level = 'Info')
            $prefix = switch ($Level) {
                "Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
                'Action'  { '> ' } default   { '  ' }
            }
            $line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
            $GdiQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
            if ($LogFile) {
                try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
            }
        }

        # ── OS normalisation (Entra raw → friendly) ──
        function Normalize-OS([string]$os) {
            switch -Wildcard ($os) {
                'Windows*' { 'Windows' } 'Android*' { 'Android' }
                'iPhone'   { 'iOS'     } 'iPadOS'   { 'iOS'     }
                'iOS'      { 'iOS'     } 'MacMDM'   { 'macOS'   }
                'Mac OS X' { 'macOS'   } 'macOS'    { 'macOS'   }
                default    { $os }
            }
        }

        # ── Windows release lookup ──
        $WinVerMap = @(
            @{ V='10.0.26200'; R='25H2'          }
            @{ V='10.0.28000'; R='26H1'           }
            @{ V='10.0.26100'; R='24H2'           }
            @{ V='10.0.22631'; R='23H2'           }
            @{ V='10.0.22621'; R='22H2 (Win11)'   }
            @{ V='10.0.19045'; R='22H2 (Win10)'   }
            @{ V='10.0.19044'; R='21H2 (LTSC)'    }
            @{ V='10.0.17763'; R='1809 (LTSC)'    }
        )
        function Get-WinRelease([string]$ver) {
            if ([string]::IsNullOrWhiteSpace($ver)) { return $null }
            $v = $ver.Trim()
            $m = $WinVerMap | Where-Object { $_.V -eq $v } | Select-Object -First 1
            if ($m) { return $m.R }
            # Try major.minor.build prefix only
            if ($v -match '^\d+\.\d+\.\d+') {
                $pfx = ($Matches[0])
                $m = $WinVerMap | Where-Object { $_.V -eq $pfx } | Select-Object -First 1
                if ($m) { return $m.R }
            }
            return $null
        }

        # ── Device type from name suffix ──
        $DevTypeMap = @(
            @{ S='-D'; T='Desktop'            }
            @{ S='-L'; T='Laptop'             }
            @{ S='-V'; T='Virtual'            }
            @{ S='-K'; T='Kiosk'              }
            @{ S='-F'; T='Teams Meeting Room' }
            @{ S='-A'; T='Shared Device'      }
        )
        function Get-DevType([string]$name) {
            if ([string]::IsNullOrWhiteSpace($name)) { return $null }
            $u = $name.Trim().ToUpperInvariant()
            foreach ($m in $DevTypeMap) {
                if ($u.EndsWith($m.S.ToUpperInvariant())) { return $m.T }
            }
            return $null
        }

        # ── Activity range label ──
        # Tries RoundtripKind first (ISO 8601/UTC), then InvariantCulture general parse
        # as fallback  -  same two-style approach as Format-Date to handle all Graph date formats.
        function Get-ActivityRange([string]$dateStr) {
            if ([string]::IsNullOrWhiteSpace($dateStr)) { return $null }
            $d = $null
            foreach ($style in @(
                [System.Globalization.DateTimeStyles]::RoundtripKind,
                [System.Globalization.DateTimeStyles]::None
            )) {
                try {
                    $d = [datetime]::Parse($dateStr,
                             [System.Globalization.CultureInfo]::InvariantCulture, $style)
                    break
                } catch {}
            }
            if ($null -eq $d) { return $null }
            $days = [int][Math]::Floor(([datetime]::UtcNow - $d.ToUniversalTime()).TotalDays)
            if ($days -le 7)       { return '0-7 days'        }
            elseif ($days -le 14)  { return '8-14 days'       }
            elseif ($days -le 30)  { return '15-30 days'      }
            elseif ($days -le 60)  { return '30-60 days'      }
            elseif ($days -le 90)  { return '60-90 days'      }
            elseif ($days -le 180) { return '90-180 days'     }
            elseif ($days -le 365) { return '180-365 days'    }
            else                   { return 'Older than 1 yr' }
        }

        # ── Page through a Graph URI ──
        function Get-GraphAll([string]$Uri, [hashtable]$Headers = @{}) {
            $items = [System.Collections.Generic.List[object]]::new()
            $next  = $Uri
            do {
                $r = Invoke-MgGraphRequest -Method GET -Uri $next -Headers $Headers
                if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
                $next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
            } while ($next)
            return ,$items
        }

        # ── Fetch full Entra device record ──
        function Get-EntraDevice([string]$Uri) {
            try {
                return Invoke-MgGraphRequest -Method GET -Uri $Uri
            } catch { return $null }
        }

        # ── Fetch Intune device by azureADDeviceId ──
        function Get-IntuneByEntraId([string]$EntraId, [hashtable]$Cache) {
            if ([string]::IsNullOrWhiteSpace($EntraId)) { return $null }
            if ($Cache.ContainsKey($EntraId)) { return $Cache[$EntraId] }
            try {
                $enc  = [Uri]::EscapeDataString($EntraId)
                $resp = Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$enc'`&`$select=id,deviceName,operatingSystem,osVersion,lastSyncDateTime,manufacturer,model,serialNumber,managedDeviceOwnerType,enrolledDateTime,azureADDeviceId,userPrincipalName,complianceState`&`$top=1"
                $dev = if ($resp['value'] -and $resp['value'].Count -gt 0) { $resp['value'][0] } else { $null }
                $Cache[$EntraId] = $dev
                return $dev
            } catch { $Cache[$EntraId] = $null; return $null }
        }

        # ── Fetch user info ──
        function Get-UserInfo([string]$UserId) {
            try {
                return Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($UserId))?`$select=id,userPrincipalName,displayName,jobTitle,accountEnabled,city,country,officeLocation"
            } catch { return $null }
        }

        # ── Format any date string to MM-dd-yyyy, tolerating multiple input formats ──
        function Format-Date([string]$raw) {
            if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
            # Try ISO 8601 / RoundtripKind first, then general parse as fallback
            foreach ($style in @(
                [System.Globalization.DateTimeStyles]::RoundtripKind,
                [System.Globalization.DateTimeStyles]::None
            )) {
                try {
                    return ([datetime]::Parse($raw, [System.Globalization.CultureInfo]::InvariantCulture, $style)).ToString('MM-dd-yyyy')
                } catch {}
            }
            return $raw   # return raw if all parsing fails
        }

        # ── Build Autopilot lookup dictionary ──────────────────────────────────────
        # deploymentProfileAssignmentStatus is a NESTED OBJECT containing deploymentProfileId.
        # Profile name is resolved by: $ap['deploymentProfileAssignmentStatus']['deploymentProfileId']
        # → fetch profile display name from windowsAutopilotDeploymentProfiles/{id}.
        # Step 1: load all profiles into id->name map.
        # Step 2: load all identities, extract profileId from nested status object.
        function Build-AutopilotDictionary {
            $dict       = @{}
            $profileMap = @{}

            # ── Step 1: all deployment profiles ──
            try {
                $nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName&$top=100'
                do {
                    $r = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                    foreach ($p in @($r['value'])) { $profileMap[[string]$p['id']] = [string]$p['displayName'] }
                    $nextUri = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
                } while ($nextUri)
                GLog "Autopilot: $($profileMap.Count) profile(s) loaded" 'Info'
            } catch {
                GLog "Autopilot profiles failed: $($_.Exception.Message)" 'Warning'
            }

            # ── Step 2: all device identities ──
            try {
                GLog 'Autopilot: loading device identities...' 'Info'
                $nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?$top=500'
                $total   = 0
                do {
                    $resp  = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                    $page  = @($resp['value'])
                    $total += $page.Count
                    foreach ($ap in $page) {
                        $aadId = [string]$ap['azureActiveDirectoryDeviceId']
                        if ([string]::IsNullOrWhiteSpace($aadId)) { continue }

                        # deploymentProfileAssignmentStatus is a nested object  - 
                        # the profileId lives inside it, not as a top-level field
                        $statusObj  = $ap['deploymentProfileAssignmentStatus']
                        $statusStr  = ''
                        $profileId  = $null
                        if ($statusObj -is [System.Collections.IDictionary]) {
                            $profileId = [string]$statusObj['deploymentProfileId']
                            $statusStr = [string]$statusObj['value']   # e.g. 'assigned'
                            if (-not $statusStr) { $statusStr = [string]$statusObj['status'] }
                        } else {
                            $statusStr = [string]$statusObj   # simple string fallback
                        }

                        # Resolve profile name from the profiles map
                        $profileName = if ($profileId -and $profileMap.ContainsKey($profileId)) {
                                           $profileMap[$profileId]
                                       } else { $null }

                        $dict[$aadId.ToLower()] = @{
                            GroupTag      = [string]$ap['groupTag']
                            ProfileStatus = $statusStr
                        }
                    }
                    $nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
                } while ($nextUri)
                GLog "Autopilot: $total record(s), $($dict.Count) indexed by Entra Device ID" 'Success'
            } catch {
                GLog "Autopilot identities failed: $($_.Exception.Message)" 'Warning'
            }
            return ,$dict
        }

        # ── Build a result row from Entra + Intune data ──
        function Build-Row([hashtable]$EntraDev, [hashtable]$IntuneDev, [hashtable]$UserInfo, [string]$OwnerUPN = '', [hashtable]$AutopilotInfo = $null) {
            $NoRec = 'No Intune Record'
            $devName = [string]$EntraDev['displayName']

            # Entra fields
            $entraDevId  = [string]$EntraDev['deviceId']    # the GUID used as azureADDeviceId
            $entraObjId  = [string]$EntraDev['id']
            $entraOS     = [string]$EntraDev['operatingSystem']
            $entraOSVer  = [string]$EntraDev['operatingSystemVersion']
            $entraJoin   = switch ([string]$EntraDev['trustType']) {
                'AzureAd'    { 'Entra Joined'  }
                'ServerAd'   { 'Hybrid Joined' }
                'Workplace'  { 'Registered'    }
                default      { [string]$EntraDev['trustType'] }
            }
            $entraOwner  = [string]$EntraDev['deviceOwnership']
            $entraEnabled= [string]$EntraDev['accountEnabled']
            $entraSignIn = [string]$EntraDev['approximateLastSignInDateTime']
            $entraSignInFmt = Format-Date $entraSignIn
            $entraActivity = Get-ActivityRange $entraSignIn

            # Intune fields
            if ($IntuneDev) {
                $intuneId       = [string]$IntuneDev['id']
                $intuneOS       = [string]$IntuneDev['operatingSystem']
                $intuneOSVer    = [string]$IntuneDev['osVersion']
                $intuneSync     = [string]$IntuneDev['lastSyncDateTime']
                $intuneSyncFmt  = if ($intuneSync) { Format-Date $intuneSync } else { $NoRec }
                $intuneActivity = Get-ActivityRange $intuneSync
                $intuneOEM      = [string]$IntuneDev['manufacturer']
                $intuneModel    = [string]$IntuneDev['model']
                $intuneSerial   = [string]$IntuneDev['serialNumber']
                $intuneOwner    = [string]$IntuneDev['managedDeviceOwnerType']
                $intuneEnrolled = Format-Date ([string]$IntuneDev['enrolledDateTime'])
                $intuneUPN      = [string]$IntuneDev['userPrincipalName']
                $intuneCompliance = [string]$IntuneDev['complianceState']
            } else {
                $intuneId       = $NoRec
                $intuneOS       = $NoRec
                $intuneOSVer    = $NoRec
                $intuneSyncFmt  = $NoRec
                $intuneActivity = $NoRec
                $intuneOEM      = $NoRec
                $intuneModel    = $NoRec
                $intuneSerial   = $NoRec
                $intuneOwner    = $NoRec
                $intuneEnrolled = $NoRec
                $intuneUPN      = $NoRec
                $intuneCompliance = $NoRec
            }

            # Autopilot fields (from dedicated lookup  -  $null if not Autopilot registered)
            $apGroupTag  = if ($AutopilotInfo) { [string]$AutopilotInfo['GroupTag']     } else { $NoRec }
            $apStatus    = if ($AutopilotInfo) { [string]$AutopilotInfo['ProfileStatus'] } else { $NoRec }

            # Prefer Intune OS version for Windows release mapping
            $osVerForMap = if ($intuneOSVer -and $intuneOSVer -ne $NoRec) { $intuneOSVer } else { $entraOSVer }
            $winRelease  = Get-WinRelease $osVerForMap
            $devType     = Get-DevType $devName

            # User fields  -  UserInfo from Entra; fall back to OwnerUPN (Entra) or Intune UPN
            $resolvedUPN = if ($UserInfo) { [string]$UserInfo['userPrincipalName'] }
                           elseif ($OwnerUPN) { $OwnerUPN }
                           elseif ($intuneUPN -and $intuneUPN -ne $NoRec) { $intuneUPN }
                           else { $null }
            $uTitle   = if ($UserInfo) { [string]$UserInfo['jobTitle'] }       else { $null }
            $uEnabled = if ($UserInfo) { [string]$UserInfo['accountEnabled'] } else { $null }
            $uCity    = if ($UserInfo) { [string]$UserInfo['city'] }           else { $null }
            $uCountry = if ($UserInfo) { [string]$UserInfo['country'] }        else { $null }

            return [PSCustomObject]@{
                'Entra_DeviceName'            = $devName
                'DeviceType'                  = $devType
                'Entra_DeviceID'              = $entraDevId
                'Entra_ObjectID'              = $entraObjId
                'Intune_DeviceID'             = $intuneId
                'Entra_JoinType'              = $entraJoin
                'Entra_Ownership'             = $entraOwner
                'Intune_Ownership'            = $intuneOwner
                'Entra_OSPlatform'            = $entraOS
                'Intune_OSPlatform'           = $intuneOS
                'Entra_OSVersion'             = $entraOSVer
                'Intune_OSVersion'            = $intuneOSVer
                'Windows_Release'             = $winRelease
                'Entra_DeviceEnabled'         = $entraEnabled
                'Entra_LastSignIn'            = $entraSignInFmt
                'Entra_ActivityRange'         = $entraActivity
                'Intune_LastCheckIn'          = $intuneSyncFmt
                'Intune_ActivityRange'        = $intuneActivity
                'Intune_OEM'                  = $intuneOEM
                'Intune_Model'                = $intuneModel
                'Intune_SerialNumber'         = $intuneSerial
                'Intune_EnrolledDate'         = $intuneEnrolled
                'Intune_ComplianceState'      = $intuneCompliance
                'Entra_DeviceOwner'           = $OwnerUPN
                'Entra_PrimaryUserUPN'        = $resolvedUPN
                'Entra_UserAccountEnabled'    = $uEnabled
                'Entra_UserCountry'           = $uCountry
                'Entra_UserCity'              = $uCity
                'Entra_UserJobTitle'          = $uTitle
                'Autopilot_GroupTag'          = $apGroupTag
                'Autopilot_ProfileStatus'     = $apStatus
            }
        }

        # ══════════════════════════════════════════════════════════════════
        # MAIN WORK
        # ══════════════════════════════════════════════════════════════════
        try {
            Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue

            $params         = $GdiParams
            $platFilter     = @($params['PlatformFilter'])
            $ownerFilter    = $params['OwnershipFilter']
            $allDevicesMode = $params['AllDevices'] -eq $true

            $intuneCache    = @{}  # azureADDeviceId -> Intune device hashtable
            $userCache      = @{}  # userId / UPN -> user hashtable
            # Build Autopilot lookup once upfront  -  keyed by azureActiveDirectoryDeviceId (lowercase)
            $autopilotDict  = Build-AutopilotDictionary
            $entraIdSet  = [System.Collections.Generic.HashSet[string]]::new()  # deduplicate Entra Object IDs

            $deviceObjIds = [System.Collections.Generic.List[string]]::new()
            $isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'

            if ($allDevicesMode) {
                # ── All Devices mode: fetch every Entra device object ──
                GLog 'All Devices mode  -  fetching all Entra device objects...' 'Action'
                GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
                GLog "Ownership filter: $ownerFilter" 'Info'

                $allEntra = Get-GraphAll 'https://graph.microsoft.com/v1.0/devices?$select=id&$top=999'
                GLog "Retrieved $($allEntra.Count) Entra device object(s)" 'Info'
                foreach ($d in $allEntra) {
                    $null = $deviceObjIds.Add([string]$d['id'])
                }
            } else {
                # ── Input-based mode: resolve entries to Entra device Object IDs ──
                $inputType      = $params['InputType']
                $entries        = $params['InputTxt'] -split "`n" |
                                  ForEach-Object { $_.Trim() } |
                                  Where-Object   { $_ -ne '' }

                GLog "Input type      : $inputType" 'Info'
                GLog "Entries         : $($entries.Count)" 'Info'
                GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
                GLog "Ownership filter: $ownerFilter" 'Info'

                foreach ($entry in $entries) {
                    if ($GdiStop['Stop']) { GLog 'Stop requested.' 'Warning'; break }

                    switch -Wildcard ($inputType) {

                    'User UPNs*' {
                        GLog "Resolving user devices: $entry" 'Info'
                        try {
                            $uid = [Uri]::EscapeDataString($entry)
                            $devs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id,displayName,deviceId,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime`&`$top=999"
                            GLog "  $($devs.Count) owned device(s) found" 'Info'
                            foreach ($d in $devs) { $null = $deviceObjIds.Add([string]$d['id']) }
                        } catch {
                            GLog "  Could not resolve user $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Device names' {
                        GLog "Resolving device by name: $entry" 'Info'
                        try {
                            $safe = $entry -replace "'","''"
                            $r = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id`&`$top=5"
                            $found = @($r['value'])
                            if ($found.Count -eq 0) { GLog "  Not found: $entry" 'Warning' }
                            else { foreach ($d in $found) { $null = $deviceObjIds.Add([string]$d['id']) } }
                        } catch {
                            GLog "  Error resolving $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Device serial numbers' {
                        GLog "Resolving serial number: $entry" 'Info'
                        # Serial numbers only exist in Intune  -  search there first, get azureADDeviceId, then Entra
                        try {
                            $safe = $entry -replace "'","''"
                            $r = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$safe'`&`$select=id,azureADDeviceId,deviceName`&`$top=5"
                            $found = @($r['value'])
                            if ($found.Count -eq 0) { GLog "  Serial not found in Intune: $entry" 'Warning' }
                            else {
                                foreach ($md in $found) {
                                    $aadId = [string]$md['azureADDeviceId']
                                    if ($aadId) {
                                        # Find Entra Object ID from azureADDeviceId (deviceId field)
                                        $er = Invoke-MgGraphRequest -Method GET `
                                            -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
                                        if ($er['value'] -and $er['value'].Count -gt 0) {
                                            $null = $deviceObjIds.Add([string]$er['value'][0]['id'])
                                        }
                                    }
                                }
                            }
                        } catch {
                            GLog "  Error resolving serial $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Entra Device Object IDs' {
                        if ($entry -match $isGuidRx) {
                            $null = $deviceObjIds.Add($entry)
                        } else {
                            GLog "  Skipped (not a GUID): $entry" 'Warning'
                        }
                    }

                    'Intune Device IDs' {
                        GLog "Resolving Intune device ID: $entry" 'Info'
                        try {
                            $md = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$entry`?`$select=id,azureADDeviceId,deviceName"
                            $aadId = [string]$md['azureADDeviceId']
                            if ($aadId) {
                                $er = Invoke-MgGraphRequest -Method GET `
                                    -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
                                if ($er['value'] -and $er['value'].Count -gt 0) {
                                    $null = $deviceObjIds.Add([string]$er['value'][0]['id'])
                                }
                            }
                        } catch {
                            GLog "  Could not resolve Intune ID $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Groups*' {
                        GLog "Resolving group: $entry" 'Info'
                        # Resolve group ID
                        $groupId = $null
                        if ($entry -match $isGuidRx) {
                            $groupId = $entry
                        } else {
                            try {
                                $safe = $entry -replace "'","''"
                                $gr = Invoke-MgGraphRequest -Method GET `
                                    -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id`&`$top=1"
                                if ($gr['value'] -and $gr['value'].Count -gt 0) {
                                    $groupId = [string]$gr['value'][0]['id']
                                }
                            } catch {}
                        }
                        if (-not $groupId) { GLog "  Group not found: $entry" 'Warning'; continue }

                        # Get all transitive members (users + devices)
                        GLog "  Scanning group members (transitive)..." 'Info'
                        # $select with @odata.type or user-subtype properties causes 400 on
                        # transitiveMembers (directoryObject base type). Use $select=id only;
                        # @odata.type is always returned automatically.
                        $members = Get-GraphAll "https://graph.microsoft.com/v1.0/groups/$groupId/transitiveMembers?`$select=id`&`$top=999"
                        GLog "  $($members.Count) member(s) found" 'Info'

                        foreach ($mem in $members) {
                            if ($GdiStop['Stop']) { break }
                            $odataType = [string]$mem['@odata.type']
                            if ($odataType -eq '#microsoft.graph.device') {
                                $null = $deviceObjIds.Add([string]$mem['id'])
                            } elseif ($odataType -eq '#microsoft.graph.user') {
                                # Resolve user's owned devices
                                $uid = [string]$mem['id']
                                try {
                                    $udevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id`&`$top=999"
                                    foreach ($ud in $udevs) { $null = $deviceObjIds.Add([string]$ud['id']) }
                                } catch {}
                            }
                        }
                    }
                }
            }

                        }  # end else (input-based mode)

            GLog "Total device IDs to process: $($deviceObjIds.Count)" 'Info'
            if ($deviceObjIds.Count -eq 0) {
                .Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
                return
            }

            # ── Process each Entra device ──
            $results = [System.Collections.Generic.List[PSCustomObject]]::new()
            $processed = 0

            foreach ($objId in $deviceObjIds) {
                if ($GdiStop['Stop']) { GLog 'Stop requested  -  halting.' 'Warning'; break }
                if ($entraIdSet.Contains($objId)) { continue }   # deduplicate
                $null = $entraIdSet.Add($objId)

                $processed++
                $GdiQueue.Enqueue(@{ Type='progress'; Current=$processed; Total=$deviceObjIds.Count })

                # Fetch full Entra device record
                $entraRaw = $null
                try {
                    $entraRaw = Invoke-MgGraphRequest -Method GET `
                        -Uri "https://graph.microsoft.com/v1.0/devices/$objId`?`$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime,registrationDateTime"
                } catch {
                    GLog "  Could not fetch Entra device $objId : $($_.Exception.Message)" 'Warning'
                    continue
                }

                $devName   = [string]$entraRaw['displayName']
                $entraDevId = [string]$entraRaw['deviceId']   # azureADDeviceId in Intune
                $entraOS   = Normalize-OS ([string]$entraRaw['operatingSystem'])

                GLog "[$processed] $devName  [$entraOS]" 'Info'

                # ── Platform filter ──
                if ($platFilter.Count -gt 0 -and $entraOS -notin $platFilter) {
                    GLog "  Skipped (platform $entraOS not in filter)" 'Info'
                    continue
                }

                # ── Ownership filter ──
                $rawOwner = [string]$entraRaw['deviceOwnership']
                if ($ownerFilter -ne 'All' -and $rawOwner -ne $ownerFilter) {
                    GLog "  Skipped (ownership $rawOwner not in filter)" 'Info'
                    continue
                }

                # ── Intune lookup ──
                $intuneDev = Get-IntuneByEntraId -EntraId $entraDevId -Cache $intuneCache
                if ($intuneDev) {
                    GLog "  Intune: $([string]$intuneDev['id'])" 'Success'
                } else {
                    GLog "  Intune: No Intune Record" 'Warning'
                }

                # ── Registered owner (user) ──
                # No $select on registeredOwners  -  $select is not supported on navigation
                # endpoints and causes userPrincipalName to be missing from the response.
                $userInfo    = $null
                $ownerUPN    = $null
                try {
                    $ownerResp = Invoke-MgGraphRequest -Method GET `
                        -Uri "https://graph.microsoft.com/v1.0/devices/$objId/registeredOwners?`$top=5"
                    $ownerList = @($ownerResp['value'])
                    $firstUser = $ownerList | Where-Object {
                        $t = [string]$_['@odata.type']
                        $t -eq '#microsoft.graph.user' -or $t -eq 'microsoft.graph.user'
                    } | Select-Object -First 1
                    if ($firstUser) {
                        # userPrincipalName lives in AdditionalProperties for some SDK versions,
                        # or directly on the hashtable when using Invoke-MgGraphRequest
                        $ownerUPN = if ($firstUser.ContainsKey('userPrincipalName')) {
                                        [string]$firstUser['userPrincipalName']
                                    } elseif ($firstUser.ContainsKey('AdditionalProperties')) {
                                        [string]$firstUser['AdditionalProperties']['userPrincipalName']
                                    } else { $null }
                        $uid = [string]$firstUser['id']
                        # If UPN still empty, fetch it from the user endpoint directly
                        if ([string]::IsNullOrWhiteSpace($ownerUPN) -and $uid) {
                            try {
                                $upnResp  = Invoke-MgGraphRequest -Method GET `
                                    -Uri "https://graph.microsoft.com/v1.0/users/$uid`?`$select=userPrincipalName"
                                $ownerUPN = [string]$upnResp['userPrincipalName']
                            } catch {}
                        }
                        GLog "  Owner: $ownerUPN" 'Info'
                        if ($userCache.ContainsKey($uid)) {
                            $userInfo = $userCache[$uid]
                        } else {
                            $userInfo = Get-UserInfo -UserId $uid
                            $userCache[$uid] = $userInfo
                        }
                    } else {
                        GLog "  No registered owner found" 'Info'
                    }
                } catch {
                    GLog "  Owner fetch error: $($_.Exception.Message)" 'Warning'
                }

                # ── Autopilot lookup  -  dictionary built upfront, keyed by entraDevId lowercase ──
                $autopilotInfo = $null
                $entraOSNorm = Normalize-OS ([string]$entraRaw['operatingSystem'])
                if ($entraOSNorm -eq 'Windows' -and $entraDevId) {
                    $apKey = $entraDevId.ToLower()
                    if ($autopilotDict.ContainsKey($apKey)) {
                        $autopilotInfo = $autopilotDict[$apKey]
                        GLog "  Autopilot: Tag=$($autopilotInfo['GroupTag']) | Status=$($autopilotInfo['ProfileStatus'])" 'Info'
                    } else {
                        GLog '  Autopilot: not registered' 'Info'
                    }
                }

                # ── Build row ──
                $row = Build-Row -EntraDev $entraRaw -IntuneDev $intuneDev -UserInfo $userInfo -OwnerUPN $ownerUPN -AutopilotInfo $autopilotInfo
                $results.Add($row)
            }

            GLog "Processed $($results.Count) device(s)." 'Success'
            $GdiQueue.Enqueue(@{ Type='done'; Rows=$results; Error=$null })

        } catch {
            $GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
        }
    })

    $script:GdiHandle = $script:GdiPs.BeginInvoke()

    # ── Timer to drain GdiQueue on the UI thread ──
    $script:GdiTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:GdiTimer.Interval = [TimeSpan]::FromMilliseconds(300)
    $script:GdiTimer.Add_Tick({
        $entry = $null
        while ($script:GdiQueue.TryDequeue([ref]$entry)) {
            switch ($entry['Type']) {
                'log' {
                    $lvl = $entry['Level']
                    $col = switch ($lvl) {
                        'Success' { $DevConfig.LogColorSuccess }
                        'Warning' { $DevConfig.LogColorWarning }
                        'Error'   { $DevConfig.LogColorError   }
                        'Action'  { $DevConfig.LogColorAction  }
                        default   { $DevConfig.LogColorInfo    }
                    }
                    try {
                        $para = [System.Windows.Documents.Paragraph]::new()
                        $run  = [System.Windows.Documents.Run]::new($entry['Line'])
                        $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                            [System.Windows.Media.ColorConverter]::ConvertFromString($col))
                        $para.Margin = [System.Windows.Thickness]::new(0)
                        $para.Inlines.Add($run)
                        $script:RtbLog.Document.Blocks.Add($para)
                        $script:RtbLog.ScrollToEnd()
                    } catch {}
                }
                'progress' {
                    $cur = $entry['Current']; $tot = $entry['Total']
                    $script:Window.FindName('PnlGDIProgress').Visibility       = 'Visible'
                    $script:Window.FindName('TxtGDIProgressMsg').Text          = 'Fetching device details...'
                    $script:Window.FindName('TxtGDIProgressDetail').Text       = "Processing device $cur of $tot..."
                    Update-StatusBar -Text "Get Device Info: $cur / $tot devices..."
                }
                'done' {
                    $script:GdiTimer.Stop()
                    try { $script:GdiPs.EndInvoke($script:GdiHandle) } catch {}
                    try { $script:GdiRs.Close() }                      catch {}
                    try { $script:GdiPs.Dispose() }                    catch {}
                    $script:GdiPs = $null; $script:GdiRs = $null
                    $script:Window.FindName('BtnStop').Visibility = 'Collapsed'
                    $script:Window.FindName('BtnStop').IsEnabled  = $true
                    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
                    $script:Window.FindName('BtnGDIRun').IsEnabled = $true
                    $script:Window.FindName('BtnGDIRunAll').IsEnabled = $true
                    $script:Window.FindName('PnlGDIProgress').Visibility = 'Collapsed'

                    $errMsg = $entry['Error']
                    if ($errMsg) {
                        Write-VerboseLog "Get Device Info error: $errMsg" -Level Error
                        Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
                        Update-StatusBar -Text "Connected as: $($script:TxtStatusBar.Text -replace 'Get Device Info.*','')"
                        return
                    }

                    $rows     = @($entry['Rows'])

                    if ($rows.Count -eq 0) {
                        Write-VerboseLog 'Get Device Info: no rows  -  check filters or input.' -Level Warning
                        $script:Window.FindName('TxtGDINoResults').Visibility = 'Visible'
                        Show-Notification 'No devices matched the input and filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    # ── Populate DataGrid ──
                    $dg = $script:Window.FindName('DgGDIResults')
                    $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
                    $script:Window.FindName('TxtGDICount').Text            = "$($rows.Count) device(s) found"
                    $script:Window.FindName('PnlGDIResults').Visibility     = 'Visible'
                    $script:Window.FindName('BtnGDICopyAll').IsEnabled      = $true
                    $script:Window.FindName('TxtGDIFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnGDIFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnGDIFilterClear').IsEnabled = $true
                    $script:Window.FindName('BtnGDIExportXlsx').IsEnabled   = $true
                    Write-VerboseLog "Get Device Info: $($rows.Count) device(s) loaded into table." -Level Success
                    Show-Notification "$($rows.Count) device(s) ready. Use Export XLSX to save." -BgColor '#D4EDDA' -FgColor '#155724'
                    Update-StatusBar -Text 'Connected'
                }
            }
        }
    })
    $script:GdiTimer.Start()
})


# -- BtnGDIRunAll: Get All Device Info --
$script:Window.FindName('BtnGDIRunAll').Add_Click({
    Hide-Notification

    # Collect filters on UI thread (no input list needed)
    $platFilter = [System.Collections.Generic.List[string]]::new()
    if ($script:Window.FindName('ChkGDIWindows').IsChecked) { $platFilter.Add('Windows') }
    if ($script:Window.FindName('ChkGDIAndroid').IsChecked) { $platFilter.Add('Android') }
    if ($script:Window.FindName('ChkGDIiOS').IsChecked)     { $platFilter.Add('iOS') }
    if ($script:Window.FindName('ChkGDIMacOS').IsChecked)   { $platFilter.Add('macOS') }

    $ownershipSel = $script:Window.FindName('CmbGDIOwnership').SelectedItem.Content
    $ownershipFilter = switch ($ownershipSel) {
        'Company only'  { 'Company' }
        'Personal only' { 'Personal' }
        default         { 'All' }
    }

    $script:GDIParams = @{
        AllDevices      = $true
        PlatformFilter  = $platFilter
        OwnershipFilter = $ownershipFilter
    }

    # Reset table state
    $script:Window.FindName('PnlGDIResults').Visibility   = 'Collapsed'
    $script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
    $script:Window.FindName('BtnGDICopyValue').IsEnabled  = $false
    $script:Window.FindName('BtnGDICopyRow').IsEnabled    = $false
    $script:Window.FindName('BtnGDICopyAll').IsEnabled    = $false
    $script:GDIAllData = $null
    $script:Window.FindName('TxtGDIFilter').IsEnabled  = $false
    $script:Window.FindName('BtnGDIFilter').IsEnabled  = $false
    $script:Window.FindName('BtnGDIFilterClear').IsEnabled = $false
    $script:Window.FindName('TxtGDIFilter').Text       = ''
    $script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $false
    $dg = $script:Window.FindName('DgGDIResults')
    if ($dg) { $dg.ItemsSource = $null }
    $script:Window.FindName('PnlGDIProgress').Visibility      = 'Visible'
    $script:Window.FindName('TxtGDIProgressMsg').Text         = 'Fetching all Entra devices...'
    $script:Window.FindName('TxtGDIProgressDetail').Text      = ''

    $script:Window.FindName('BtnGDIRun').IsEnabled    = $false
    $script:Window.FindName('BtnGDIRunAll').IsEnabled = $false
    $script:Window.FindName('BtnStop').Visibility = 'Visible'
    $script:Window.FindName('BtnStop').IsEnabled  = $true
    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
    $script:BgStopped = $false
    Write-VerboseLog '--- Get All Device Info: starting ---' -Level Action

    $script:GdiQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    $script:GdiStop  = [hashtable]::Synchronized(@{ Stop = $false })

    $script:GdiRs = [runspacefactory]::CreateRunspace()
    $script:GdiRs.ApartmentState = 'STA'
    $script:GdiRs.ThreadOptions  = 'ReuseThread'
    $script:GdiRs.Open()
    $script:GdiRs.SessionStateProxy.SetVariable('GdiQueue',  $script:GdiQueue)
    $script:GdiRs.SessionStateProxy.SetVariable('GdiStop',   $script:GdiStop)
    $script:GdiRs.SessionStateProxy.SetVariable('GdiParams', $script:GDIParams)
    $script:GdiRs.SessionStateProxy.SetVariable('LogFile',   $script:LogFile)

    $script:GdiPs = [powershell]::Create()
    $script:GdiPs.Runspace = $script:GdiRs

    $null = $script:GdiPs.AddScript({
        # ── Helper: enqueue a log message ──
        function GLog {
            param([string]$Msg, [string]$Level = 'Info')
            $prefix = switch ($Level) {
                "Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
                'Action'  { '> ' } default   { '  ' }
            }
            $line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
            $GdiQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
            if ($LogFile) {
                try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
            }
        }

        # ── OS normalisation (Entra raw → friendly) ──
        function Normalize-OS([string]$os) {
            switch -Wildcard ($os) {
                'Windows*' { 'Windows' } 'Android*' { 'Android' }
                'iPhone'   { 'iOS'     } 'iPadOS'   { 'iOS'     }
                'iOS'      { 'iOS'     } 'MacMDM'   { 'macOS'   }
                'Mac OS X' { 'macOS'   } 'macOS'    { 'macOS'   }
                default    { $os }
            }
        }

        # ── Windows release lookup ──
        $WinVerMap = @(
            @{ V='10.0.26200'; R='25H2'          }
            @{ V='10.0.28000'; R='26H1'           }
            @{ V='10.0.26100'; R='24H2'           }
            @{ V='10.0.22631'; R='23H2'           }
            @{ V='10.0.22621'; R='22H2 (Win11)'   }
            @{ V='10.0.19045'; R='22H2 (Win10)'   }
            @{ V='10.0.19044'; R='21H2 (LTSC)'    }
            @{ V='10.0.17763'; R='1809 (LTSC)'    }
        )
        function Get-WinRelease([string]$ver) {
            if ([string]::IsNullOrWhiteSpace($ver)) { return $null }
            $v = $ver.Trim()
            $m = $WinVerMap | Where-Object { $_.V -eq $v } | Select-Object -First 1
            if ($m) { return $m.R }
            # Try major.minor.build prefix only
            if ($v -match '^\d+\.\d+\.\d+') {
                $pfx = ($Matches[0])
                $m = $WinVerMap | Where-Object { $_.V -eq $pfx } | Select-Object -First 1
                if ($m) { return $m.R }
            }
            return $null
        }

        # ── Device type from name suffix ──
        $DevTypeMap = @(
            @{ S='-D'; T='Desktop'            }
            @{ S='-L'; T='Laptop'             }
            @{ S='-V'; T='Virtual'            }
            @{ S='-K'; T='Kiosk'              }
            @{ S='-F'; T='Teams Meeting Room' }
            @{ S='-A'; T='Shared Device'      }
        )
        function Get-DevType([string]$name) {
            if ([string]::IsNullOrWhiteSpace($name)) { return $null }
            $u = $name.Trim().ToUpperInvariant()
            foreach ($m in $DevTypeMap) {
                if ($u.EndsWith($m.S.ToUpperInvariant())) { return $m.T }
            }
            return $null
        }

        # ── Activity range label ──
        # Tries RoundtripKind first (ISO 8601/UTC), then InvariantCulture general parse
        # as fallback  -  same two-style approach as Format-Date to handle all Graph date formats.
        function Get-ActivityRange([string]$dateStr) {
            if ([string]::IsNullOrWhiteSpace($dateStr)) { return $null }
            $d = $null
            foreach ($style in @(
                [System.Globalization.DateTimeStyles]::RoundtripKind,
                [System.Globalization.DateTimeStyles]::None
            )) {
                try {
                    $d = [datetime]::Parse($dateStr,
                             [System.Globalization.CultureInfo]::InvariantCulture, $style)
                    break
                } catch {}
            }
            if ($null -eq $d) { return $null }
            $days = [int][Math]::Floor(([datetime]::UtcNow - $d.ToUniversalTime()).TotalDays)
            if ($days -le 7)       { return '0-7 days'        }
            elseif ($days -le 14)  { return '8-14 days'       }
            elseif ($days -le 30)  { return '15-30 days'      }
            elseif ($days -le 60)  { return '30-60 days'      }
            elseif ($days -le 90)  { return '60-90 days'      }
            elseif ($days -le 180) { return '90-180 days'     }
            elseif ($days -le 365) { return '180-365 days'    }
            else                   { return 'Older than 1 yr' }
        }

        # ── Page through a Graph URI ──
        function Get-GraphAll([string]$Uri, [hashtable]$Headers = @{}) {
            $items = [System.Collections.Generic.List[object]]::new()
            $next  = $Uri
            do {
                $r = Invoke-MgGraphRequest -Method GET -Uri $next -Headers $Headers
                if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
                $next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
            } while ($next)
            return ,$items
        }

        # ── Fetch full Entra device record ──
        function Get-EntraDevice([string]$Uri) {
            try {
                return Invoke-MgGraphRequest -Method GET -Uri $Uri
            } catch { return $null }
        }

        # ── Fetch Intune device by azureADDeviceId ──
        function Get-IntuneByEntraId([string]$EntraId, [hashtable]$Cache) {
            if ([string]::IsNullOrWhiteSpace($EntraId)) { return $null }
            if ($Cache.ContainsKey($EntraId)) { return $Cache[$EntraId] }
            try {
                $enc  = [Uri]::EscapeDataString($EntraId)
                $resp = Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$enc'`&`$select=id,deviceName,operatingSystem,osVersion,lastSyncDateTime,manufacturer,model,serialNumber,managedDeviceOwnerType,enrolledDateTime,azureADDeviceId,userPrincipalName,complianceState`&`$top=1"
                $dev = if ($resp['value'] -and $resp['value'].Count -gt 0) { $resp['value'][0] } else { $null }
                $Cache[$EntraId] = $dev
                return $dev
            } catch { $Cache[$EntraId] = $null; return $null }
        }

        # ── Fetch user info ──
        function Get-UserInfo([string]$UserId) {
            try {
                return Invoke-MgGraphRequest -Method GET `
                    -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($UserId))?`$select=id,userPrincipalName,displayName,jobTitle,accountEnabled,city,country,officeLocation"
            } catch { return $null }
        }

        # ── Format any date string to MM-dd-yyyy, tolerating multiple input formats ──
        function Format-Date([string]$raw) {
            if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
            # Try ISO 8601 / RoundtripKind first, then general parse as fallback
            foreach ($style in @(
                [System.Globalization.DateTimeStyles]::RoundtripKind,
                [System.Globalization.DateTimeStyles]::None
            )) {
                try {
                    return ([datetime]::Parse($raw, [System.Globalization.CultureInfo]::InvariantCulture, $style)).ToString('MM-dd-yyyy')
                } catch {}
            }
            return $raw   # return raw if all parsing fails
        }

        # ── Build Autopilot lookup dictionary ──────────────────────────────────────
        # deploymentProfileAssignmentStatus is a NESTED OBJECT containing deploymentProfileId.
        # Profile name is resolved by: $ap['deploymentProfileAssignmentStatus']['deploymentProfileId']
        # → fetch profile display name from windowsAutopilotDeploymentProfiles/{id}.
        # Step 1: load all profiles into id->name map.
        # Step 2: load all identities, extract profileId from nested status object.
        function Build-AutopilotDictionary {
            $dict       = @{}
            $profileMap = @{}

            # ── Step 1: all deployment profiles ──
            try {
                $nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName&$top=100'
                do {
                    $r = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                    foreach ($p in @($r['value'])) { $profileMap[[string]$p['id']] = [string]$p['displayName'] }
                    $nextUri = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
                } while ($nextUri)
                GLog "Autopilot: $($profileMap.Count) profile(s) loaded" 'Info'
            } catch {
                GLog "Autopilot profiles failed: $($_.Exception.Message)" 'Warning'
            }

            # ── Step 2: all device identities ──
            try {
                GLog 'Autopilot: loading device identities...' 'Info'
                $nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?$top=500'
                $total   = 0
                do {
                    $resp  = Invoke-MgGraphRequest -Method GET -Uri $nextUri
                    $page  = @($resp['value'])
                    $total += $page.Count
                    foreach ($ap in $page) {
                        $aadId = [string]$ap['azureActiveDirectoryDeviceId']
                        if ([string]::IsNullOrWhiteSpace($aadId)) { continue }

                        # deploymentProfileAssignmentStatus is a nested object  - 
                        # the profileId lives inside it, not as a top-level field
                        $statusObj  = $ap['deploymentProfileAssignmentStatus']
                        $statusStr  = ''
                        $profileId  = $null
                        if ($statusObj -is [System.Collections.IDictionary]) {
                            $profileId = [string]$statusObj['deploymentProfileId']
                            $statusStr = [string]$statusObj['value']   # e.g. 'assigned'
                            if (-not $statusStr) { $statusStr = [string]$statusObj['status'] }
                        } else {
                            $statusStr = [string]$statusObj   # simple string fallback
                        }

                        # Resolve profile name from the profiles map
                        $profileName = if ($profileId -and $profileMap.ContainsKey($profileId)) {
                                           $profileMap[$profileId]
                                       } else { $null }

                        $dict[$aadId.ToLower()] = @{
                            GroupTag      = [string]$ap['groupTag']
                            ProfileStatus = $statusStr
                        }
                    }
                    $nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
                } while ($nextUri)
                GLog "Autopilot: $total record(s), $($dict.Count) indexed by Entra Device ID" 'Success'
            } catch {
                GLog "Autopilot identities failed: $($_.Exception.Message)" 'Warning'
            }
            return ,$dict
        }

        # ── Build a result row from Entra + Intune data ──
        function Build-Row([hashtable]$EntraDev, [hashtable]$IntuneDev, [hashtable]$UserInfo, [string]$OwnerUPN = '', [hashtable]$AutopilotInfo = $null) {
            $NoRec = 'No Intune Record'
            $devName = [string]$EntraDev['displayName']

            # Entra fields
            $entraDevId  = [string]$EntraDev['deviceId']    # the GUID used as azureADDeviceId
            $entraObjId  = [string]$EntraDev['id']
            $entraOS     = [string]$EntraDev['operatingSystem']
            $entraOSVer  = [string]$EntraDev['operatingSystemVersion']
            $entraJoin   = switch ([string]$EntraDev['trustType']) {
                'AzureAd'    { 'Entra Joined'  }
                'ServerAd'   { 'Hybrid Joined' }
                'Workplace'  { 'Registered'    }
                default      { [string]$EntraDev['trustType'] }
            }
            $entraOwner  = [string]$EntraDev['deviceOwnership']
            $entraEnabled= [string]$EntraDev['accountEnabled']
            $entraSignIn = [string]$EntraDev['approximateLastSignInDateTime']
            $entraSignInFmt = Format-Date $entraSignIn
            $entraActivity = Get-ActivityRange $entraSignIn

            # Intune fields
            if ($IntuneDev) {
                $intuneId       = [string]$IntuneDev['id']
                $intuneOS       = [string]$IntuneDev['operatingSystem']
                $intuneOSVer    = [string]$IntuneDev['osVersion']
                $intuneSync     = [string]$IntuneDev['lastSyncDateTime']
                $intuneSyncFmt  = if ($intuneSync) { Format-Date $intuneSync } else { $NoRec }
                $intuneActivity = Get-ActivityRange $intuneSync
                $intuneOEM      = [string]$IntuneDev['manufacturer']
                $intuneModel    = [string]$IntuneDev['model']
                $intuneSerial   = [string]$IntuneDev['serialNumber']
                $intuneOwner    = [string]$IntuneDev['managedDeviceOwnerType']
                $intuneEnrolled = Format-Date ([string]$IntuneDev['enrolledDateTime'])
                $intuneUPN      = [string]$IntuneDev['userPrincipalName']
                $intuneCompliance = [string]$IntuneDev['complianceState']
            } else {
                $intuneId       = $NoRec
                $intuneOS       = $NoRec
                $intuneOSVer    = $NoRec
                $intuneSyncFmt  = $NoRec
                $intuneActivity = $NoRec
                $intuneOEM      = $NoRec
                $intuneModel    = $NoRec
                $intuneSerial   = $NoRec
                $intuneOwner    = $NoRec
                $intuneEnrolled = $NoRec
                $intuneUPN      = $NoRec
                $intuneCompliance = $NoRec
            }

            # Autopilot fields (from dedicated lookup  -  $null if not Autopilot registered)
            $apGroupTag  = if ($AutopilotInfo) { [string]$AutopilotInfo['GroupTag']     } else { $NoRec }
            $apStatus    = if ($AutopilotInfo) { [string]$AutopilotInfo['ProfileStatus'] } else { $NoRec }

            # Prefer Intune OS version for Windows release mapping
            $osVerForMap = if ($intuneOSVer -and $intuneOSVer -ne $NoRec) { $intuneOSVer } else { $entraOSVer }
            $winRelease  = Get-WinRelease $osVerForMap
            $devType     = Get-DevType $devName

            # User fields  -  UserInfo from Entra; fall back to OwnerUPN (Entra) or Intune UPN
            $resolvedUPN = if ($UserInfo) { [string]$UserInfo['userPrincipalName'] }
                           elseif ($OwnerUPN) { $OwnerUPN }
                           elseif ($intuneUPN -and $intuneUPN -ne $NoRec) { $intuneUPN }
                           else { $null }
            $uTitle   = if ($UserInfo) { [string]$UserInfo['jobTitle'] }       else { $null }
            $uEnabled = if ($UserInfo) { [string]$UserInfo['accountEnabled'] } else { $null }
            $uCity    = if ($UserInfo) { [string]$UserInfo['city'] }           else { $null }
            $uCountry = if ($UserInfo) { [string]$UserInfo['country'] }        else { $null }

            return [PSCustomObject]@{
                'Entra_DeviceName'            = $devName
                'DeviceType'                  = $devType
                'Entra_DeviceID'              = $entraDevId
                'Entra_ObjectID'              = $entraObjId
                'Intune_DeviceID'             = $intuneId
                'Entra_JoinType'              = $entraJoin
                'Entra_Ownership'             = $entraOwner
                'Intune_Ownership'            = $intuneOwner
                'Entra_OSPlatform'            = $entraOS
                'Intune_OSPlatform'           = $intuneOS
                'Entra_OSVersion'             = $entraOSVer
                'Intune_OSVersion'            = $intuneOSVer
                'Windows_Release'             = $winRelease
                'Entra_DeviceEnabled'         = $entraEnabled
                'Entra_LastSignIn'            = $entraSignInFmt
                'Entra_ActivityRange'         = $entraActivity
                'Intune_LastCheckIn'          = $intuneSyncFmt
                'Intune_ActivityRange'        = $intuneActivity
                'Intune_OEM'                  = $intuneOEM
                'Intune_Model'                = $intuneModel
                'Intune_SerialNumber'         = $intuneSerial
                'Intune_EnrolledDate'         = $intuneEnrolled
                'Intune_ComplianceState'      = $intuneCompliance
                'Entra_DeviceOwner'           = $OwnerUPN
                'Entra_PrimaryUserUPN'        = $resolvedUPN
                'Entra_UserAccountEnabled'    = $uEnabled
                'Entra_UserCountry'           = $uCountry
                'Entra_UserCity'              = $uCity
                'Entra_UserJobTitle'          = $uTitle
                'Autopilot_GroupTag'          = $apGroupTag
                'Autopilot_ProfileStatus'     = $apStatus
            }
        }

        # ══════════════════════════════════════════════════════════════════
        # MAIN WORK
        # ══════════════════════════════════════════════════════════════════
        try {
            Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue

            $params         = $GdiParams
            $platFilter     = @($params['PlatformFilter'])
            $ownerFilter    = $params['OwnershipFilter']
            $allDevicesMode = $params['AllDevices'] -eq $true

            $intuneCache    = @{}  # azureADDeviceId -> Intune device hashtable
            $userCache      = @{}  # userId / UPN -> user hashtable
            # Build Autopilot lookup once upfront  -  keyed by azureActiveDirectoryDeviceId (lowercase)
            $autopilotDict  = Build-AutopilotDictionary
            $entraIdSet  = [System.Collections.Generic.HashSet[string]]::new()  # deduplicate Entra Object IDs

            $deviceObjIds = [System.Collections.Generic.List[string]]::new()
            $isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'

            if ($allDevicesMode) {
                # ── All Devices mode: fetch every Entra device object ──
                GLog 'All Devices mode  -  fetching all Entra device objects...' 'Action'
                GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
                GLog "Ownership filter: $ownerFilter" 'Info'

                $allEntra = Get-GraphAll 'https://graph.microsoft.com/v1.0/devices?$select=id&$top=999'
                GLog "Retrieved $($allEntra.Count) Entra device object(s)" 'Info'
                foreach ($d in $allEntra) {
                    $null = $deviceObjIds.Add([string]$d['id'])
                }
            } else {
                # ── Input-based mode: resolve entries to Entra device Object IDs ──
                $inputType      = $params['InputType']
                $entries        = $params['InputTxt'] -split "`n" |
                                  ForEach-Object { $_.Trim() } |
                                  Where-Object   { $_ -ne '' }

                GLog "Input type      : $inputType" 'Info'
                GLog "Entries         : $($entries.Count)" 'Info'
                GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
                GLog "Ownership filter: $ownerFilter" 'Info'

                foreach ($entry in $entries) {
                    if ($GdiStop['Stop']) { GLog 'Stop requested.' 'Warning'; break }

                    switch -Wildcard ($inputType) {

                    'User UPNs*' {
                        GLog "Resolving user devices: $entry" 'Info'
                        try {
                            $uid = [Uri]::EscapeDataString($entry)
                            $devs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id,displayName,deviceId,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime`&`$top=999"
                            GLog "  $($devs.Count) owned device(s) found" 'Info'
                            foreach ($d in $devs) { $null = $deviceObjIds.Add([string]$d['id']) }
                        } catch {
                            GLog "  Could not resolve user $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Device names' {
                        GLog "Resolving device by name: $entry" 'Info'
                        try {
                            $safe = $entry -replace "'","''"
                            $r = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id`&`$top=5"
                            $found = @($r['value'])
                            if ($found.Count -eq 0) { GLog "  Not found: $entry" 'Warning' }
                            else { foreach ($d in $found) { $null = $deviceObjIds.Add([string]$d['id']) } }
                        } catch {
                            GLog "  Error resolving $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Device serial numbers' {
                        GLog "Resolving serial number: $entry" 'Info'
                        # Serial numbers only exist in Intune  -  search there first, get azureADDeviceId, then Entra
                        try {
                            $safe = $entry -replace "'","''"
                            $r = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$safe'`&`$select=id,azureADDeviceId,deviceName`&`$top=5"
                            $found = @($r['value'])
                            if ($found.Count -eq 0) { GLog "  Serial not found in Intune: $entry" 'Warning' }
                            else {
                                foreach ($md in $found) {
                                    $aadId = [string]$md['azureADDeviceId']
                                    if ($aadId) {
                                        # Find Entra Object ID from azureADDeviceId (deviceId field)
                                        $er = Invoke-MgGraphRequest -Method GET `
                                            -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
                                        if ($er['value'] -and $er['value'].Count -gt 0) {
                                            $null = $deviceObjIds.Add([string]$er['value'][0]['id'])
                                        }
                                    }
                                }
                            }
                        } catch {
                            GLog "  Error resolving serial $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Entra Device Object IDs' {
                        if ($entry -match $isGuidRx) {
                            $null = $deviceObjIds.Add($entry)
                        } else {
                            GLog "  Skipped (not a GUID): $entry" 'Warning'
                        }
                    }

                    'Intune Device IDs' {
                        GLog "Resolving Intune device ID: $entry" 'Info'
                        try {
                            $md = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$entry`?`$select=id,azureADDeviceId,deviceName"
                            $aadId = [string]$md['azureADDeviceId']
                            if ($aadId) {
                                $er = Invoke-MgGraphRequest -Method GET `
                                    -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
                                if ($er['value'] -and $er['value'].Count -gt 0) {
                                    $null = $deviceObjIds.Add([string]$er['value'][0]['id'])
                                }
                            }
                        } catch {
                            GLog "  Could not resolve Intune ID $entry : $($_.Exception.Message)" 'Warning'
                        }
                    }

                    'Groups*' {
                        GLog "Resolving group: $entry" 'Info'
                        # Resolve group ID
                        $groupId = $null
                        if ($entry -match $isGuidRx) {
                            $groupId = $entry
                        } else {
                            try {
                                $safe = $entry -replace "'","''"
                                $gr = Invoke-MgGraphRequest -Method GET `
                                    -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id`&`$top=1"
                                if ($gr['value'] -and $gr['value'].Count -gt 0) {
                                    $groupId = [string]$gr['value'][0]['id']
                                }
                            } catch {}
                        }
                        if (-not $groupId) { GLog "  Group not found: $entry" 'Warning'; continue }

                        # Get all transitive members (users + devices)
                        GLog "  Scanning group members (transitive)..." 'Info'
                        # $select with @odata.type or user-subtype properties causes 400 on
                        # transitiveMembers (directoryObject base type). Use $select=id only;
                        # @odata.type is always returned automatically.
                        $members = Get-GraphAll "https://graph.microsoft.com/v1.0/groups/$groupId/transitiveMembers?`$select=id`&`$top=999"
                        GLog "  $($members.Count) member(s) found" 'Info'

                        foreach ($mem in $members) {
                            if ($GdiStop['Stop']) { break }
                            $odataType = [string]$mem['@odata.type']
                            if ($odataType -eq '#microsoft.graph.device') {
                                $null = $deviceObjIds.Add([string]$mem['id'])
                            } elseif ($odataType -eq '#microsoft.graph.user') {
                                # Resolve user's owned devices
                                $uid = [string]$mem['id']
                                try {
                                    $udevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id`&`$top=999"
                                    foreach ($ud in $udevs) { $null = $deviceObjIds.Add([string]$ud['id']) }
                                } catch {}
                            }
                        }
                    }
                }
            }

                        }  # end else (input-based mode)

            GLog "Total device IDs to process: $($deviceObjIds.Count)" 'Info'
            if ($deviceObjIds.Count -eq 0) {
                .Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
                return
            }

            # ── Process each Entra device ──
            $results = [System.Collections.Generic.List[PSCustomObject]]::new()
            $processed = 0

            foreach ($objId in $deviceObjIds) {
                if ($GdiStop['Stop']) { GLog 'Stop requested  -  halting.' 'Warning'; break }
                if ($entraIdSet.Contains($objId)) { continue }   # deduplicate
                $null = $entraIdSet.Add($objId)

                $processed++
                $GdiQueue.Enqueue(@{ Type='progress'; Current=$processed; Total=$deviceObjIds.Count })

                # Fetch full Entra device record
                $entraRaw = $null
                try {
                    $entraRaw = Invoke-MgGraphRequest -Method GET `
                        -Uri "https://graph.microsoft.com/v1.0/devices/$objId`?`$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime,registrationDateTime"
                } catch {
                    GLog "  Could not fetch Entra device $objId : $($_.Exception.Message)" 'Warning'
                    continue
                }

                $devName   = [string]$entraRaw['displayName']
                $entraDevId = [string]$entraRaw['deviceId']   # azureADDeviceId in Intune
                $entraOS   = Normalize-OS ([string]$entraRaw['operatingSystem'])

                GLog "[$processed] $devName  [$entraOS]" 'Info'

                # ── Platform filter ──
                if ($platFilter.Count -gt 0 -and $entraOS -notin $platFilter) {
                    GLog "  Skipped (platform $entraOS not in filter)" 'Info'
                    continue
                }

                # ── Ownership filter ──
                $rawOwner = [string]$entraRaw['deviceOwnership']
                if ($ownerFilter -ne 'All' -and $rawOwner -ne $ownerFilter) {
                    GLog "  Skipped (ownership $rawOwner not in filter)" 'Info'
                    continue
                }

                # ── Intune lookup ──
                $intuneDev = Get-IntuneByEntraId -EntraId $entraDevId -Cache $intuneCache
                if ($intuneDev) {
                    GLog "  Intune: $([string]$intuneDev['id'])" 'Success'
                } else {
                    GLog "  Intune: No Intune Record" 'Warning'
                }

                # ── Registered owner (user) ──
                # No $select on registeredOwners  -  $select is not supported on navigation
                # endpoints and causes userPrincipalName to be missing from the response.
                $userInfo    = $null
                $ownerUPN    = $null
                try {
                    $ownerResp = Invoke-MgGraphRequest -Method GET `
                        -Uri "https://graph.microsoft.com/v1.0/devices/$objId/registeredOwners?`$top=5"
                    $ownerList = @($ownerResp['value'])
                    $firstUser = $ownerList | Where-Object {
                        $t = [string]$_['@odata.type']
                        $t -eq '#microsoft.graph.user' -or $t -eq 'microsoft.graph.user'
                    } | Select-Object -First 1
                    if ($firstUser) {
                        # userPrincipalName lives in AdditionalProperties for some SDK versions,
                        # or directly on the hashtable when using Invoke-MgGraphRequest
                        $ownerUPN = if ($firstUser.ContainsKey('userPrincipalName')) {
                                        [string]$firstUser['userPrincipalName']
                                    } elseif ($firstUser.ContainsKey('AdditionalProperties')) {
                                        [string]$firstUser['AdditionalProperties']['userPrincipalName']
                                    } else { $null }
                        $uid = [string]$firstUser['id']
                        # If UPN still empty, fetch it from the user endpoint directly
                        if ([string]::IsNullOrWhiteSpace($ownerUPN) -and $uid) {
                            try {
                                $upnResp  = Invoke-MgGraphRequest -Method GET `
                                    -Uri "https://graph.microsoft.com/v1.0/users/$uid`?`$select=userPrincipalName"
                                $ownerUPN = [string]$upnResp['userPrincipalName']
                            } catch {}
                        }
                        GLog "  Owner: $ownerUPN" 'Info'
                        if ($userCache.ContainsKey($uid)) {
                            $userInfo = $userCache[$uid]
                        } else {
                            $userInfo = Get-UserInfo -UserId $uid
                            $userCache[$uid] = $userInfo
                        }
                    } else {
                        GLog "  No registered owner found" 'Info'
                    }
                } catch {
                    GLog "  Owner fetch error: $($_.Exception.Message)" 'Warning'
                }

                # ── Autopilot lookup  -  dictionary built upfront, keyed by entraDevId lowercase ──
                $autopilotInfo = $null
                $entraOSNorm = Normalize-OS ([string]$entraRaw['operatingSystem'])
                if ($entraOSNorm -eq 'Windows' -and $entraDevId) {
                    $apKey = $entraDevId.ToLower()
                    if ($autopilotDict.ContainsKey($apKey)) {
                        $autopilotInfo = $autopilotDict[$apKey]
                        GLog "  Autopilot: Tag=$($autopilotInfo['GroupTag']) | Status=$($autopilotInfo['ProfileStatus'])" 'Info'
                    } else {
                        GLog '  Autopilot: not registered' 'Info'
                    }
                }

                # ── Build row ──
                $row = Build-Row -EntraDev $entraRaw -IntuneDev $intuneDev -UserInfo $userInfo -OwnerUPN $ownerUPN -AutopilotInfo $autopilotInfo
                $results.Add($row)
            }

            GLog "Processed $($results.Count) device(s)." 'Success'
            $GdiQueue.Enqueue(@{ Type='done'; Rows=$results; Error=$null })

        } catch {
            $GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
        }
    })

    $script:GdiHandle = $script:GdiPs.BeginInvoke()

    # Timer to drain GdiQueue on UI thread
    $script:GdiTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $script:GdiTimer.Interval = [TimeSpan]::FromMilliseconds(300)
    $script:GdiTimer.Add_Tick({
        $entry = $null
        while ($script:GdiQueue.TryDequeue([ref]$entry)) {
            switch ($entry['Type']) {
                'log' {
                    $lvl = $entry['Level']
                    $col = switch ($lvl) {
                        'Success' { $DevConfig.LogColorSuccess }
                        'Warning' { $DevConfig.LogColorWarning }
                        'Error'   { $DevConfig.LogColorError   }
                        'Action'  { $DevConfig.LogColorAction  }
                        default   { $DevConfig.LogColorInfo    }
                    }
                    try {
                        $para = [System.Windows.Documents.Paragraph]::new()
                        $run  = [System.Windows.Documents.Run]::new($entry['Line'])
                        $run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
                            [System.Windows.Media.ColorConverter]::ConvertFromString($col))
                        $para.Margin = [System.Windows.Thickness]::new(0)
                        $para.Inlines.Add($run)
                        $script:RtbLog.Document.Blocks.Add($para)
                        $script:RtbLog.ScrollToEnd()
                    } catch {}
                }
                'progress' {
                    $cur = $entry['Current']; $tot = $entry['Total']
                    $script:Window.FindName('PnlGDIProgress').Visibility       = 'Visible'
                    $script:Window.FindName('TxtGDIProgressMsg').Text          = 'Fetching device details...'
                    $script:Window.FindName('TxtGDIProgressDetail').Text       = "Processing device $cur of $tot..."
                    Update-StatusBar -Text "Get Device Info: $cur / $tot devices..."
                }
                'done' {
                    $script:GdiTimer.Stop()
                    try { $script:GdiPs.EndInvoke($script:GdiHandle) } catch {}
                    try { $script:GdiRs.Close() }                      catch {}
                    try { $script:GdiPs.Dispose() }                    catch {}
                    $script:GdiPs = $null; $script:GdiRs = $null
                    $script:Window.FindName('BtnStop').Visibility = 'Collapsed'
                    $script:Window.FindName('BtnStop').IsEnabled  = $true
                    $script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
                    $script:Window.FindName('BtnGDIRun').IsEnabled = $true
                    $script:Window.FindName('BtnGDIRunAll').IsEnabled = $true
                    $script:Window.FindName('PnlGDIProgress').Visibility = 'Collapsed'

                    $errMsg = $entry['Error']
                    if ($errMsg) {
                        Write-VerboseLog "Get Device Info error: $errMsg" -Level Error
                        Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
                        Update-StatusBar -Text "Connected as: $($script:TxtStatusBar.Text -replace 'Get Device Info.*','')"
                        return
                    }

                    $rows     = @($entry['Rows'])

                    if ($rows.Count -eq 0) {
                        Write-VerboseLog 'Get Device Info: no rows  -  check filters or input.' -Level Warning
                        $script:Window.FindName('TxtGDINoResults').Visibility = 'Visible'
                        Show-Notification 'No devices matched the input and filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
                        Update-StatusBar -Text 'Connected'
                        return
                    }

                    # ── Populate DataGrid ──
                    $dg = $script:Window.FindName('DgGDIResults')
                    $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
                    $script:Window.FindName('TxtGDICount').Text            = "$($rows.Count) device(s) found"
                    $script:Window.FindName('PnlGDIResults').Visibility     = 'Visible'
                    $script:Window.FindName('BtnGDICopyAll').IsEnabled      = $true
                    $script:Window.FindName('TxtGDIFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnGDIFilter').IsEnabled  = $true
                    $script:Window.FindName('BtnGDIFilterClear').IsEnabled = $true
                    $script:Window.FindName('BtnGDIExportXlsx').IsEnabled   = $true
                    Write-VerboseLog "Get Device Info: $($rows.Count) device(s) loaded into table." -Level Success
                    Show-Notification "$($rows.Count) device(s) ready. Use Export XLSX to save." -BgColor '#D4EDDA' -FgColor '#155724'
                    Update-StatusBar -Text 'Connected'
                }
            }
        }
    })
    $script:GdiTimer.Start()
})

# ── DgGDIResults: cell selection ─────────────────────────────────────────────
$script:Window.FindName('DgGDIResults').Add_SelectedCellsChanged({
    $dg       = $script:Window.FindName('DgGDIResults')
    $selCells = $dg.SelectedCells.Count
    $script:Window.FindName('BtnGDICopyValue').IsEnabled = ($selCells -ge 1)
    $script:Window.FindName('BtnGDICopyRow').IsEnabled   = ($selCells -gt 0)
})

# ── BtnGDICopyValue ──────────────────────────────────────────────────────────
# ── BtnGDIFilter ──
$script:Window.FindName('BtnGDIFilter').Add_Click({
    $dg      = $script:Window.FindName('DgGDIResults')
    $keyword = $script:Window.FindName('TxtGDIFilter').Text.Trim()
    if ($null -eq $script:GDIAllData) {
        $script:GDIAllData = @($dg.ItemsSource)
    }
    if ($keyword -eq '') {
        $dg.ItemsSource = $script:GDIAllData
        Show-Notification "Filter cleared - $($script:GDIAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    } else {
        $filtered = @($script:GDIAllData | Where-Object {
            $row = $_
            $match = $false
            foreach ($prop in $row.PSObject.Properties) {
                if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
            }
            $match
        })

# ── BtnGDIFilterClear ──
$script:Window.FindName('BtnGDIFilterClear').Add_Click({
    $script:Window.FindName('TxtGDIFilter').Text = ''
    $dg = $script:Window.FindName('DgGDIResults')
    if ($null -ne $script:GDIAllData) {
        $dg.ItemsSource = $script:GDIAllData
        Show-Notification "Filter cleared - $($script:GDIAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
    }
})
        $dg.ItemsSource = $filtered
        Show-Notification "$($filtered.Count) of $($script:GDIAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
    }
    Write-VerboseLog "GDI: Filter applied - keyword='$keyword'" -Level Info
})

$script:Window.FindName('BtnGDICopyValue').Add_Click({
    $dg = $script:Window.FindName('DgGDIResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $vals = [System.Collections.Generic.List[string]]::new()
    foreach ($cellInfo in @($dg.SelectedCells)) {
        $col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
        if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
    }
    [System.Windows.Clipboard]::SetText($vals -join "`r`n")
    Show-Notification '$($vals.Count) value(s) copied to clipboard.' -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog 'GDI: Copied cell value to clipboard.' -Level Success
})

# ── BtnGDICopyRow ────────────────────────────────────────────────────────────
$script:Window.FindName('BtnGDICopyRow').Add_Click({
    $dg = $script:Window.FindName('DgGDIResults')
    if ($dg.SelectedCells.Count -eq 0) { return }
    $seen = [System.Collections.Generic.HashSet[object]]::new()
    $rowObjs = [System.Collections.Generic.List[object]]::new()
    foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rowObjs.Add($cell.Item) } }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "	")
    foreach ($row in $rowObjs) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "	")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "
")
    Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "GDI: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
    # Highlight full rows
    $dg.UnselectAllCells()
    foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
})

# ── BtnGDICopyAll ────────────────────────────────────────────────────────────
$script:Window.FindName('BtnGDICopyAll').Add_Click({
    $dg       = $script:Window.FindName('DgGDIResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) { return }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $txtLines = [System.Collections.Generic.List[string]]::new()
    $txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "	")
    foreach ($row in $allItems) {
        $vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
        $txtLines.Add($vals -join "	")
    }
    [System.Windows.Clipboard]::SetText($txtLines -join "
")
    Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
    Write-VerboseLog "GDI: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})

# ── BtnGDIExportXlsx ─────────────────────────────────────────────────────────
$script:Window.FindName('BtnGDIExportXlsx').Add_Click({
    $dg       = $script:Window.FindName('DgGDIResults')
    $allItems = @($dg.ItemsSource)
    if ($allItems.Count -eq 0) {
        Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
        return
    }
    $colDefs = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($col in @($dg.Columns)) {
        $bc     = $col -as [System.Windows.Controls.DataGridBoundColumn]
        $bcPath = ''
        if ($bc) { $bcPath = $bc.Binding.Path.Path }
        $colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
    }
    $dlg = [Microsoft.Win32.SaveFileDialog]::new()
    $dlg.Title            = 'Export Get Device Info Results'
    $dlg.Filter           = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
    $dlg.FileName         = "DeviceInfo_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
    $dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
    if ($dlg.ShowDialog() -ne $true) { return }
    $outPath = $dlg.FileName
    try {
        $folder = Split-Path $outPath -Parent
        if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
        $exportRows = $allItems | ForEach-Object {
            $rowObj = $_; $ht = [ordered]@{}
            foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
            [PSCustomObject]$ht
        }
        if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction SilentlyContinue
            $exportRows | Export-Excel -Path $outPath -WorksheetName 'DeviceInfo' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
            Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
        } else {
            $exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
            Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
        }
        Write-VerboseLog "GDI: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
    } catch {
        Write-VerboseLog "GDI Export failed: $($_.Exception.Message)" -Level Error
        Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
    }
})

#endregion


# ============================================================
#region LAUNCH
# ============================================================

Write-VerboseLog "========================================" -Level Info
Write-VerboseLog "$($DevConfig.WindowTitle)" -Level Info
Write-VerboseLog "Author: Satish Singhi" -Level Info
Write-VerboseLog "Session: $script:SessionStamp" -Level Info
if ($script:LogFile) { Write-VerboseLog "Log file: $script:LogFile" -Level Info }
Write-VerboseLog "========================================" -Level Info
Write-VerboseLog "Click 'Connect' to authenticate to Microsoft Graph." -Level Info

$script:Window.Add_Closed({
    Stop-PimAutoRefresh
    try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
    Write-VerboseLog "Session ended. Disconnected." -Level Info
})

$null = $script:Window.ShowDialog()

#endregion
#========== END_GUI_TOOL_CONTENT ===========
__PS7_PAYLOAD_END__#>
