source/Private/Common.ps1

function Invoke-AzCommandWithNotFoundHandling {
    <#
    .SYNOPSIS
    Executes an Az command and returns a default value on "not found" errors instead of throwing.
 
    .DESCRIPTION
    Wraps a scriptblock in try/catch. If the Az cmdlet throws with FullyQualifiedErrorId
    containing 'Request_ResourceNotFound', returns DefaultValue. All other errors propagate.
 
    .PARAMETER ScriptBlock
    The Az command to execute. Must use -ErrorAction Stop internally.
 
    .PARAMETER DefaultValue
    Value to return when the resource is not found. Defaults to $null.
 
    .OUTPUTS
    The output of the scriptblock, or DefaultValue on not-found errors.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        $DefaultValue = $null
    )

    try {
        & $ScriptBlock
    }
    catch {
        if ($_.FullyQualifiedErrorId -match 'Request_ResourceNotFound') {
            return $DefaultValue
        }
        throw
    }
}

function Get-RequiredModuleVersionRange {
    <#
    .SYNOPSIS
    Derives the accepted version range for a pinned module version.
 
    .DESCRIPTION
    The pinned version is the minimum; any patch release with the same major.minor is accepted.
 
    .PARAMETER PinnedVersion
    The tested module version, e.g. 5.5.2.
 
    .OUTPUTS
    [PSCustomObject] with MinimumVersion, MaximumVersion and RangeText.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [version]$PinnedVersion
    )

    [PSCustomObject]@{
        MinimumVersion = $PinnedVersion
        MaximumVersion = [version]"$($PinnedVersion.Major).$($PinnedVersion.Minor).99999"
        RangeText      = "$($PinnedVersion.Major).$($PinnedVersion.Minor).x ($PinnedVersion or newer)"
    }
}

function Get-InstalledModuleInRange {
    <#
    .SYNOPSIS
    Returns the highest installed version of a module within the accepted range, or nothing.
    #>

    [CmdletBinding()]
    [OutputType([PSModuleInfo])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ModuleName,

        [Parameter(Mandatory = $true)]
        [PSCustomObject]$Range
    )

    Get-Module -FullyQualifiedName @{
        ModuleName     = $ModuleName
        ModuleVersion  = $Range.MinimumVersion
        MaximumVersion = $Range.MaximumVersion
    } -ListAvailable -ErrorAction SilentlyContinue |
        Sort-Object -Property Version -Descending |
        Select-Object -First 1
}

function Get-ConflictingModuleList {

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$RequiredModules
    )

    $conflicts = @()

    foreach ($moduleName in $RequiredModules.Keys) {
        $range = Get-RequiredModuleVersionRange -PinnedVersion $RequiredModules[$moduleName]
        $loadedModules = Get-Module -Name $moduleName -ErrorAction SilentlyContinue

        if ($loadedModules) {
            $conflictingVersions = $loadedModules | Where-Object { $_.Version -lt $range.MinimumVersion -or $_.Version -gt $range.MaximumVersion }

            if ($conflictingVersions) {
                $conflicts += [PSCustomObject]@{
                    ModuleName = $moduleName
                    LoadedVersions = $conflictingVersions.Version
                    RequiredRange = $range.RangeText
                }
            }
        }
    }

    return $conflicts
}

function Show-ModuleConflictInstructions {
    <#
    .SYNOPSIS
    Shows user-friendly instructions to resolve module version conflicts.
 
    .PARAMETER ConflictingModules
    Array of conflicting module information from Get-ConflictingModuleList.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', 'Show-ModuleConflictInstructions')]
    param (
        [Parameter(Mandatory = $true)]
        [PSCustomObject[]]$ConflictingModules
    )

    Write-Host "Module version conflicts detected - initialization cannot proceed in current session" -ForegroundColor Red
    Write-Host ""
    Write-Host "PowerShell cannot unload assemblies from the current session." -ForegroundColor Yellow
    Write-Host "To resolve this issue, please follow these steps:" -ForegroundColor Yellow
    Write-Host ""
    Write-Host "1. Close this PowerShell session" -ForegroundColor Cyan
    Write-Host "2. Open a new PowerShell session" -ForegroundColor Cyan
    Write-Host "3. Re-run your command" -ForegroundColor Cyan
    Write-Host ""
    Write-Host "Module version conflicts:" -ForegroundColor Yellow

    foreach ($conflict in $ConflictingModules) {
        $loadedVersions = $conflict.LoadedVersions -join ', '
        Write-Host " - $($conflict.ModuleName): Loaded=$loadedVersions, Required=$($conflict.RequiredRange)" -ForegroundColor Yellow
    }

    Write-Host ""
}

