Private/NPSInteractive.ps1

<#
.SYNOPSIS
    Shared interactive (Read-Host-driven) wizards for NPSManager - the "add a rule" flow, group
    condition collection, VSA resolution, and RADIUS client picking. Dot-sourced by BOTH
    Add-NPSNetworkPolicy.ps1 (standalone) and NPS-Manager.ps1 (the dashboard's Option 5), so a fix
    or UX change here lands in both places at once instead of needing to be copied twice - exactly
    the kind of duplication that already caused a real bug once in this repo's Master Orchestrator
    (see its own "Build-AndStageClient" refactor history).
 
.DESCRIPTION
    Requires Modules\NPSCore.ps1 to already be dot-sourced (uses Get-ADGroupSID,
    Resolve-NestedADGroups, Get-NPSExistingSequences, Add-NPSPolicySet, Add-NPSSingleRule,
    Get-NPSClients, Test-ADConnectivityViaExplicitDC, Set-NPSShimADServer, Set-NPSShimADUsername,
    Test-NPSServerRegistered, Test-NPSADAuthError).
#>


function Write-NPSHeader {
    <#
    .SYNOPSIS
        Draws the "====" banner used at the top of every screen/menu in this tool - and, per the maintainer's
        request, clears the screen first so scrollback from the PREVIOUS screen doesn't pile up
        between menu transitions. Called at the start of essentially every screen redraw across this
        whole app, so this one change gives a "clear between screens" effect everywhere for free.
 
    .DESCRIPTION
        Clear-Host wrapped in try/catch, not called bare - it can throw in a handful of non-console
        hosts (e.g. some CI/automation runners with no real console buffer). This app is always run
        in a real interactive elevated console window by design, so that's not expected to actually
        happen here, but silently no-op'ing instead of crashing the entire dashboard over a cosmetic
        failure is the correct trade-off regardless.
    #>

    param([string]$Title)
    try { Clear-Host } catch {}
    Write-Host ""
    Write-Host "======================================================" -ForegroundColor Cyan
    Write-Host " $Title" -ForegroundColor Cyan
    Write-Host "======================================================" -ForegroundColor Cyan
    Write-Host ""
}

# ---------------------------------------------------------------------------
function Write-NPSADErrorDiagnostics {
    <#
    .SYNOPSIS
        Shared error-detail dump for AD connectivity/registration failures - CategoryInfo/
        FullyQualifiedErrorId/InnerException chain. Promoted to a top-level function (was originally
        nested inside Invoke-NPSADFallbackPrompt) so Connect-NPSADWithRetry can use it too without
        duplicating it - confirmed live (the maintainer) that this level of detail matters for telling apart
        genuinely different failures; a run of "fixes" to Test-ADConnectivityViaExplicitDC once kept
        not changing an error that, in hindsight, may have actually been coming from the OTHER call
        (Test-NPSServerRegistered) the whole time, back when both calls shared one try/catch with no
        way to tell which one actually failed.
    #>

    param([System.Management.Automation.ErrorRecord]$ErrorRecord)
    Write-Host " Exception type: $($ErrorRecord.Exception.GetType().FullName)" -ForegroundColor DarkGray
    Write-Host " CategoryInfo: $($ErrorRecord.CategoryInfo)" -ForegroundColor DarkGray
    Write-Host " FullyQualifiedErrorId: $($ErrorRecord.FullyQualifiedErrorId)" -ForegroundColor DarkGray
    $inner = $ErrorRecord.Exception.InnerException
    $depth = 1
    while ($inner) {
        Write-Host " InnerException($depth): $($inner.GetType().FullName): $($inner.Message)" -ForegroundColor DarkGray
        $inner = $inner.InnerException
        $depth++
    }
}

# ---------------------------------------------------------------------------
function Connect-NPSADWithRetry {
    <#
    .SYNOPSIS
        Shared "prove connectivity + credentials work against a specific DC" retry loop -
        Test-ADConnectivityViaExplicitDC, up to $MaxAttempts tries, offering Get-Credential between
        failures. Factored out of Invoke-NPSADFallbackPrompt so Invoke-NPSRequiredCredentialsPrompt
        (the PROACTIVE version, for a client flagged as always needing explicit credentials - see
        $NPSRequiresExplicitADCredentials) doesn't duplicate it - same "one copy, not two that can
        drift apart" reasoning this codebase already applies elsewhere (e.g. Complete-ClientStaging's
        shared Kickstart-splicing logic).
 
    .DESCRIPTION
        Credentials are session-only here too - NEVER written to disk, same as every other credential
        prompt in this codebase.
 
    .PARAMETER Username
        Last-known-good username for this site (see $NPSADUsername / Set-NPSShimADUsername), if any -
        pre-fills the Get-Credential prompt so the tech doesn't have to retype it every launch. Never
        a password - just the username. Blank is fine (Get-Credential just starts empty, as before).
 
    .OUTPUTS
        $null if every attempt failed or the tech declined to retry. Otherwise a pscustomobject with
        Credential (the PSCredential that actually worked, or $null if the default identity worked
        without one).
    #>

    param(
        [Parameter(Mandatory)][string]$DCServer,
        [string]$Username,
        [int]$MaxAttempts = 3
    )

    $credential = $null
    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            $asWhom = if ($credential) { " as '$($credential.UserName)'" } else { "" }
            Write-Host "Connecting directly to '$DCServer'$asWhom..." -ForegroundColor Cyan
            Test-ADConnectivityViaExplicitDC -DCServer $DCServer -Credential $credential
            Write-Host "Connected successfully via '$DCServer'." -ForegroundColor Green
            return [pscustomobject]@{ Credential = $credential }
        } catch {
            Write-Host "Connection to '$DCServer' failed - $($_.Exception.Message)" -ForegroundColor Red
            Write-NPSADErrorDiagnostics -ErrorRecord $_
            if ($attempt -ge $MaxAttempts) {
                Write-Host "Giving up after $MaxAttempts attempt(s)." -ForegroundColor Yellow
                return $null
            }
            $tryCreds = Read-Host "Try again with different credentials? (Y/N)"
            if ($tryCreds -notmatch '^[Yy]') { return $null }
            # Pre-fill with the last-known-good username for this site, if any - the tech can still
            # overwrite it in the Get-Credential dialog; this just saves retyping it every launch.
            if ($Username) {
                $credential = Get-Credential -UserName $Username -Message "Credentials with AD access on '$DCServer'"
            } else {
                $credential = Get-Credential -Message "Credentials with AD access on '$DCServer' (e.g. DOMAIN\username)"
            }
            if (-not $credential) {
                Write-Host "No credentials entered - skipped." -ForegroundColor Gray
                return $null
            }
        }
    }
    return $null
}

