modules/ISC.OperatorToolchain.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

$script:PackageManagerAdapter = $null

function Set-OperatorToolchainPackageManager {
    param(
        [Parameter(Mandatory)][scriptblock]$InstallPackage,
        [Parameter(Mandatory)][scriptblock]$TestPackageManager
    )

    $script:PackageManagerAdapter = @{
        InstallPackage       = $InstallPackage
        TestPackageManager   = $TestPackageManager
    }
}

function Get-OperatorPlatform {
    $platform = if ($PSVersionTable.PSObject.Properties['Platform']) { $PSVersionTable.Platform } else { 'Win32NT' }
    if ($platform -eq 'Unix') {
        if ($IsMacOS) { return 'macOS' }
        return 'Linux'
    }
    return 'Windows'
}

function Test-WingetAvailable {
    return [bool](Get-Command winget -ErrorAction SilentlyContinue)
}

function Test-HomebrewAvailable {
    return [bool](Get-Command brew -ErrorAction SilentlyContinue)
}

function Invoke-PlatformPackageInstall {
    param(
        [Parameter(Mandatory)][string]$PackageId,
        [string]$BrewPackage,
        [string]$ManualHint
    )

    if ($script:PackageManagerAdapter) {
        & $script:PackageManagerAdapter.InstallPackage $PackageId $BrewPackage
        return
    }

    $platform = Get-OperatorPlatform
    if ($platform -eq 'Windows') {
        if (-not (Test-WingetAvailable)) {
            throw 'winget is not available. Install App Installer from the Microsoft Store or use Windows 10 1809+.'
        }
        Write-Info "Installing $PackageId via winget ..."
        & winget install --id $PackageId --exact --accept-package-agreements --accept-source-agreements --silent
        if ($LASTEXITCODE -ne 0) {
            throw "winget install $PackageId failed with exit code $LASTEXITCODE."
        }
        return
    }

    if (Test-HomebrewAvailable) {
        $brewName = if ($BrewPackage) { $BrewPackage } else { $PackageId }
        Write-Info "Installing $brewName via Homebrew ..."
        & brew install $brewName
        if ($LASTEXITCODE -ne 0) {
            throw "brew install $brewName failed with exit code $LASTEXITCODE."
        }
        return
    }

    if ($ManualHint) {
        throw $ManualHint
    }
    throw "No package manager is available on $platform. Install $PackageId manually."
}

function Resolve-CliCommand {
    param(
        [Parameter(Mandatory)][string[]]$CommandNames
    )

    foreach ($name in $CommandNames) {
        $cmd = Get-Command $name -ErrorAction SilentlyContinue
        if ($cmd) { return $cmd.Source }
    }
    return $null
}

function Ensure-CliCommand {
    param(
        [Parameter(Mandatory)][string[]]$CommandNames,
        [string]$WingetPackageId,
        [string]$BrewPackage,
        [string]$ManualHint,
        [switch]$SkipInstall
    )

    $path = Resolve-CliCommand -CommandNames $CommandNames
    if ($path) { return $path }

    if ($SkipInstall) {
        if ($ManualHint) { throw $ManualHint }
        throw "Required command not found on PATH: $($CommandNames -join ', ')"
    }

    if (-not (Read-YesNo -Prompt "Install $($CommandNames[0]) using the platform package manager?" -Default $true)) {
        if ($ManualHint) { throw $ManualHint }
        throw "Required command not found: $($CommandNames -join ', ')"
    }

    Invoke-PlatformPackageInstall -PackageId $WingetPackageId -BrewPackage $BrewPackage -ManualHint $ManualHint
    $path = Resolve-CliCommand -CommandNames $CommandNames
    if (-not $path) {
        throw "Installed $($CommandNames[0]) but it is still not on PATH. Restart the terminal and re-run."
    }
    return $path
}