function Initialize-RequiredModule {
    <#
    .SYNOPSIS
    Initializes required PowerShell modules within pinned major.minor version ranges.
 
    .DESCRIPTION
    Checks for module version conflicts, installs missing modules, and imports required modules.
    Any patch release with the same major.minor as the pinned version is accepted; the highest installed version in range is used.
    If version conflicts are detected, provides user instructions and returns false.
 
    .PARAMETER RequiredModules
    Hashtable of module names to minimum versions. Defaults to RequiredAzModules.
 
    .OUTPUTS
    [bool] True if initialization succeeded, False if conflicts prevent initialization.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [System.Collections.IDictionary]$RequiredModules = $RequiredAzModules
    )

    Write-Verbose "Initializing required PowerShell modules..."

    # Check for module version conflicts (wrong version already loaded = unrecoverable in this session)
    $conflictingModules = @(Get-ConflictingModuleList -RequiredModules $RequiredModules)
    if ($conflictingModules.Count -gt 0) {
        Show-ModuleConflictInstructions -ConflictingModules $conflictingModules
        return $false
    }

    # Determine what needs installation vs import
    $modulesToInstall = [ordered]@{}
    $modulesToImport = [ordered]@{}

    foreach ($moduleName in $RequiredModules.Keys) {
        $range = Get-RequiredModuleVersionRange -PinnedVersion $RequiredModules[$moduleName]

        $loadedModule = Get-Module -Name $moduleName | Where-Object { $_.Version -ge $range.MinimumVersion -and $_.Version -le $range.MaximumVersion }
        if ($loadedModule) {
            Write-Verbose "Module $moduleName version $($loadedModule.Version) is already loaded"
            continue
        }

        $installedModule = Get-InstalledModuleInRange -ModuleName $moduleName -Range $range

        if (-not $installedModule) {
            $modulesToInstall[$moduleName] = $range
        }

        $modulesToImport[$moduleName] = $range
    }

    # Pass 1: Install all missing modules BEFORE importing any (avoids file-lock warnings)
    foreach ($moduleName in $modulesToInstall.Keys) {
        $range = $modulesToInstall[$moduleName]
        Write-Verbose "Installing module $moduleName..."
        try {
            Install-Module -Name $moduleName -MinimumVersion $range.MinimumVersion -MaximumVersion $range.MaximumVersion -Force -AllowClobber -Scope CurrentUser -ErrorAction Stop
            $installedModule = Get-InstalledModuleInRange -ModuleName $moduleName -Range $range
            Write-Information "Successfully installed $moduleName version $($installedModule.Version)"
        }
        catch {
            Write-Error "Failed to install required module $moduleName. Error: $($_.Exception.Message)"
            return $false
        }
    }

    # Pass 2: Import all modules (no -Force; dependencies are already loaded in order)
    foreach ($moduleName in $modulesToImport.Keys) {
        $range = $modulesToImport[$moduleName]
        Write-Verbose "Importing module $moduleName..."
        try {
            $importedModule = Import-Module -Name $moduleName -MinimumVersion $range.MinimumVersion -MaximumVersion $range.MaximumVersion -PassThru -ErrorAction Stop -InformationAction SilentlyContinue -Verbose:$false
            Write-Verbose "Successfully imported $moduleName version $($importedModule.Version)"
        }
        catch {
            Write-Error "Failed to import required module $moduleName. Error: $($_.Exception.Message)"
            return $false
        }
    }

    Write-Verbose "All required PowerShell modules are initialized and ready to use"
    return $true
}