# ---------------------------------------------------------------------------
function Invoke-NPSADFallbackPrompt {
    <#
    .SYNOPSIS
        Offers the "connect to a specific DC directly" fallback after a normal AD check has failed -
        confirmed live (the maintainer) as the actual fix at a site where normal AD/ADWS auto-discovery is
        broken but a direct connection to a known-good DC works. On success, offers to persist the
        DC hostname into the staged shim (-ShimPath) so future launches use it automatically instead
        of hitting the same failure/prompt again.
 
        REACTIVE - only offered after a normal check has already failed. For a client already known
        to always need this (see $NPSRequiresExplicitADCredentials), see the PROACTIVE counterpart
        Invoke-NPSRequiredCredentialsPrompt instead, which skips straight to this without waiting for
        a failure first.
 
    .DESCRIPTION
        A direct connection can still fail on CREDENTIALS alone even once the right DC is named -
        confirmed live ("The server has rejected the client credentials") when the account this
        session is running as isn't valid/trusted against that specific DC. Connect-NPSADWithRetry
        offers Get-Credential to supply different ones across up to 3 attempts - session-only; NEVER
        written to disk anywhere in this codebase (unlike the DC hostname itself, which the "save for
        next time" step below does persist into the shim - a hostname and a password are not the same
        kind of secret).
 
        A working credential IS returned to the caller (see .OUTPUTS) - confirmed live (the maintainer) that
        the SAME credential is needed again for other AD-touching operations in the same session
        (the rule builder's wildcard group search, nested-group resolution), not just this one
        registration check - re-prompting separately for every single AD call would be needlessly
        painful. The caller is expected to cache it for the rest of the session (see
        NPS-Manager.ps1's $script:ADCredential) and pass it to those other calls too - still never
        written to disk, same as here.
 
    .PARAMETER ShimPath
        Path to the staged NPS-Manager_Shim.ps1 copy that launched this session (see
        NPS-Manager.ps1's -ShimPath param), if any - only known when running via the staged shim,
        not a standalone/manual copy of the dashboard. Passed to Set-NPSShimADServer for the "save
        for next time" step; when blank, that step is offered as a manual "-ADServer" note instead.
 
    .PARAMETER ADUsername
        Last-known-good username for this site (see $NPSADUsername / Set-NPSShimADUsername), if any -
        passed straight through to Connect-NPSADWithRetry to pre-fill its Get-Credential prompt.
 
    .OUTPUTS
        $null if skipped or every connection attempt failed. Otherwise a pscustomobject with
        DCServer, Registered (the actual Test-NPSServerRegistered result via that DC), Credential
        (the PSCredential that actually worked, or $null if the default identity worked without one),
        and Username (that credential's UserName, or $null) - the caller should apply DCServer,
        Credential, AND Username as this session's AD context going forward (see NPS-Manager.ps1's
        Invoke-InstallAuthorizeNPS), not just for this one check.
    #>

    param([string]$ShimPath, [string]$ADUsername)

    Write-Host ""
    Write-Host "The normal AD check failed - this can happen at sites with flaky AD/ADWS auto-discovery." -ForegroundColor Yellow
    $dcServer = Read-Host "Try connecting to a specific Domain Controller directly? Enter its FQDN (e.g. dc1.contoso.local), or blank to skip"
    if ([string]::IsNullOrWhiteSpace($dcServer)) {
        Write-Host "Skipped." -ForegroundColor Gray
        return $null
    }
    if ($dcServer -notmatch '\.') {
        # Confirmed live (the maintainer): a bare short hostname (no domain suffix) hit a DIFFERENT failure
        # than the exact same DC's FQDN did with the exact same credential - Kerberos SPN resolution
        # is picky about short-name vs FQDN in a way that can silently change which auth path gets
        # used. Not blocking the short name (it might still work at some sites), just flagging it.
        Write-Host "'$dcServer' looks like a short hostname, not a full FQDN - if this fails, try the" -ForegroundColor Yellow
        Write-Host "full FQDN instead (e.g. '$dcServer.yourdomain.tld') - Kerberos can behave differently" -ForegroundColor Yellow
        Write-Host "for a short name vs. the FQDN of the exact same DC." -ForegroundColor Yellow
    }

    $connectResult = Connect-NPSADWithRetry -DCServer $dcServer -Username $ADUsername
    if (-not $connectResult) { return $null }
    $credential = $connectResult.Credential

    # --- The actual registration check, SEPARATELY - a failure here is never confused with a
    # connectivity/credential failure above, since it has its own try/catch. ---
    $registered = $null
    try {
        Write-Host "Checking AD registration via '$dcServer'..." -ForegroundColor Cyan
        $registered = Test-NPSServerRegistered -Server $dcServer -Credential $credential -Force
    } catch {
        # Confirmed live (the maintainer): the connectivity check above can succeed under the DEFAULT identity
        # (no -Credential) while THIS check still fails on auth - Test-NPSServerRegistered runs its
        # own no-credential path in a background job (see its own TimeoutSeconds notes), and that job
        # boundary doesn't always carry the same Kerberos context the foreground connectivity check
        # just proved works. Without this branch, a tech in exactly that situation never sees a
        # credential prompt at all - "connected successfully" already printed, so there was nothing
        # left in the old flow that would ever call Get-Credential. Only offered when no credential
        # was already tried (retrying with the SAME credential that just failed would just fail the
        # same way) and the failure actually looks auth-related (Test-NPSADAuthError) - a genuinely
        # different failure (e.g. the group not existing) isn't something a credential retry can fix.
        if (-not $credential -and (Test-NPSADAuthError -ErrorRecord $_)) {
            Write-Host "Connected to '$dcServer' fine, but the registration check itself hit an auth error - $($_.Exception.Message)" -ForegroundColor Yellow
            Write-NPSADErrorDiagnostics -ErrorRecord $_
            $retryWithCreds = Read-Host "Try the registration check again with explicit credentials? (Y/N)"
            if ($retryWithCreds -match '^[Yy]') {
                $retryCred = if ($ADUsername) { Get-Credential -UserName $ADUsername -Message "Credentials with AD access on '$dcServer'" } else { Get-Credential -Message "Credentials with AD access on '$dcServer' (e.g. DOMAIN\username)" }
                if ($retryCred) {
                    try {
                        $registered = Test-NPSServerRegistered -Server $dcServer -Credential $retryCred -Force
                        $credential = $retryCred
                        Write-Host "Registration check succeeded with explicit credentials." -ForegroundColor Green
                    } catch {
                        Write-Host "Still failed with explicit credentials - $($_.Exception.Message)" -ForegroundColor Red
                        Write-NPSADErrorDiagnostics -ErrorRecord $_
                        Write-Host "Continuing anyway - '$dcServer' is confirmed reachable; just couldn't verify registration status." -ForegroundColor Yellow
                    }
                } else {
                    Write-Host "No credentials entered - skipped." -ForegroundColor Gray
                }
            }
        } else {
            Write-Host "Connected to '$dcServer' successfully, but the registration check itself failed - $($_.Exception.Message)" -ForegroundColor Red
            Write-NPSADErrorDiagnostics -ErrorRecord $_
            Write-Host "Continuing anyway - '$dcServer' is confirmed reachable with these credentials; just couldn't verify" -ForegroundColor Yellow
            Write-Host "registration status this specific way. Saving below still applies this DC to the rest of the session." -ForegroundColor Yellow
        }
    }

    $save = Read-Host "Save '$dcServer' so future launches use it automatically, no re-prompting? (Y/N)"
    if ($save -match '^[Yy]') {
        if ($ShimPath -and (Test-Path $ShimPath)) {
            try {
                Set-NPSShimADServer -ShimPath $ShimPath -DCServer $dcServer
                Write-Host "Saved '$dcServer' into the shim at $ShimPath - future runs will use it automatically." -ForegroundColor Green
            } catch {
                Write-Host "Could not save into the shim - $($_.Exception.Message)" -ForegroundColor Red
                Write-Host "You can still pass it manually next time: -ADServer '$dcServer'" -ForegroundColor Yellow
            }
            # Username too, if an explicit credential was actually entered (not the default identity)
            # - NEVER the password, just the username, so future Get-Credential prompts are pre-filled
            # instead of starting blank every launch (confirmed live: "I also needed to use a
            # different admin account").
            if ($credential -and $credential.UserName) {
                try {
                    Set-NPSShimADUsername -ShimPath $ShimPath -Username $credential.UserName
                    Write-Host "Saved username '$($credential.UserName)' into the shim too." -ForegroundColor Green
                } catch {
                    Write-Host "Could not save the username into the shim - $($_.Exception.Message)" -ForegroundColor Red
                }
            }
            # CORRECTED, 2026-08-18: saving the DC hostname alone does NOT deliver on "future launches
            # use it automatically" - confirmed live (the maintainer): the save reported success, but a fresh
            # relaunch went right back to the normal (REACTIVE) flow, hit the same auto-discovery
            # failure again, and re-prompted for the DC from scratch anyway (this function has no
            # pre-fill from an already-known $ADServer at all). Without also flipping
            # $NPSRequiresExplicitADCredentials, NOTHING at startup ever tells the dashboard to call
            # the PROACTIVE Invoke-NPSRequiredCredentialsPrompt instead - which IS the one that reuses
            # a saved DC without re-asking (see its own notes) - so the saved value just sat in the
            # shim, unused, until a tech separately found and flipped the Troubleshooting toggle by
            # hand. Setting it here is what the "Y" answer to THIS prompt already promises.
            try {
                Set-NPSShimRequiresExplicitADCredentials -ShimPath $ShimPath -RequiresExplicitCredentials $true
                Write-Host "Future launches will also skip straight to asking for the DC/credentials up front." -ForegroundColor Green
            } catch {
                Write-Host "Could not enable proactive AD credential prompting - $($_.Exception.Message)" -ForegroundColor Red
                Write-Host "The DC is still saved, but future launches may hit the same auto-discovery failure" -ForegroundColor Yellow
                Write-Host "before falling back to it - toggle it on manually via Troubleshooting Tools instead." -ForegroundColor Yellow
            }
        } else {
            Write-Host "Not running from a staged shim (or its path wasn't provided) - can't auto-save." -ForegroundColor Yellow
            Write-Host "Pass it manually next time: -ADServer '$dcServer'" -ForegroundColor Yellow
        }
    }

    return [pscustomobject]@{ DCServer = $dcServer; Registered = $registered; Credential = $credential; Username = $(if ($credential) { $credential.UserName } else { $null }) }
}

# ---------------------------------------------------------------------------
function Invoke-NPSRequiredCredentialsPrompt {
    <#
    .SYNOPSIS
        PROACTIVE counterpart to Invoke-NPSADFallbackPrompt, for a client flagged (via
        $NPSRequiresExplicitADCredentials in its staged shim - see
        Set-NPSShimRequiresExplicitADCredentials and NPSTroubleshooting.ps1's toggle) as always
        needing an explicit DC + credentials, rather than reactively offering this only after a
        normal auto-discovery check has already failed. Called once at dashboard startup (see
        NPS-Manager.ps1) when that flag is set, BEFORE the first status check even runs, so the
        very first screen the tech sees already reflects reality instead of showing "Unknown" until
        they happen to visit Option 1 or Troubleshooting's "Test AD connectivity".
 
    .DESCRIPTION
        If $ADServer is already known (a prior session already saved a DC hostname into the shim via
        Set-NPSShimADServer), it's reused as-is and only credentials are asked for - the DC hostname
        itself is never re-prompted for once saved. Otherwise this asks for the DC FQDN too, same as
        Invoke-NPSADFallbackPrompt does reactively. Credentials themselves are NEVER persisted - this
        flag only controls whether the tech is asked proactively vs. reactively, not whether the
        answer is remembered (confirmed live: "we need to ask for it every time it's launched if we
        don't save credentials").
 
    .PARAMETER ADUsername
        Last-known-good username for this site (see $NPSADUsername / Set-NPSShimADUsername), if any -
        passed straight through to Connect-NPSADWithRetry to pre-fill its Get-Credential prompt.
 
    .OUTPUTS
        Same shape as Invoke-NPSADFallbackPrompt - $null if skipped/failed, otherwise a
        pscustomobject with DCServer, Registered, Credential, and Username.
    #>

    param([string]$ADServer, [string]$ShimPath, [string]$ADUsername)

    Write-Host ""
    Write-Host "This client is flagged as always needing explicit AD credentials (auto-discovery isn't" -ForegroundColor Yellow
    Write-Host "reliable here) - see Troubleshooting Tools to turn this off if that's changed." -ForegroundColor Yellow

    $dcServer = $ADServer
    if ([string]::IsNullOrWhiteSpace($dcServer)) {
        $dcServer = Read-Host "Domain Controller FQDN (e.g. dc1.contoso.local), or blank to skip"
        if ([string]::IsNullOrWhiteSpace($dcServer)) {
            Write-Host "Skipped - AD-dependent features will not work this session." -ForegroundColor Gray
            return $null
        }
    } else {
        Write-Host "Using previously-saved Domain Controller: $dcServer" -ForegroundColor Cyan
    }

    $connectResult = Connect-NPSADWithRetry -DCServer $dcServer -Username $ADUsername
    if (-not $connectResult) { return $null }
    $credential = $connectResult.Credential

    $registered = $null
    try {
        $registered = Test-NPSServerRegistered -Server $dcServer -Credential $credential -Force
    } catch {
        # Same job-boundary auth quirk as Invoke-NPSADFallbackPrompt hits (see its own notes on this
        # branch) - the default identity can pass the connectivity check above yet still fail THIS
        # check's own no-credential background-job path. Only offered when no credential was already
        # tried and the failure looks auth-related, same guard reasoning as the other function.
        if (-not $credential -and (Test-NPSADAuthError -ErrorRecord $_)) {
            Write-Host "Connected to '$dcServer' fine, but the registration check itself hit an auth error - $($_.Exception.Message)" -ForegroundColor Yellow
            Write-NPSADErrorDiagnostics -ErrorRecord $_
            $retryWithCreds = Read-Host "Try the registration check again with explicit credentials? (Y/N)"
            if ($retryWithCreds -match '^[Yy]') {
                $retryCred = if ($ADUsername) { Get-Credential -UserName $ADUsername -Message "Credentials with AD access on '$dcServer'" } else { Get-Credential -Message "Credentials with AD access on '$dcServer' (e.g. DOMAIN\username)" }
                if ($retryCred) {
                    try {
                        $registered = Test-NPSServerRegistered -Server $dcServer -Credential $retryCred -Force
                        $credential = $retryCred
                        Write-Host "Registration check succeeded with explicit credentials." -ForegroundColor Green
                    } catch {
                        Write-Host "Still failed with explicit credentials - $($_.Exception.Message)" -ForegroundColor Red
                        Write-NPSADErrorDiagnostics -ErrorRecord $_
                    }
                } else {
                    Write-Host "No credentials entered - skipped." -ForegroundColor Gray
                }
            }
        } else {
            Write-Host "Connected to '$dcServer' successfully, but the registration check itself failed - $($_.Exception.Message)" -ForegroundColor Red
            Write-NPSADErrorDiagnostics -ErrorRecord $_
        }
    }

    # Offer to save whatever actually changed - the DC only if it wasn't already known, the username
    # only if a credential was actually entered AND it differs from what's already saved (skips a
    # needless re-prompt/rewrite on a launch where nothing new happened).
    $dcChanged = ($ADServer -ne $dcServer)
    $usernameChanged = ($credential -and $credential.UserName -and $credential.UserName -ne $ADUsername)
    if ($dcChanged -or $usernameChanged) {
        $save = Read-Host "Save these to the shim so future launches use them automatically? (Y/N)"
        if ($save -match '^[Yy]') {
            if ($ShimPath -and (Test-Path $ShimPath)) {
                if ($dcChanged) {
                    try {
                        Set-NPSShimADServer -ShimPath $ShimPath -DCServer $dcServer
                        Write-Host "Saved '$dcServer' into the shim at $ShimPath." -ForegroundColor Green
                    } catch {
                        Write-Host "Could not save the DC into the shim - $($_.Exception.Message)" -ForegroundColor Red
                    }
                }
                if ($usernameChanged) {
                    try {
                        Set-NPSShimADUsername -ShimPath $ShimPath -Username $credential.UserName
                        Write-Host "Saved username '$($credential.UserName)' into the shim." -ForegroundColor Green
                    } catch {
                        Write-Host "Could not save the username into the shim - $($_.Exception.Message)" -ForegroundColor Red
                    }
                }
            } else {
                Write-Host "Not running from a staged shim - can't auto-save." -ForegroundColor Yellow
            }
        }
    }

    return [pscustomobject]@{ DCServer = $dcServer; Registered = $registered; Credential = $credential; Username = $(if ($credential) { $credential.UserName } else { $null }) }
}

# ---------------------------------------------------------------------------
function Read-ANDConditionGroups {
    <#
    .SYNOPSIS
        Collects AND/OR group-membership conditions for one "side" (IPSec, SSLVPN, or a plain
        single rule's "Access" side) - each condition entered is AND'd with the others; multiple
        comma-separated search terms within ONE condition are OR'd.
 
    .DESCRIPTION
        Each comma-separated entry is a WILDCARD SEARCH TERM, not a required exact group name -
        resolved via Find-NPSADGroupsByWildcard ("contains" match). Exactly one AD hit auto-selects;
        multiple hits show a numbered pick-list so the tech doesn't need to already know the group's
        full exact name. An already-SID-shaped entry is still passed straight through unchanged
        (same as Get-ADGroupSID always did), since a SID isn't something to wildcard-search for.
 
        ZERO hits offers Create/Retype/Skip instead of just failing the term - Create prompts for a
        target OU via Select-NPSADOrganizationalUnit's text browser, then provisions the group
        (New-NPSADGroup) and resolves it exactly like a real search hit would. Retype/Skip both fall
        through to the existing "re-enter the whole condition" behavior, which already handles "fix a
        typo" without needing a separate single-term retry path.
    #>

    param(
        [Parameter(Mandatory)][string]$SideLabel,
        [string]$ADServer,
        # Optional - see Invoke-NPSADFallbackPrompt's -Credential notes. Confirmed live (the maintainer)
        # this is needed alongside -ADServer at some sites, for the exact wildcard-search call this
        # function makes.
        [System.Management.Automation.PSCredential]$ADCredential,
        # Passed straight through to Invoke-NPSADFallbackPrompt if a group search fails on what looks
        # like a credentials problem (see Test-NPSADAuthError) - lets the "save for next time" step
        # work from here too, not just the top-level status check.
        [string]$ShimPath
    )

    Write-Host "Building the $SideLabel required-group condition:" -ForegroundColor Cyan
    Write-Host ""
    Write-Host " - Each condition you add is AND'd with the others (a user must satisfy ALL of them)." -ForegroundColor Cyan
    Write-Host " - Within ONE condition, list multiple search terms separated by commas - those are" -ForegroundColor Cyan
    Write-Host " OR'd (a user needs to match ANY ONE of them to satisfy that condition)." -ForegroundColor Cyan
    Write-Host " - Terms don't need to be exact - a partial name searches AD and you pick from matches." -ForegroundColor Cyan
    Write-Host ""

    if (-not (Test-RSATAvailable)) {
        Write-Host "The ActiveDirectory module (RSAT) isn't available - needed to search AD for group names." -ForegroundColor Yellow
        $installRSAT = Read-Host "Install it now? (Y/N)"
        if ($installRSAT -match '^[Yy]') {
            Install-RSATActiveDirectoryModule | Out-Null
        }
    }

    $sidSets = [System.Collections.Generic.List[string[]]]::new()
    $names   = [System.Collections.Generic.List[string]]::new()
    $conditionNum = 1
    while ($true) {
        $entry = Read-Host "$SideLabel AND-condition #${conditionNum}: group search term(s), comma-separated for OR (blank to finish, need at least 1)"
        if ([string]::IsNullOrWhiteSpace($entry)) {
            if ($sidSets.Count -eq 0) { Write-Host "At least one condition is required." -ForegroundColor Yellow; continue }
            break
        }
        $entryTerms = $entry.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
        $sids = [System.Collections.Generic.List[string]]::new()
        $ok = $true
        foreach ($term in $entryTerms) {
            $picked = $null
            if ($term -match '^S-1-5-\d+(-\d+)+$') {
                $sids.Add($term)
                $names.Add($term)
                Write-Host " Using SID directly: $term" -ForegroundColor Green
                continue
            }

            try {
                # Proactively wrapped in @() - Find-NPSADGroupsByWildcard already wraps its OWN return
                # value the same way, but PowerShell's pipeline/array-unwrapping can still collapse a
                # length-1 result back to a bare scalar object by the time it's captured here (a
                # well-documented gotcha, confirmed live: this exact assignment broke specifically when
                # the search matched only one group, leaving $found.Count reading blank/failing on the
                # numeric comparisons below instead of behaving like a real 1-element array).
                $found = @(Find-NPSADGroupsByWildcard -SearchTerm $term -Server $ADServer -Credential $ADCredential)
            } catch {
                Write-Host " Could not search AD for '$term' - $($_.Exception.Message)" -ForegroundColor Red
                # A site where the default identity partially works (e.g. the earlier status check
                # succeeded) can still fail HERE on a real query - confirmed live (the maintainer). Offer the
                # same fallback the top-level check would, right here, rather than just warning and
                # leaving the tech stuck mid-wizard.
                if (Test-NPSADAuthError -ErrorRecord $_) {
                    Write-Host " This looks like a credentials problem, not a bad search term." -ForegroundColor Yellow
                    $fallback = Invoke-NPSADFallbackPrompt -ShimPath $ShimPath
                    if ($fallback -and $fallback.Credential) {
                        # Applies for the REST of this session too, same as the top-level fallback -
                        # every AD call after this one (including the rest of THIS loop) picks it up.
                        $script:ADServer = $fallback.DCServer
                        $script:ADCredential = $fallback.Credential
                        $ADServer = $fallback.DCServer
                        $ADCredential = $fallback.Credential
                        try {
                            $found = @(Find-NPSADGroupsByWildcard -SearchTerm $term -Server $ADServer -Credential $ADCredential)
                        } catch {
                            Write-Host " Still could not search AD for '$term' - $($_.Exception.Message)" -ForegroundColor Red
                            $ok = $false
                            continue
                        }
                    } else {
                        $ok = $false
                        continue
                    }
                } else {
                    $ok = $false
                    continue
                }
            }

            # Computed once into a real [int] rather than repeatedly re-reading $found.Count below -
            # belt-and-suspenders against the same class of surprise recurring on a later access.
            $foundCount = $found.Count

            if ($foundCount -eq 0) {
                Write-Host " No AD groups found matching '*$term*'." -ForegroundColor Red
                Write-Host " C. Create a new AD group named '$term'"
                Write-Host " R. Retype this term (maybe a typo)"
                Write-Host " S. Skip this term"
                Write-Host " Tip: 'C' here creates just this one bare group. If what's actually missing is" -ForegroundColor DarkGray
                Write-Host " a whole client's VPN group/OU structure, AD-Manager (PushableTools\ADManager\," -ForegroundColor DarkGray
                Write-Host " menu 2) is the fuller, OU-browsable tool for that." -ForegroundColor DarkGray
                $zeroChoice = Read-Host " Select"
                if ($zeroChoice -match '^[Cc]') {
                    Write-Host " Pick the OU to create '$term' in:" -ForegroundColor Cyan
                    $targetOU = Select-NPSADOrganizationalUnit -Server $ADServer -Credential $ADCredential
                    if (-not $targetOU) {
                        Write-Host " Cancelled - no OU selected." -ForegroundColor Yellow
                        $ok = $false
                        continue
                    }
                    try {
                        $picked = New-NPSADGroup -Name $term -Path $targetOU -Server $ADServer -Credential $ADCredential
                        Write-Host " Created AD group '$term' in $targetOU" -ForegroundColor Green
                        # Falls through to the shared $sids/$names.Add(...) tail below, same as a
                        # normal search hit - a freshly-created group is resolved exactly the same way.
                    } catch {
                        Write-Host " Could not create group '$term' - $($_.Exception.Message)" -ForegroundColor Red
                        $ok = $false
                        continue
                    }
                } else {
                    # 'R' (retype) and 'S' (skip) both land here - either way, forcing the WHOLE
                    # condition line to be re-entered is the existing, established behavior for any
                    # unresolved term in this loop (see the "Skipped '$term'" case below), and it
                    # naturally handles "fix a typo" too since the tech just retypes the corrected
                    # term next time instead of this function needing a separate single-term retry path.
                    $ok = $false
                    continue
                }
            }

            if (-not $picked) {
                if ($foundCount -eq 1) {
                    $picked = $found[0]
                    Write-Host " '$term' matched exactly one group: $($picked.Name)" -ForegroundColor Green
                } else {
                    Write-Host " '$term' matched $foundCount groups:" -ForegroundColor Cyan
                    for ($i = 0; $i -lt $foundCount; $i++) {
                        Write-Host (" {0}. {1}" -f ($i + 1), $found[$i].Name)
                    }
                    $sel = Read-Host " Select a number (blank to skip this term)"
                    $idx = ($sel -as [int]) - 1
                    if ($idx -ge 0 -and $idx -lt $foundCount) {
                        $picked = $found[$idx]
                    } else {
                        Write-Host " Skipped '$term' - no selection made." -ForegroundColor Yellow
                        $ok = $false
                        continue
                    }
                }
            }

            $sids.Add($picked.SID.Value)
            $names.Add($picked.Name)
            Write-Host " Resolved '$term' -> '$($picked.Name)' -> $($picked.SID.Value)" -ForegroundColor Green
        }
        if (-not $ok) {
            Write-Host "Condition #${conditionNum} had unresolved/skipped term(s) - re-enter it." -ForegroundColor Yellow
            continue
        }
        $sidSets.Add($sids.ToArray())
        $conditionNum++
    }
    return [pscustomobject]@{ SidSets = $sidSets.ToArray(); RequiredNames = @($names | Select-Object -Unique) }
}

# ---------------------------------------------------------------------------
function Get-VsaNamesForSide {
    <#
    .SYNOPSIS
        Collects Fortinet-Group-Name VSA candidates for one side - offers to auto-resolve nested AD
        membership of the required group(s) (PLUS the required group(s) themselves - a client's
        FortiGate group can be scoped directly to the same group used for the condition match just
        as easily as to something it's nested under), or falls back to manual entry.
 
    .DESCRIPTION
        Nested membership resolves into ONE combined candidate list (required group(s) + everything
        found nested under them, deduped) shown ONCE as a numbered pick-list - not a separate (Y/N)
        prompt per group like this used to be. A required group with a dozen nested groups used to
        mean a dozen-plus individual confirmations here; now it's one list + one selection line
        (numbers comma-separated, 'A' for all, or blank for none) - same numbered-pick-list pattern
        already used elsewhere in this wizard (Read-ANDConditionGroups' AD search disambiguation,
        Select-NPSClientIP), not a new UX invented just for this.
    #>

    param(
        [Parameter(Mandatory)][string]$SideLabel,
        [string[]]$RequiredNames,
        [string]$ADServer,
        [System.Management.Automation.PSCredential]$ADCredential,
        # Passed straight through to Invoke-NPSADFallbackPrompt if a nested-membership resolve fails
        # on what looks like a credentials problem (see Test-NPSADAuthError).
        [string]$ShimPath
    )

    $vsaNames = [System.Collections.Generic.List[string]]::new()
    if ($RequiredNames.Count -gt 0) {
        if (-not (Test-RSATAvailable)) {
            Write-Host "The ActiveDirectory module (RSAT) isn't available - needed to auto-resolve nested AD group membership." -ForegroundColor Yellow
            $installRSAT = Read-Host "Install it now? (Y/N, N to skip straight to manual VSA entry)"
            if ($installRSAT -match '^[Yy]') {
                Install-RSATActiveDirectoryModule | Out-Null
            }
        }
        # Defaults to Yes (2026-08-31 per the maintainer) - blank/Enter now auto-resolves, same [Enter for Yes]
        # shape this codebase already uses elsewhere for a defaulted Y/N. Saying N explicitly still
        # skips straight to manual VSA entry below.
        $autoResolve = Read-Host "Auto-resolve nested AD group membership for the $SideLabel required group(s) ($($RequiredNames -join ', ')) and pick VSAs from those? [Enter for Yes] (Y/N)`n (requires the ActiveDirectory module/RSAT - say N to type VSA group names directly instead)"
        if ([string]::IsNullOrWhiteSpace($autoResolve) -or $autoResolve -match '^[Yy]') {
            # Resolve AD FIRST, ask the tech ONCE at the end - build one combined, deduped candidate
            # list (required group(s) + everything nested under them, in discovery order) instead of
            # interleaving a live AD call with a Y/N prompt per group.
            #
            # Lowercased at the point of collection (not just deep inside New-FortinetGroupVSA's own
            # hex-encoding) so what the wizard's PREVIEW shows the tech is exactly what ends up written
            # into the VSA - FortiGate's Fortinet-Group-Name matching is case-sensitive (confirmed
            # live, the maintainer), so a display/actual case mismatch here would be exactly the kind of silent
            # "auth works, group assignment doesn't" failure this is meant to avoid.
            $candidates = [System.Collections.Generic.List[string]]::new()
            foreach ($reqName in $RequiredNames) {
                $reqNameLower = $reqName.ToLowerInvariant()
                if ($candidates -notcontains $reqNameLower) { $candidates.Add($reqNameLower) }

                Write-Host "Resolving nested membership for '$reqName'..." -ForegroundColor Gray
                # Local closure so the retry-after-fallback path below can reuse the exact same
                # "walk $nested, add each to the candidate list" logic without duplicating it.
                $addNestedGroups = {
                    param($NestedGroups)
                    foreach ($n in $NestedGroups) {
                        $nameLower = $n.Name.ToLowerInvariant()
                        if ($candidates -notcontains $nameLower) { $candidates.Add($nameLower) }
                    }
                }
                try {
                    $nested = Resolve-NestedADGroups -GroupName $reqName -Server $ADServer -Credential $ADCredential
                    & $addNestedGroups $nested
                } catch {
                    Write-Host " Could not resolve nested membership for '$reqName' - $($_.Exception.Message)" -ForegroundColor Red
                    # A site where the default identity partially works (e.g. the earlier status check
                    # succeeded) can still fail HERE on a real query - confirmed live (the maintainer). Offer
                    # the same fallback the top-level check would, right here, rather than just
                    # warning and leaving the tech stuck mid-wizard.
                    if (Test-NPSADAuthError -ErrorRecord $_) {
                        Write-Host " This looks like a credentials problem, not a bad group name." -ForegroundColor Yellow
                        $fallback = Invoke-NPSADFallbackPrompt -ShimPath $ShimPath
                        if ($fallback -and $fallback.Credential) {
                            # Applies for the REST of this session too - every AD call after this one
                            # (including the rest of THIS loop, and the OTHER side's resolve pass)
                            # picks it up immediately.
                            $script:ADServer = $fallback.DCServer
                            $script:ADCredential = $fallback.Credential
                            $ADServer = $fallback.DCServer
                            $ADCredential = $fallback.Credential
                            try {
                                $nested = Resolve-NestedADGroups -GroupName $reqName -Server $ADServer -Credential $ADCredential
                                & $addNestedGroups $nested
                            } catch {
                                Write-Host " Still could not resolve nested membership for '$reqName' - $($_.Exception.Message)" -ForegroundColor Red
                            }
                        }
                    }
                }
            }

            if ($candidates.Count -eq 0) {
                Write-Host "No groups found - you'll need to enter $SideLabel VSA group names directly." -ForegroundColor Yellow
            } else {
                Write-Host ""
                Write-Host "$SideLabel VSA candidates (required group(s) + everything nested under them):" -ForegroundColor Cyan
                for ($i = 0; $i -lt $candidates.Count; $i++) {
                    Write-Host (" {0}. {1}" -f ($i + 1), $candidates[$i])
                }
                Write-Host ""
                # Blank now defaults to ALL (2026-08-31 per the maintainer, live: "let's make this screen a
                # 'blank for all, comma separated or n for none'") - was blank=none/'A'=all, which
                # meant the common case (take everything nested under the required group) needed an
                # extra keystroke every time. 'N' is the new explicit way to say none, since blank no
                # longer means that; 'A' still works too, for anyone used to typing it.
                $sel = Read-Host "Include which as $SideLabel VSAs? [Enter for all] Numbers comma-separated, or N for none"
                if ([string]::IsNullOrWhiteSpace($sel) -or $sel -match '^[Aa]$') {
                    foreach ($c in $candidates) { $vsaNames.Add($c) }
                } elseif ($sel -match '^[Nn]$') {
                    # None - $vsaNames stays empty.
                } else {
                    foreach ($token in ($sel.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
                        $idx = ($token -as [int]) - 1
                        if ($idx -ge 0 -and $idx -lt $candidates.Count) {
                            $vsaNames.Add($candidates[$idx])
                        } else {
                            Write-Host " Skipped invalid selection '$token'." -ForegroundColor Yellow
                        }
                    }
                }
                if ($vsaNames.Count -gt 0) {
                    Write-Host "Selected: $($vsaNames -join ', ')" -ForegroundColor Green
                } else {
                    Write-Host "No groups selected - you'll need to enter $SideLabel VSA group names directly." -ForegroundColor Yellow
                }
            }
        }
    }
    if ($vsaNames.Count -eq 0) {
        $entry = Read-Host "$SideLabel VSA group name(s), comma-separated (e.g. 'azuremfa_ikev2' / 'azuremfa_sslvpn_group') - leave blank for none"
        foreach ($n in ($entry.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { $vsaNames.Add($n.ToLowerInvariant()) }
    }
    return @($vsaNames)
}

# ---------------------------------------------------------------------------
function Invoke-ReprocessNPSVsaWizard {
    <#
    .SYNOPSIS
        Re-runs nested AD group membership resolution for an EXISTING Network Policy's already-
        configured required group(s), so its RADIUS Profile's Fortinet-Group-Name VSAs can be
        refreshed after AD group nesting changes (e.g. a new firewall group added to a role group) -
        without deleting and recreating the whole rule.
 
    .DESCRIPTION
        Pulls the SID(s) straight off the picked policy's own USERNTGROUPS condition(s) (see
        Get-NPSRequiredGroupSidsFromConstraints), resolves each back to its current name
        (Resolve-NPSGroupNameFromSid - same credentials-fallback handling as everywhere else AD gets
        touched in this wizard flow), then reuses Get-VsaNamesForSide - the EXACT same interactive
        nested-resolve UX Invoke-AddNPSRuleWizard already uses - rather than a second, parallel copy
        of that flow. Shows current vs. proposed VSAs (added/removed/unchanged) before writing.
    #>

    param(
        [Parameter(Mandatory)][string]$IASConfigPath,
        [string]$ADServer,
        [System.Management.Automation.PSCredential]$ADCredential,
        [string]$ShimPath
    )

    Write-NPSHeader "Re-process VSAs for a Rule"
    $summary = Get-NPSPolicySummary -Path $IASConfigPath -PolicyType NetworkPolicy
    $picked = Select-NPSPolicyFromSummary -Summary $summary -Prompt "Select a rule to re-process VSAs for, by number"
    if (-not $picked) { return }

    $sids = Get-NPSRequiredGroupSidsFromConstraints -Constraints $picked.Constraints
    if ($sids.Count -eq 0) {
        Write-Host "'$($picked.Name)' has no required-group-membership (USERNTGROUPS) condition to re-process VSAs from." -ForegroundColor Yellow
        Write-Host "(Only rules built with a group-membership condition have anything to re-resolve here.)" -ForegroundColor Yellow
        Read-Host "Press Enter to continue"
        return
    }

    Write-Host "Resolving current group name(s) for this rule's required SID(s)..." -ForegroundColor Gray
    $requiredNames = [System.Collections.Generic.List[string]]::new()
    foreach ($sid in $sids) {
        try {
            $name = Resolve-NPSGroupNameFromSid -Sid $sid -Server $ADServer -Credential $ADCredential
            $requiredNames.Add($name)
            Write-Host " $sid -> $name" -ForegroundColor Green
        } catch {
            Write-Host " Could not resolve $sid - $($_.Exception.Message)" -ForegroundColor Red
            # Same reactive AD-credential fallback as everywhere else this wizard touches AD - see
            # Test-NPSADAuthError.
            if (Test-NPSADAuthError -ErrorRecord $_) {
                Write-Host " This looks like a credentials problem." -ForegroundColor Yellow
                $fallback = Invoke-NPSADFallbackPrompt -ShimPath $ShimPath
                if ($fallback -and $fallback.Credential) {
                    $script:ADServer = $fallback.DCServer
                    $script:ADCredential = $fallback.Credential
                    $ADServer = $fallback.DCServer
                    $ADCredential = $fallback.Credential
                    try {
                        $name = Resolve-NPSGroupNameFromSid -Sid $sid -Server $ADServer -Credential $ADCredential
                        $requiredNames.Add($name)
                        Write-Host " $sid -> $name" -ForegroundColor Green
                    } catch {
                        Write-Host " Still could not resolve $sid - skipping it (may have been deleted)." -ForegroundColor Red
                    }
                }
            } else {
                Write-Host " Skipping $sid (may have been deleted from AD)." -ForegroundColor Yellow
            }
        }
    }
    if ($requiredNames.Count -eq 0) {
        Write-Host "Could not resolve any of this rule's required group(s) - nothing to re-process." -ForegroundColor Red
        Read-Host "Press Enter to continue"
        return
    }

    $currentVsas = @()
    try {
        $currentVsas = @(Get-NPSProfileVsaGroupNames -Path $IASConfigPath -PolicyName $picked.Name)
    } catch {
        Write-Host "Could not read the current VSA list - $($_.Exception.Message)" -ForegroundColor Red
        Read-Host "Press Enter to continue"
        return
    }
    Write-Host ""
    Write-Host "Current VSAs on '$($picked.Name)': $(if ($currentVsas.Count -gt 0) { $currentVsas -join ', ' } else { '(none)' })" -ForegroundColor Cyan
    Write-Host ""

    Write-NPSHeader "RADIUS Attributes (Fortinet-Group-Name VSAs)"
    $newVsas = @(Get-VsaNamesForSide -SideLabel $picked.Name -RequiredNames @($requiredNames) -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath)

    $added = @($newVsas | Where-Object { $currentVsas -notcontains $_ })
    $removed = @($currentVsas | Where-Object { $newVsas -notcontains $_ })
    $unchanged = @($newVsas | Where-Object { $currentVsas -contains $_ })

    Write-Host ""
    Write-Host "Proposed VSAs for '$($picked.Name)': $(if ($newVsas.Count -gt 0) { $newVsas -join ', ' } else { '(none)' })" -ForegroundColor Cyan
    if ($added.Count -gt 0) { Write-Host " + Added: $($added -join ', ')" -ForegroundColor Green }
    if ($removed.Count -gt 0) { Write-Host " - Removed: $($removed -join ', ')" -ForegroundColor Red }
    if ($unchanged.Count -gt 0) { Write-Host " = Unchanged: $($unchanged -join ', ')" -ForegroundColor Gray }
    if ($added.Count -eq 0 -and $removed.Count -eq 0) {
        Write-Host "No change - the resolved VSA set is identical to what's already there." -ForegroundColor Gray
        Read-Host "Press Enter to continue"
        return
    }
    Write-Host ""

    $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)"
    if ($confirm -notmatch '^[Yy]') { Write-Host "Cancelled - nothing changed." -ForegroundColor Gray; return }

    $result = Set-NPSProfileVsaGroupNames -Path $IASConfigPath -PolicyName $picked.Name -VsaGroupNames @($newVsas)
    Write-Host ""
    Write-Host "Done." -ForegroundColor Green
    Write-Host "Backup: $($result.BackupPath)" -ForegroundColor Green
    Write-Host "NPS/IAS watches ias.xml and picks up the change on its own - no service restart needed." -ForegroundColor Green
}

# ---------------------------------------------------------------------------
function Select-NPSClientIP {
    <#
    .SYNOPSIS
        Picks a Client-IP-Address for a new policy's Client-IP-Address condition - offers a numbered
        list of existing RADIUS Clients (Get-NPSClients) to choose from, or manual entry (always
        available, including when no clients exist yet).
 
    .DESCRIPTION
        A "0 RADIUS Clients registered" result is a real, valid state to land in even when
        Invoke-AddNPSRuleWizard just showed IPs already referenced elsewhere in ias.xml - those come
        from a DIFFERENT section (existing NetworkPolicy conditions' literal Client-IP-Address
        values) than this one (Protocols\Microsoft Radius Protocol\Clients, the actual registered
        RADIUS Client objects - see Get-NPSClients). A policy CAN condition on an IP that was never
        registered as a formal Client, so seeing IPs in one list and none in the other isn't a
        contradiction - always shows an explicit "(none found)" line for that case rather than
        silently rendering a header with nothing under it, which read as a blank/broken list.
    #>

    param([Parameter(Mandatory)][string]$IASConfigPath)

    $clients = @()
    try { $clients = @(Get-NPSClients -Path $IASConfigPath) } catch {}

    Write-Host "Existing RADIUS Clients:" -ForegroundColor Cyan
    if ($clients.Count -eq 0) {
        Write-Host " (none found in ias.xml - see Option 4, Manage NPS Clients, to register one)" -ForegroundColor Gray
    } else {
        for ($i = 0; $i -lt $clients.Count; $i++) {
            $c = $clients[$i]
            $flag = if (-not $c.Enabled) { ' [DISABLED]' } else { '' }
            Write-Host (" {0}. {1} ({2}){3}" -f ($i + 1), $c.Name, $c.IPAddress, $flag)
        }
    }
    Write-Host " M. Enter a Client-IP-Address manually instead"
    $sel = Read-Host "Pick a client by number, or M to enter manually"
    if ($sel -match '^[Mm]$') {
        return Read-Host "Client-IP-Address for the FortiGate NAS"
    }
    $idx = ($sel -as [int]) - 1
    if ($idx -ge 0 -and $idx -lt $clients.Count) {
        return $clients[$idx].IPAddress
    }
    Write-Host "Invalid selection - enter manually instead." -ForegroundColor Yellow
    return Read-Host "Client-IP-Address for the FortiGate NAS"
}

# ---------------------------------------------------------------------------
function Format-NPSCondText {
    param([array]$SidSets)
    return ($SidSets | ForEach-Object { '(' + ($_ -join ' OR ') + ')' }) -join ' AND '
}

# ---------------------------------------------------------------------------
function Invoke-AddNPSRuleWizard {
    <#
    .SYNOPSIS
        The full interactive "add a rule" flow - asks whether this needs the standard 3-policy
        set (Combined/IPSec-only/SSLVPN-only) or a single standalone rule, then drives the rest of
        the flow (naming, Client-IP, AND/OR conditions, VSA resolution, sequence placement, preview,
        confirm, apply) accordingly. Called from both Add-NPSNetworkPolicy.ps1 (standalone) and
        NPS-Manager.ps1 (dashboard Option 5 -> Add).
    #>

    param(
        [Parameter(Mandatory)][string]$IASConfigPath,
        [string]$ADServer,
        [System.Management.Automation.PSCredential]$ADCredential,
        # Passed straight through to Read-ANDConditionGroups/Get-VsaNamesForSide, in turn to
        # Invoke-NPSADFallbackPrompt if either hits what looks like a credentials problem mid-flow.
        [string]$ShimPath
    )

    Write-NPSHeader "Add NPS Rule"

    Write-Host "Does this rule need to simultaneously support SSLVPN AND IPSec through RADIUS?" -ForegroundColor Cyan
    Write-Host " Y = builds the 3-policy set (Combined / IPSec-only / SSLVPN-only)" -ForegroundColor Cyan
    Write-Host " N = a single standalone rule" -ForegroundColor Cyan
    $simulSupport = Read-Host "(Y/N)"
    $isTriplicate = $simulSupport -match '^[Yy]'

    Write-NPSHeader "Policy Naming"
    if ($isTriplicate) {
        $baseName = Read-Host "Base name for this policy set (e.g. 'RADIUS - Accounting')`n Three policies will be created: '<name> - IKEv2 & SSLVPN', '<name> - IKEv2 VPN', '<name> - SSLVPN'"
    } else {
        $baseName = Read-Host "Display name for this rule (e.g. 'RADIUS - VendorAccess')"
    }
    while ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = Read-Host "A name is required" }

    [xml]$currentConfig = Get-Content -Path $IASConfigPath -Raw
    $existingClientIPs = @($currentConfig.Root.Children.Microsoft_Internet_Authentication_Service.Children.NetworkPolicy.Children.ChildNodes |
        ForEach-Object {
            $c = @($_.Properties.msNPConstraint) | Where-Object { $_ -and $_.'#text' -match 'Client-IP-Address=([\d.]+)' }
            if ($c) { $Matches[1] }
        } | Select-Object -Unique)
    if ($existingClientIPs) {
        Write-Host "Client-IP-Address values already in use in this config: $($existingClientIPs -join ', ')" -ForegroundColor Gray
    }
    $clientIP = Select-NPSClientIP -IASConfigPath $IASConfigPath
    while ([string]::IsNullOrWhiteSpace($clientIP)) { $clientIP = Read-Host "A Client-IP-Address is required" }

    if ($isTriplicate) {
        Write-NPSHeader "IPSec (IKEv2) Access - Required Group Membership"
        Write-Host "Who gets IPSec/IKEv2 access - AD group condition(s) below." -ForegroundColor Gray
        Write-Host ""
        $ipsecReq = Read-ANDConditionGroups -SideLabel "IPSec" -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

        Write-NPSHeader "SSLVPN Access - Required Group Membership"
        Write-Host "Who gets SSLVPN access - a SEPARATE condition from IPSec above (same group(s) or" -ForegroundColor Gray
        Write-Host "different ones, your call)." -ForegroundColor Gray
        Write-Host ""
        $sslvpnReq = Read-ANDConditionGroups -SideLabel "SSLVPN" -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

        Write-NPSHeader "RADIUS Attributes (Fortinet-Group-Name VSAs)"
        Write-Host "These become msRADIUSAnyVSA entries in the RADIUS Profile - they tell the FortiGate" -ForegroundColor Cyan
        Write-Host "which local group(s) to place the user into once authenticated." -ForegroundColor Cyan
        Write-Host ""

        Write-Host "-- IPSec side --" -ForegroundColor Cyan
        $ikeVsaNames = Get-VsaNamesForSide -SideLabel "IPSec" -RequiredNames $ipsecReq.RequiredNames -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

        Write-Host ""
        Write-Host "-- SSLVPN side --" -ForegroundColor Cyan
        $sslVsaNames = Get-VsaNamesForSide -SideLabel "SSLVPN" -RequiredNames $sslvpnReq.RequiredNames -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

        if ($ikeVsaNames.Count -eq 0 -or $sslVsaNames.Count -eq 0) {
            Write-Host "ERROR: At least one VSA group name is required for both IPSec and SSLVPN." -ForegroundColor Red
            return
        }

        Write-NPSHeader "Policy Order"
        # Shows Name alongside Sequence (2026-08-31 per the maintainer: "can we show the list of policy names
        # so we can pick the right one, similar to how it's shown on the first page of NPS Manager") -
        # same rendering NPS-Manager.ps1's own "Current Network Policies" status view (Option 1's
        # dashboard) already uses.
        $policySummary = Get-NPSPolicySummary -Path $IASConfigPath -PolicyType NetworkPolicy
        if ($policySummary.Count -eq 0) {
            Write-Host "No existing Network Policies yet - this will be the first." -ForegroundColor Gray
        } else {
            Write-Host "Current Network Policies (evaluated top to bottom, first match wins):" -ForegroundColor Cyan
            foreach ($p in $policySummary) {
                $stateTag = if ($p.Enabled) { '' } else { ' [DISABLED]' }
                Write-Host (" {0,3}. {1}{2}" -f $p.Sequence, $p.Name, $stateTag)
            }
        }
        Write-Host "New policies always land as a contiguous 3-slot block, Combined first (must be evaluated" -ForegroundColor Gray
        Write-Host "before the narrower IPSec-only/SSLVPN-only ones, or a dual-qualified user could get caught" -ForegroundColor Gray
        Write-Host "by the narrower policy first and only receive one VSA)." -ForegroundColor Gray
        $seqInput = Read-Host "Insert at sequence number [Enter for 1 = top priority]"
        $insertAt = if ([string]::IsNullOrWhiteSpace($seqInput)) { 1 } else { [int]$seqInput }

        Write-NPSHeader "Preview"
        $preview = Add-NPSPolicySet -Path $IASConfigPath -BaseName $baseName -ClientIPAddress $clientIP `
            -IPSecGroupSidSets $ipsecReq.SidSets -SSLVPNGroupSidSets $sslvpnReq.SidSets `
            -IPSecVsaGroupNames $ikeVsaNames -SSLVPNVsaGroupNames $sslVsaNames `
            -InsertAtSequence $insertAt -WhatIf

        $ipsecCondText = Format-NPSCondText $ipsecReq.SidSets
        $sslCondText   = Format-NPSCondText $sslvpnReq.SidSets

        Write-Host "Would create 3 policies starting at sequence $insertAt`:"
        Write-Host " 1. $baseName - IKEv2 & SSLVPN (requires IPSec: $ipsecCondText AND SSLVPN: $sslCondText)"
        Write-Host " VSAs: $(($ikeVsaNames + $sslVsaNames | Select-Object -Unique) -join ', ')"
        Write-Host " 2. $baseName - IKEv2 VPN (requires: $ipsecCondText)"
        Write-Host " VSAs: $($ikeVsaNames -join ', ')"
        Write-Host " 3. $baseName - SSLVPN (requires: $sslCondText)"
        Write-Host " VSAs: $($sslVsaNames -join ', ')"
        Write-Host ""
        if ($preview.ShiftedSequences) {
            Write-Host "Existing policies at sequence $insertAt and above will be shifted back by 3 to make room." -ForegroundColor Yellow
        }
        Write-Host ""

        $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)"
        if ($confirm -notmatch '^[Yy]') { Write-Host "Cancelled - nothing changed." -ForegroundColor Gray; return }

        $result = Add-NPSPolicySet -Path $IASConfigPath -BaseName $baseName -ClientIPAddress $clientIP `
            -IPSecGroupSidSets $ipsecReq.SidSets -SSLVPNGroupSidSets $sslvpnReq.SidSets `
            -IPSecVsaGroupNames $ikeVsaNames -SSLVPNVsaGroupNames $sslVsaNames `
            -InsertAtSequence $insertAt

        Write-Host ""
        Write-Host "Done." -ForegroundColor Green
        Write-Host "Backup: $($result.BackupPath)" -ForegroundColor Green
        Write-Host "NPS/IAS watches ias.xml and picks up the change on its own - no service restart needed." -ForegroundColor Green
    } else {
        Write-NPSHeader "Required Group Membership (AND / OR)"
        Write-Host "Who this rule applies to - AD group condition(s) below." -ForegroundColor Gray
        Write-Host ""
        $req = Read-ANDConditionGroups -SideLabel "Access" -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

        Write-NPSHeader "RADIUS Attributes (Fortinet-Group-Name VSAs)"
        Write-Host "Optional for a single rule - leave blank if this rule doesn't need to assign a" -ForegroundColor Cyan
        Write-Host "specific FortiGate group." -ForegroundColor Cyan
        Write-Host ""
        $vsaNames = Get-VsaNamesForSide -SideLabel "Access" -RequiredNames $req.RequiredNames -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

        Write-NPSHeader "Policy Order"
        # Shows Name alongside Sequence (2026-08-31 per the maintainer) - same rendering as the other two
        # "Policy Order" spots in this file/OrchestratorImport.ps1, see their own comments.
        $policySummary = Get-NPSPolicySummary -Path $IASConfigPath -PolicyType NetworkPolicy
        if ($policySummary.Count -eq 0) {
            Write-Host "No existing Network Policies yet - this will be the first." -ForegroundColor Gray
        } else {
            Write-Host "Current Network Policies (evaluated top to bottom, first match wins):" -ForegroundColor Cyan
            foreach ($p in $policySummary) {
                $stateTag = if ($p.Enabled) { '' } else { ' [DISABLED]' }
                Write-Host (" {0,3}. {1}{2}" -f $p.Sequence, $p.Name, $stateTag)
            }
        }
        $seqInput = Read-Host "Insert at sequence number [Enter for 1 = top priority]"
        $insertAt = if ([string]::IsNullOrWhiteSpace($seqInput)) { 1 } else { [int]$seqInput }

        Write-NPSHeader "Preview"
        $preview = Add-NPSSingleRule -Path $IASConfigPath -DisplayName $baseName -ClientIPAddress $clientIP `
            -GroupSidSets $req.SidSets -VsaGroupNames $vsaNames -InsertAtSequence $insertAt -WhatIf

        Write-Host "Would create 1 policy at sequence $insertAt`:"
        Write-Host " $baseName (requires: $(Format-NPSCondText $req.SidSets))"
        Write-Host " VSAs: $(if ($vsaNames.Count -gt 0) { $vsaNames -join ', ' } else { '(none)' })"
        Write-Host ""
        if ($preview.ShiftedSequences) {
            Write-Host "Existing policies at sequence $insertAt and above will be shifted back by 1 to make room." -ForegroundColor Yellow
        }
        Write-Host ""

        $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)"
        if ($confirm -notmatch '^[Yy]') { Write-Host "Cancelled - nothing changed." -ForegroundColor Gray; return }

        $result = Add-NPSSingleRule -Path $IASConfigPath -DisplayName $baseName -ClientIPAddress $clientIP `
            -GroupSidSets $req.SidSets -VsaGroupNames $vsaNames -InsertAtSequence $insertAt

        Write-Host ""
        Write-Host "Done." -ForegroundColor Green
        Write-Host "Backup: $($result.BackupPath)" -ForegroundColor Green
        Write-Host "NPS/IAS watches ias.xml and picks up the change on its own - no service restart needed." -ForegroundColor Green
    }
}

# ---------------------------------------------------------------------------
function Read-NPSConnectionRequestConditions {
    <#
    .SYNOPSIS
        The interactive condition-collection loop for a Connection Request Policy - factored out of
        Invoke-AddNPSConnectionRequestPolicyWizard so Invoke-EditNPSPolicyConditions can reuse the
        EXACT same loop (condition kinds, wording, validation) instead of a second, parallel copy
        that could drift out of sync with the add flow.
 
    .DESCRIPTION
        Offers the condition kinds actually seen in real CRP data (Client-IP-Address, Client-
        Friendly-Name, NAS-Port-Type, TIMEOFDAY), required AD group membership (reuses
        Read-ANDConditionGroups as-is), and a free-text "other attribute" fallback so an uncommon
        condition is never a hard blocker.
 
    .PARAMETER StartingConditions
        Pre-seeds the list (used by the edit flow, which starts from the policy's CURRENT conditions
        rather than empty) - each entry is shown as already-added before the tech adds/removes
        anything further. Add-only per pass (this loop has no "remove" option - matches the wholesale-
        recompute semantics already established for VSA re-processing: start over with what you want,
        don't try to surgically edit one entry among many).
    #>

    param(
        [string]$ADServer,
        [System.Management.Automation.PSCredential]$ADCredential,
        [string]$ShimPath,
        [string[]]$StartingConditions = @()
    )

    # MUST be an explicit [string[]] cast, not a bare @() wrap - List[string]::new() has no overload
    # for a plain object[] (what @() alone produces), since .NET generics don't treat
    # IEnumerable<object> as assignable to IEnumerable<string> - confirmed live, this exact call threw
    # "Cannot find an overload for 'new' and the argument count: 1" without the cast.
    $conditions = [System.Collections.Generic.List[string]]::new([string[]]@($StartingConditions))
    Write-Host "Add one or more conditions - ALL of them must match for this policy to apply." -ForegroundColor Cyan
    if ($conditions.Count -gt 0) {
        Write-Host "Starting from $($conditions.Count) existing condition(s):" -ForegroundColor Gray
        foreach ($c in $conditions) { Write-Host " - $c" -ForegroundColor Gray }
    }
    Write-Host ""
    :ConditionLoop while ($true) {
        Write-Host " 1. Client IP Address"
        Write-Host " 2. Client Friendly Name (supports a regex pattern, e.g. '.*pGINA')"
        Write-Host " 3. NAS Port Type (numeric code, e.g. real samples use '^5$' - RD Gateway)"
        Write-Host " 4. Time of Day (Always = all days/times, or type a custom schedule)"
        Write-Host " 5. Required AD group membership (AND/OR - same wizard the access-grant flow uses)"
        Write-Host " 6. Other RADIUS attribute (free-text name + value)"
        Write-Host " F. Finish$(if ($conditions.Count -eq 0) { ' (need at least 1 condition first)' } else { " ($($conditions.Count) condition(s) so far)" })"
        $choice = Read-Host "Select an option"

        switch -Regex ($choice) {
            '^1$' {
                $ip = Read-Host "Client IP Address"
                if (-not [string]::IsNullOrWhiteSpace($ip)) {
                    $conditions.Add("MATCH(`"Client-IP-Address=$ip`")")
                    Write-Host " Added." -ForegroundColor Green
                }
            }
            '^2$' {
                $pattern = Read-Host "Client Friendly Name (exact value or regex pattern)"
                if (-not [string]::IsNullOrWhiteSpace($pattern)) {
                    $conditions.Add("MATCH(`"Client-Friendly-Name=$pattern`")")
                    Write-Host " Added." -ForegroundColor Green
                }
            }
            '^3$' {
                $portType = Read-Host "NAS Port Type value/pattern"
                if (-not [string]::IsNullOrWhiteSpace($portType)) {
                    $conditions.Add("MATCH(`"NAS-Port-Type=$portType`")")
                    Write-Host " Added." -ForegroundColor Green
                }
            }
            '^4$' {
                $todChoice = Read-Host " A for Always (all days, all times), or C for a Custom TIMEOFDAY value"
                if ($todChoice -match '^[Aa]') {
                    # Exact convention seen in every real "always" CRP sample seen in the field - all 7 days, 00:00-24:00.
                    $conditions.Add('TIMEOFDAY("0 00:00-24:00; 1 00:00-24:00; 2 00:00-24:00; 3 00:00-24:00; 4 00:00-24:00; 5 00:00-24:00; 6 00:00-24:00")')
                    Write-Host " Added." -ForegroundColor Green
                } elseif ($todChoice -match '^[Cc]') {
                    $custom = Read-Host " Custom TIMEOFDAY value (the content that goes inside TIMEOFDAY(`"...`"))"
                    if (-not [string]::IsNullOrWhiteSpace($custom)) {
                        $conditions.Add("TIMEOFDAY(`"$custom`")")
                        Write-Host " Added." -ForegroundColor Green
                    }
                } else {
                    Write-Host " Cancelled." -ForegroundColor Gray
                }
            }
            '^5$' {
                $req = Read-ANDConditionGroups -SideLabel "Group membership" -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath
                foreach ($sidSet in $req.SidSets) {
                    $quoted = ($sidSet | ForEach-Object { "`"$_`"" }) -join ','
                    $conditions.Add("USERNTGROUPS($quoted)")
                }
                if ($req.SidSets.Count -gt 0) { Write-Host " Added $($req.SidSets.Count) condition(s)." -ForegroundColor Green }
            }
            '^6$' {
                $attrName = Read-Host " RADIUS attribute name (e.g. 'Called-Station-Id')"
                if (-not [string]::IsNullOrWhiteSpace($attrName)) {
                    $attrValue = Read-Host " Value/pattern for '$attrName'"
                    if (-not [string]::IsNullOrWhiteSpace($attrValue)) {
                        $conditions.Add("MATCH(`"$attrName=$attrValue`")")
                        Write-Host " Added." -ForegroundColor Green
                    }
                }
            }
            '^[Ff]$' {
                if ($conditions.Count -eq 0) {
                    Write-Host "At least one condition is required before finishing." -ForegroundColor Yellow
                } else {
                    break ConditionLoop
                }
            }
            default { Write-Host "Invalid selection." -ForegroundColor Yellow }
        }
        Write-Host ""
    }
    return @($conditions)
}