function Ensure-GCloudCommand {
    return Ensure-CliCommand -CommandNames @('gcloud', 'gcloud.cmd') `
        -WingetPackageId 'Google.CloudSDK' `
        -BrewPackage 'gcloud-cli' `
        -ManualHint 'Install the Google Cloud SDK from https://cloud.google.com/sdk/docs/install and run gcloud init.'
}

function Ensure-AzCliCommand {
    return Ensure-CliCommand -CommandNames @('az', 'az.cmd') `
        -WingetPackageId 'Microsoft.AzureCLI' `
        -BrewPackage 'azure-cli' `
        -ManualHint 'Install the Azure CLI from https://learn.microsoft.com/cli/azure/install-azure-cli and run az login.'
}

function Ensure-OpenSslCommand {
    param([string]$OpenSslPath)

    if ($OpenSslPath) {
        if (-not (Test-Path -LiteralPath $OpenSslPath)) {
            throw "openssl was not found at $OpenSslPath"
        }
        return $OpenSslPath
    }

    $existing = Resolve-CliCommand -CommandNames @('openssl')
    if ($existing) { return $existing }

    $platform = Get-OperatorPlatform
    if ($platform -eq 'macOS' -and (Test-Path -LiteralPath '/usr/bin/openssl')) {
        return '/usr/bin/openssl'
    }

    return Ensure-CliCommand -CommandNames @('openssl') `
        -WingetPackageId 'ShiningLight.OpenSSL.Light' `
        -BrewPackage 'openssl@3' `
        -ManualHint 'Install OpenSSL (Git for Windows includes it) or pass -OpenSslPath. SailPoint requires a traditional encrypted RSA PEM.'
}

function Install-PowerShellGalleryModuleSet {
    param(
        [Parameter(Mandatory)][string[]]$ModuleNames,
        [string]$PromptTitle = 'PowerShell Gallery modules are required'
    )

    $missing = @($ModuleNames | Where-Object { -not (Get-Module -ListAvailable -Name $_) })
    if ($missing.Count -eq 0) { return }

    Write-Step $PromptTitle
    foreach ($name in $missing) { Write-Info $name }
    if (-not (Read-YesNo -Prompt 'Install the missing modules for the current user?' -Default $true)) {
        throw "Install the modules manually: Install-Module $($missing -join ', ') -Scope CurrentUser"
    }
    foreach ($name in $missing) {
        Write-Info "Installing $name ..."
        Install-Module -Name $name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
    }
}

function Import-PowerShellGalleryModuleSet {
    param([Parameter(Mandatory)][string[]]$ModuleNames)

    foreach ($name in $ModuleNames) {
        Import-Module $name -ErrorAction Stop
    }
}

function Ensure-MicrosoftGraphModules {
    $required = @(
        'Microsoft.Graph.Authentication'
        'Microsoft.Graph.Applications'
        'Microsoft.Graph.Identity.DirectoryManagement'
    )
    Install-PowerShellGalleryModuleSet -ModuleNames $required -PromptTitle 'Microsoft Graph PowerShell modules are required'
    Import-PowerShellGalleryModuleSet -ModuleNames $required
}

function Get-AwsToolsModuleNames {
    param([string[]]$ExtraModules = @())

    $names = @(
        'AWS.Tools.Common'
        'AWS.Tools.SecurityToken'
        'AWS.Tools.IdentityManagement'
        'AWS.Tools.Organizations'
    ) + @($ExtraModules)
    return @($names | Select-Object -Unique)
}

function Resolve-AwsToolsTargetVersion {
    param(
        [Version]$SessionVersion,
        [Version[]]$InstalledVersions
    )

    if ($SessionVersion) { return [Version]$SessionVersion }
    $sorted = @($InstalledVersions | Where-Object { $null -ne $_ } | Sort-Object -Descending)
    if ($sorted.Count -eq 0) { return $null }
    return $sorted[0]
}

function Get-AwsToolsMissingModuleNames {
    param(
        [Parameter(Mandatory)][string[]]$RequiredNames,
        [Version]$TargetVersion,
        [hashtable]$InstalledByName
    )

    $missing = [System.Collections.Generic.List[string]]::new()
    foreach ($name in $RequiredNames) {
        $versions = @()
        if ($InstalledByName -and $InstalledByName.ContainsKey($name)) {
            $versions = @($InstalledByName[$name])
        }
        $hasTarget = if ($TargetVersion) {
            @($versions | Where-Object { $_ -eq $TargetVersion }).Count -gt 0
        }
        else {
            $versions.Count -gt 0
        }
        if (-not $hasTarget) { $missing.Add($name) }
    }
    return [string[]]@($missing)
}

function Test-AwsToolsNeedsVersionSync {
    param(
        [Version]$SessionVersion,
        [string[]]$InstalledVersionLabels
    )

    if ($SessionVersion) { return $false }
    $unique = @($InstalledVersionLabels | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
    return $unique.Count -gt 1
}

function Resolve-AwsToolsModuleImportPath {
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][Version]$TargetVersion,
        [Parameter(Mandatory)][object[]]$Available
    )

    $match = @(
        $Available |
            Where-Object { $_.Name -eq $Name -and $_.Version -eq $TargetVersion -and $_.Path } |
            Select-Object -First 1
    )
    if ($match.Count -eq 0) { return $null }
    return [string]$match[0].Path
}

function Test-AwsToolsImportNeedsNewSession {
    param([string]$Message)

    if ([string]::IsNullOrWhiteSpace($Message)) { return $false }
    return $Message -match 'already loaded|AWSSDK\.Core|Assembly with same name'
}

function Get-AwsToolsSessionVersion {
    $common = Get-Module -Name 'AWS.Tools.Common'
    if ($common) { return $common.Version }
    $any = Get-Module | Where-Object { $_.Name -like 'AWS.Tools.*' } | Select-Object -First 1
    if ($any) { return $any.Version }
    return $null
}

function Get-AwsToolsInstalledVersionMap {
    param([Parameter(Mandatory)][string[]]$ModuleNames)

    $map = @{}
    foreach ($name in $ModuleNames) {
        $map[$name] = @(Get-Module -ListAvailable -Name $name | ForEach-Object { $_.Version })
    }
    return $map
}

function Install-AwsToolsModuleSet {
    param(
        [Parameter(Mandatory)][string[]]$ModuleNames,
        [Version]$Version
    )

    if (-not (Get-Module -ListAvailable -Name 'AWS.Tools.Installer')) {
        Write-Info 'Installing AWS.Tools.Installer ...'
        Install-Module -Name 'AWS.Tools.Installer' -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
    }
    Import-Module AWS.Tools.Installer -ErrorAction Stop

    $toInstall = @($ModuleNames | Where-Object { $_ -ne 'AWS.Tools.Common' })
    if ($toInstall.Count -eq 0) { $toInstall = @('AWS.Tools.SecurityToken') }

    $installParams = @{
        Name        = $toInstall
        Scope       = 'CurrentUser'
        ErrorAction = 'Stop'
    }
    if ($Version) {
        $installParams['Version'] = $Version.ToString()
        Write-Info "Installing $($toInstall -join ', ') version $Version (matching modules already loaded in this session) ..."
    }
    else {
        $installParams['Force'] = $true
        $installParams['Cleanup'] = $true
        Write-Info "Installing $($toInstall -join ', ') ..."
    }

    try {
        Install-AWSToolsModule @installParams | Out-Null
    }
    catch {
        if ($Version) {
            throw @(
                "Could not install $($toInstall -join ', ') at version $Version to match this PowerShell session."
                'Close this terminal and re-run AWS.ps1 so every AWS.Tools module can load at the same version.'
                $_.Exception.Message
            ) -join ' '
        }
        throw
    }
}

function Sync-AwsToolsModuleVersions {
    if (-not (Get-Module -ListAvailable -Name 'AWS.Tools.Installer')) {
        Write-Info 'Installing AWS.Tools.Installer ...'
        Install-Module -Name 'AWS.Tools.Installer' -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
    }
    Import-Module AWS.Tools.Installer -ErrorAction Stop
    Write-Info 'Synchronizing installed AWS.Tools module versions ...'
    Update-AWSToolsModule -Scope CurrentUser -Force -CleanUp -ErrorAction Stop | Out-Null
}

function Import-AwsToolsModuleSet {
    param([Parameter(Mandatory)][string[]]$ModuleNames)

    $ordered = @('AWS.Tools.Common') + @($ModuleNames | Where-Object { $_ -ne 'AWS.Tools.Common' })
    $sessionVersion = Get-AwsToolsSessionVersion
    $installedVersions = @(
        $ordered |
            ForEach-Object { Get-Module -ListAvailable -Name $_ | ForEach-Object { $_.Version } }
    )
    $targetVersion = Resolve-AwsToolsTargetVersion -SessionVersion $sessionVersion -InstalledVersions $installedVersions
    if (-not $targetVersion) {
        throw 'AWS.Tools.Common is not installed.'
    }

    $available = @(Get-Module -ListAvailable -Name $ordered)
    foreach ($name in $ordered) {
        $importPath = Resolve-AwsToolsModuleImportPath -Name $name -TargetVersion $targetVersion -Available $available
        if (-not $importPath) {
            throw "Module $name version $targetVersion is not installed. Re-run the script and allow AWS Tools installation."
        }

        $loaded = Get-Module -Name $name
        if ($loaded) {
            if ($loaded.Version -ne $targetVersion) {
                throw @(
                    "Module $name is already loaded at version $($loaded.Version) in this PowerShell session,"
                    "but version $targetVersion is required."
                    'Close this terminal and re-run AWS.ps1.'
                ) -join ' '
            }
            continue
        }

        try {
            Import-Module -Name $importPath -ErrorAction Stop
        }
        catch {
            $msg = [string]$_
            if (Test-AwsToolsImportNeedsNewSession -Message $msg) {
                throw @(
                    "Module $name version $targetVersion could not be loaded because another AWS.Tools assembly is already in this PowerShell process."
                    'Close this terminal and re-run AWS.ps1.'
                    $msg
                ) -join ' '
            }
            throw
        }
    }
}

function Ensure-AwsToolsModules {
    param([string[]]$ExtraModules = @())

    $moduleNames = Get-AwsToolsModuleNames -ExtraModules $ExtraModules
    $sessionVersion = Get-AwsToolsSessionVersion
    $installedByName = Get-AwsToolsInstalledVersionMap -ModuleNames $moduleNames
    $installedVersions = @(
        $moduleNames |
            ForEach-Object { @($installedByName[$_]) } |
            Where-Object { $null -ne $_ }
    )
    $installedVersionLabels = @(
        $moduleNames |
            ForEach-Object {
                $module = Get-Module -ListAvailable -Name $_ | Sort-Object Version -Descending | Select-Object -First 1
                if ($module) { $module.Version.ToString() }
            }
    )
    $targetVersion = Resolve-AwsToolsTargetVersion -SessionVersion $sessionVersion -InstalledVersions $installedVersions
    $missingOnDisk = @(Get-AwsToolsMissingModuleNames -RequiredNames $moduleNames -TargetVersion $targetVersion -InstalledByName $installedByName)
    $versionMismatchOnDisk = Test-AwsToolsNeedsVersionSync -SessionVersion $sessionVersion -InstalledVersionLabels $installedVersionLabels

    if ($missingOnDisk.Count -gt 0 -or $versionMismatchOnDisk) {
        Write-Step 'AWS Tools for PowerShell modules are required'
        if ($missingOnDisk.Count -gt 0) {
            foreach ($name in $missingOnDisk) { Write-Info $name }
        }
        if ($versionMismatchOnDisk) {
            Write-Info "Installed AWS.Tools versions differ ($(($installedVersionLabels | Select-Object -Unique) -join ', ')). All modules must match."
        }
        if (-not (Read-YesNo -Prompt 'Install or synchronize AWS Tools modules for the current user?' -Default $true)) {
            throw "Install the modules manually, then start a new PowerShell session: Install-Module AWS.Tools.Installer -Scope CurrentUser; Install-AWSToolsModule -Name $($moduleNames -join ', ') -Scope CurrentUser -Force -Cleanup"
        }
        if ($missingOnDisk.Count -gt 0) {
            Install-AwsToolsModuleSet -ModuleNames $missingOnDisk -Version $targetVersion
        }
        else {
            Sync-AwsToolsModuleVersions
        }
        if (-not $sessionVersion) {
            Write-Info 'AWS Tools modules updated. If the next step fails with an ''already loaded'' error, close this terminal and re-run AWS.ps1.'
        }
    }

    Import-AwsToolsModuleSet -ModuleNames $moduleNames
}

function Save-IQServicePayload {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$DownloadUri,
        [string]$ZipPath,
        [scriptblock]$GetZipPath,
        [scriptblock]$GetBuildFromPath,
        [scriptblock]$UnblockFiles
    )

    $destination = & $GetZipPath $InstallPath
    $sourcePath = $null

    if (-not [string]::IsNullOrWhiteSpace($ZipPath)) {
        if (-not (Test-Path -LiteralPath $ZipPath)) {
            throw "ZIP file not found: $ZipPath"
        }
        $sourcePath = (Resolve-Path -LiteralPath $ZipPath).Path
        if ($sourcePath -ne $destination) {
            Copy-Item -LiteralPath $sourcePath -Destination $destination -Force
            Write-Ok "Copied $sourcePath to $destination"
        }
        else {
            Write-Ok "Using ZIP at $destination"
        }
    }
    elseif (-not [string]::IsNullOrWhiteSpace($DownloadUri)) {
        Write-Step 'Downloading IQService.zip'
        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            Invoke-WebRequest -Uri $DownloadUri -OutFile $destination -UseBasicParsing
        }
        catch {
            $message = $_.Exception.Message
            if ($message -match '403|401|expired|Forbidden') {
                throw "Download failed. Pre-signed ISC download URLs expire quickly. Copy a fresh link from Connections > Sources > IQService Download. Details: $message"
            }
            throw "Download failed: $message"
        }
        Write-Ok "Saved to $destination"
        $sourcePath = $destination
    }
    else {
        throw 'Provide -DownloadUri or -ZipPath, or choose Download from the interactive menu.'
    }

    $build = & $GetBuildFromPath $destination
    if ($build) {
        Write-Ok "Detected build $build"
    }

    if ($UnblockFiles) {
        $unblock = & $UnblockFiles $InstallPath @($destination)
        if ($unblock.Unblocked -gt 0) {
            Write-Ok "Unblocked $($unblock.Unblocked) file(s) before extraction"
        }
    }

    return [PSCustomObject]@{
        ZipPath = $destination
        Build   = $build
    }
}

function Expand-IQServicePayload {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$ZipPath,
        [scriptblock]$GetZipPath,
        [scriptblock]$UnblockFiles
    )

    if ([string]::IsNullOrWhiteSpace($ZipPath)) {
        $ZipPath = & $GetZipPath $InstallPath
    }

    if (-not (Test-Path -LiteralPath $ZipPath)) {
        throw "ZIP file not found: $ZipPath. Run Download first or pass -ZipPath."
    }

    if (-not (Test-Path -LiteralPath $InstallPath)) {
        New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
    }

    Write-Step "Extracting $ZipPath"
    Expand-Archive -LiteralPath $ZipPath -DestinationPath $InstallPath -Force
    Write-Ok "Extracted to $InstallPath"

    if ($UnblockFiles) {
        $unblock = & $UnblockFiles $InstallPath
        Write-Ok "Unblocked $($unblock.Unblocked) of $($unblock.Checked) executable file(s)"
    }
}

Export-ModuleMember -Function @(
    'Set-OperatorToolchainPackageManager'
    'Get-OperatorPlatform'
    'Test-WingetAvailable'
    'Test-HomebrewAvailable'
    'Invoke-PlatformPackageInstall'
    'Resolve-CliCommand'
    'Ensure-CliCommand'
    'Ensure-GCloudCommand'
    'Ensure-AzCliCommand'
    'Ensure-OpenSslCommand'
    'Install-PowerShellGalleryModuleSet'
    'Import-PowerShellGalleryModuleSet'
    'Ensure-MicrosoftGraphModules'
    'Get-AwsToolsModuleNames'
    'Resolve-AwsToolsTargetVersion'
    'Get-AwsToolsMissingModuleNames'
    'Test-AwsToolsNeedsVersionSync'
    'Resolve-AwsToolsModuleImportPath'
    'Test-AwsToolsImportNeedsNewSession'
    'Ensure-AwsToolsModules'
    'Save-IQServicePayload'
    'Expand-IQServicePayload'
)