# ---------------------------------------------------------------------------
function Invoke-AddNPSConnectionRequestPolicyWizard {
    <#
    .SYNOPSIS
        The full interactive "add a Connection Request Policy" flow - deliberately NOT a reuse of
        Invoke-AddNPSRuleWizard, which is inherently about access grants (group SIDs + VSAs); that
        model is semantically wrong for a CRP, which decides WHERE/HOW a request gets authenticated,
        not who gets access to what. No VSA/profile step at all - confirmed with the maintainer, every real
        CRP sample seen in the field routes/authenticates locally rather than granting VSA-bearing
        access.
 
    .DESCRIPTION
        Condition collection itself is Read-NPSConnectionRequestConditions (shared with
        Invoke-EditNPSPolicyConditions) - the real ones seen in live CRP data (Client-IP-Address,
        Client-Friendly-Name, NAS-Port-Type, TIMEOFDAY), required AD group membership (reuses
        Read-ANDConditionGroups as-is), and a free-text "other attribute" fallback so an uncommon
        condition is never a hard blocker. Same sequence-placement/preview/confirm/apply pattern as
        Invoke-AddNPSRuleWizard, just against Add-NPSConnectionRequestRule instead.
    #>

    param(
        [Parameter(Mandatory)][string]$IASConfigPath,
        [string]$ADServer,
        [System.Management.Automation.PSCredential]$ADCredential,
        # Passed straight through to Read-ANDConditionGroups, in turn to Invoke-NPSADFallbackPrompt if
        # it hits what looks like a credentials problem mid-flow.
        [string]$ShimPath
    )

    Write-NPSHeader "Add Connection Request Policy"

    $displayName = Read-Host "Display name for this policy (e.g. 'RADIUS from FortiGate')"
    while ([string]::IsNullOrWhiteSpace($displayName)) { $displayName = Read-Host "A name is required" }

    Write-NPSHeader "Conditions"
    $conditions = Read-NPSConnectionRequestConditions -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath

    Write-NPSHeader "Policy Order"
    [xml]$currentConfig = Get-Content -Path $IASConfigPath -Raw
    $existingSeqs = Get-NPSExistingSequences -ConfigXml $currentConfig -PolicyType ConnectionRequest
    Write-Host "Existing real Connection Request Policy sequence numbers: $($existingSeqs -join ', ')" -ForegroundColor Gray
    $seqInput = Read-Host "Insert at sequence number [Enter for 1 = top priority]"
    $insertAt = if ([string]::IsNullOrWhiteSpace($seqInput)) { 1 } else { [int]$seqInput }

    Write-NPSHeader "Preview"
    $preview = Add-NPSConnectionRequestRule -Path $IASConfigPath -DisplayName $displayName -Conditions @($conditions) -InsertAtSequence $insertAt -WhatIf

    Write-Host "Would create 1 Connection Request Policy at sequence $insertAt`:"
    Write-Host " $displayName"
    foreach ($c in $conditions) { Write-Host " - $c" -ForegroundColor Gray }
    Write-Host " (no RADIUS Profile/VSAs - Connection Request Policies in this environment don't use them)" -ForegroundColor Gray
    Write-Host ""
    if ($preview.ShiftedSequences) {
        Write-Host "Existing Connection Request Policies at sequence $insertAt and above will be shifted back by 1 to make room." -ForegroundColor Yellow
    }
    Write-Host ""

    $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)"
    if ($confirm -notmatch '^[Yy]') { Write-Host "Cancelled - nothing changed." -ForegroundColor Gray; return }

    $result = Add-NPSConnectionRequestRule -Path $IASConfigPath -DisplayName $displayName -Conditions @($conditions) -InsertAtSequence $insertAt

    Write-Host ""
    Write-Host "Done." -ForegroundColor Green
    Write-Host "Backup: $($result.BackupPath)" -ForegroundColor Green
    Write-Host "NPS/IAS watches ias.xml and picks up the change on its own - no service restart needed." -ForegroundColor Green
}

# ---------------------------------------------------------------------------
function Invoke-EditNPSPolicyConditions {
    <#
    .SYNOPSIS
        Edits an EXISTING policy's conditions - Network Policy or Connection Request Policy, per
        -PolicyType (identical record schema, see NPSCore.ps1 module NOTES) - previously the only way
        to change a policy's conditions was delete + recreate. Wholesale replace (Set-NPSPolicyConditions),
        matching the same "recompute the full set" semantics already established for VSA
        re-processing, not a surgical single-condition edit.
 
    .DESCRIPTION
        Connection Request Policy conditions reuse Read-NPSConnectionRequestConditions - the EXACT
        same condition-collection loop the add wizard uses - pre-seeded with the policy's current
        conditions (add-only from there; start over with what you want, matching the wholesale-
        recompute philosophy rather than a per-entry add/remove UI).
 
        Network Policy conditions are structurally two distinct pieces (Client-IP-Address + AND/OR
        group SIDs, see New-NPSGroupConditionList) rather than one flat freeform list, so there's no
        equivalent single pre-seeded builder to reuse - the tech keeps or changes the Client-IP-
        Address, then re-enters the group-membership conditions fresh via Read-ANDConditionGroups
        (current ones are shown first for reference) - the same as a tech would re-derive them when
        adding a rule in the first place, just re-confirmed rather than assumed unchanged.
 
        Does NOT touch the RADIUS Profile/VSAs at all, even for a Network Policy whose group
        conditions changed - re-processing VSAs afterward (Invoke-ReprocessNPSVsaWizard) is a
        deliberately separate, explicit step, not auto-chained here.
    #>

    param(
        [Parameter(Mandatory)][string]$IASConfigPath,
        [Parameter(Mandatory)][ValidateSet('NetworkPolicy', 'ConnectionRequest')][string]$PolicyType,
        [string]$ADServer,
        [System.Management.Automation.PSCredential]$ADCredential,
        [string]$ShimPath
    )

    Write-NPSHeader "Edit Conditions"
    $summary = Get-NPSPolicySummary -Path $IASConfigPath -PolicyType $PolicyType
    $picked = Select-NPSPolicyFromSummary -Summary $summary -Prompt "Select a policy to edit conditions for, by number"
    if (-not $picked) { return }

    Write-Host ""
    Write-Host "Current conditions on '$($picked.Name)':" -ForegroundColor Cyan
    foreach ($c in $picked.Constraints) { Write-Host " - $c" -ForegroundColor Gray }
    Write-Host ""

    if ($PolicyType -eq 'ConnectionRequest') {
        Write-NPSHeader "Conditions"
        $newConditions = Read-NPSConnectionRequestConditions -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath -StartingConditions $picked.Constraints
    } else {
        Write-NPSHeader "Client IP Address"
        $currentIpMatch = $picked.Constraints | Where-Object { $_ -match 'Client-IP-Address=([^"]+)' } | Select-Object -First 1
        $currentIp = if ($currentIpMatch -and $currentIpMatch -match 'Client-IP-Address=([^"]+)') { $Matches[1] } else { $null }
        $clientIP = $null
        if ($currentIp) {
            $keepIp = Read-Host "Keep the current Client-IP-Address ($currentIp)? (Y/N)"
            if ($keepIp -match '^[Yy]' -or [string]::IsNullOrWhiteSpace($keepIp)) { $clientIP = $currentIp }
        }
        if (-not $clientIP) { $clientIP = Select-NPSClientIP -IASConfigPath $IASConfigPath }
        while ([string]::IsNullOrWhiteSpace($clientIP)) { $clientIP = Read-Host "A Client-IP-Address is required" }

        Write-NPSHeader "Required Group Membership (AND / OR)"
        Write-Host "Re-enter the required group-membership condition(s) for this policy - shown above for reference." -ForegroundColor Cyan
        $req = Read-ANDConditionGroups -SideLabel "Access" -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath
        $newConditions = New-NPSGroupConditionList -ClientIPAddress $clientIP -AndGroupSidSets $req.SidSets
    }

    $added = @($newConditions | Where-Object { $picked.Constraints -notcontains $_ })
    $removed = @($picked.Constraints | Where-Object { $newConditions -notcontains $_ })
    $unchanged = @($newConditions | Where-Object { $picked.Constraints -contains $_ })

    Write-NPSHeader "Preview"
    $preview = Set-NPSPolicyConditions -Path $IASConfigPath -PolicyName $picked.Name -PolicyType $PolicyType -Conditions $newConditions -WhatIf

    Write-Host "Proposed conditions for '$($picked.Name)':"
    foreach ($c in $newConditions) { Write-Host " - $c" -ForegroundColor Gray }
    Write-Host ""
    if ($added.Count -gt 0) { Write-Host " + Added: $($added -join ' | ')" -ForegroundColor Green }
    if ($removed.Count -gt 0) { Write-Host " - Removed: $($removed -join ' | ')" -ForegroundColor Red }
    if ($unchanged.Count -gt 0) { Write-Host " = Unchanged: $($unchanged -join ' | ')" -ForegroundColor Gray }
    if ($added.Count -eq 0 -and $removed.Count -eq 0) {
        Write-Host "No change - the new condition set is identical to what's already there." -ForegroundColor Gray
        Read-Host "Press Enter to continue"
        return
    }
    if ($PolicyType -eq 'NetworkPolicy' -and $removed.Count -gt 0) {
        Write-Host ""
        Write-Host "Note: this rule's required group(s) changed - consider re-processing its VSAs" -ForegroundColor Yellow
        Write-Host "afterward (Option R) if the FortiGate group assignment should reflect this too." -ForegroundColor Yellow
    }
    Write-Host ""

    $confirm = Read-Host "Apply this to $IASConfigPath now? A timestamped backup will be made first. (Y/N)"
    if ($confirm -notmatch '^[Yy]') { Write-Host "Cancelled - nothing changed." -ForegroundColor Gray; return }

    $result = Set-NPSPolicyConditions -Path $IASConfigPath -PolicyName $picked.Name -PolicyType $PolicyType -Conditions $newConditions
    Write-Host ""
    Write-Host "Done." -ForegroundColor Green
    Write-Host "Backup: $($result.BackupPath)" -ForegroundColor Green
    Write-Host "NPS/IAS watches ias.xml and picks up the change on its own - no service restart needed." -ForegroundColor Green
}