src/public/Deployment/New-AitherWindowsISO.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Build a custom Windows Server ISO and deploy AitherOS nodes via OpenTofu + Hyper-V.

.DESCRIPTION
    End-to-end pipeline that:
      1. Builds a custom Windows Server 2025 Core ISO (via 3105_Build-WindowsISO.ps1)
      2. Provisions Hyper-V VMs using OpenTofu (modules/aitheros-node)
      3. Waits for VMs to install and WinRM to become available
      4. Triggers post-install configuration via the Elysium pipeline
      5. Joins nodes to the AitherMesh

    This is the single command to go from stock ISO → running AitherOS node.

.PARAMETER SourceISO
    Path to the stock Windows Server 2025 ISO. Required if -SkipISOBuild is not set.

.PARAMETER ISOPath
    Path to a pre-built custom AitherOS ISO. If provided, skips ISO build.

.PARAMETER NodeName
    Name(s) for the VM(s) to create. Default: aither-node-01

.PARAMETER NodeCount
    Number of identical nodes to create (alternative to NodeName array).

.PARAMETER Profile
    AitherOS deployment profile. Default: Core

.PARAMETER CpuCount
    Virtual CPUs per node. Default: 4

.PARAMETER MemoryGB
    Startup memory in GB. Default: 4

.PARAMETER DiskGB
    System disk size in GB. Default: 80

.PARAMETER SwitchName
    Hyper-V virtual switch name. Default: AitherSwitch

.PARAMETER SwitchType
    Virtual switch type (Internal, External, Private). Default: Internal

.PARAMETER VhdPath
    Directory for VHD storage. Default: C:\VMs\AitherOS

.PARAMETER AdminPassword
    Administrator password for the nodes. Auto-generated if omitted.

.PARAMETER MeshCoreUrl
    MeshCore endpoint for auto-join. Default: http://192.168.1.100:8125

.PARAMETER SkipISOBuild
    Skip ISO build (use existing ISO at -ISOPath).

.PARAMETER SkipTofuApply
    Skip OpenTofu apply (just build ISO and generate configs).

.PARAMETER SkipPostInstall
    Skip waiting for WinRM and post-install configuration.

.PARAMETER TofuAutoApprove
    Auto-approve OpenTofu apply without interactive confirmation.

.PARAMETER WaitTimeoutMinutes
    Minutes to wait for VM installation to complete. Default: 20

.PARAMETER DryRun
    Show what would happen without executing.

.PARAMETER Force
    Overwrite existing ISO, destroy existing VMs.

.PARAMETER PassThru
    Return pipeline result object instead of formatted output.

.EXAMPLE
    New-AitherWindowsISO -SourceISO 'C:\ISOs\Server2025.iso'

    Builds ISO, creates one VM, waits for install, configures node.

.EXAMPLE
    New-AitherWindowsISO -SourceISO 'D:\ISOs\26100.iso' -NodeCount 2 -Profile Full -TofuAutoApprove

    Builds ISO and deploys 2 Full-profile nodes without confirmation prompts.

.EXAMPLE
    New-AitherWindowsISO -ISOPath 'C:\ISOs\AitherOS-Server2025-Core.iso' -SkipISOBuild -NodeName 'gpu-node-01' -CpuCount 8 -MemoryGB 16

    Uses pre-built ISO to deploy a high-spec GPU node.

.OUTPUTS
    PSCustomObject with pipeline results (if -PassThru), or formatted console output.

.NOTES
    Prerequisites:
      - Windows ADK (oscdimg.exe) — for ISO build
      - OpenTofu >= 1.6.0 — for VM provisioning
      - Hyper-V role enabled on the host
      - Administrator privileges
#>

function New-AitherWindowsISO {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(ParameterSetName = 'BuildISO')]
        [string]$SourceISO,

        [string]$ISOPath,

        [string[]]$NodeName = @('aither-node-01'),

        [int]$NodeCount = 0,

        [ValidateSet('Full', 'Core', 'Minimal', 'GPU', 'Edge')]
        [string]$Profile = 'Core',

        [int]$CpuCount = 4,

        [int]$MemoryGB = 4,

        [int]$DiskGB = 80,

        [string]$SwitchName = 'AitherSwitch',

        [ValidateSet('Internal', 'External', 'Private')]
        [string]$SwitchType = 'Internal',

        [string]$VhdPath = 'C:\VMs\AitherOS',

        [securestring]$AdminPassword,

        [string]$MeshCoreUrl = 'http://192.168.1.100:8125',

        [switch]$SkipISOBuild,

        [switch]$SkipTofuApply,

        [switch]$SkipPostInstall,

        [switch]$TofuAutoApprove,

        [int]$WaitTimeoutMinutes = 20,

        [switch]$DryRun,

        [switch]$Force,

        [switch]$PassThru
    )

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

    $moduleRoot = if (Get-Command Get-AitherModuleRoot -ErrorAction SilentlyContinue) {
        Get-AitherModuleRoot
    }
    else {
        (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path
    }

    $azRoot = Join-Path $moduleRoot 'AitherZero'
    $scriptsDir = Join-Path $azRoot 'library\automation-scripts\31-remote'
    $infraDir = Join-Path $azRoot 'library\infrastructure\environments\local-hyperv'

    # Build node list
    if ($NodeCount -gt 0) {
        $NodeName = 1..$NodeCount | ForEach-Object { "aither-node-$('{0:D2}' -f $_)" }
    }

    $result = [PSCustomObject]@{
        PSTypeName     = 'AitherOS.ISODeployPipeline'
        Timestamp      = Get-Date -Format 'o'
        SourceISO      = $SourceISO
        CustomISO      = $ISOPath
        Nodes          = $NodeName
        Profile        = $Profile
        Phases         = [ordered]@{}
        OverallStatus  = 'NotStarted'
    }

    Write-Host "`n╔════════════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host "║ AitherOS ISO → Deploy Pipeline ║" -ForegroundColor Cyan
    Write-Host "╚════════════════════════════════════════════╝" -ForegroundColor Cyan
    Write-Host " Nodes: $($NodeName -join ', ')"
    Write-Host " Profile: $Profile"
    Write-Host " Switch: $SwitchName ($SwitchType)"
    Write-Host " Specs: ${CpuCount}vCPU, ${MemoryGB}GB RAM, ${DiskGB}GB disk"
    Write-Host ""

    # ═══════════════════════════════════════════
    # Phase 0: Auto-Resolve Prerequisites
    # ═══════════════════════════════════════════
    Write-Host "━━━ Phase 0: Prerequisite Resolution ━━━" -ForegroundColor Yellow

    $prereqScope = if ($SkipISOBuild -and $SkipTofuApply) { 'Validate' }
                   elseif ($SkipISOBuild) { 'Deploy' }
                   elseif ($SkipTofuApply) { 'ISO' }
                   else { 'All' }

    if (Get-Command Resolve-AitherInfraPrereqs -ErrorAction SilentlyContinue) {
        $prereqResult = Resolve-AitherInfraPrereqs -Scope $prereqScope -AutoInstall $true -PassThru
        $result.Phases['Prerequisites'] = @{
            Status         = if ($prereqResult.AllSatisfied) { 'Success' } elseif ($prereqResult.RebootRequired) { 'RebootRequired' } else { 'PartialFailure' }
            Results        = $prereqResult.Results
            RebootRequired = $prereqResult.RebootRequired
        }

        if ($prereqResult.RebootRequired -and -not $SkipTofuApply) {
            Write-Warning "Hyper-V requires a reboot before VMs can be created."
            Write-Host " You can build the ISO now and deploy after reboot:" -ForegroundColor Cyan
            Write-Host " New-AitherWindowsISO -ISOPath <path> -SkipISOBuild" -ForegroundColor Cyan
            if (-not $SkipISOBuild) {
                Write-Host " Continuing with ISO build only..." -ForegroundColor Yellow
                $SkipTofuApply = $true
                $SkipPostInstall = $true
            }
            else {
                $result.OverallStatus = 'RebootRequired'
                if ($PassThru) { return $result }
                return
            }
        }
    }
    else {
        # Fallback: lightweight inline prereq checks
        Write-Host " (Resolve-AitherInfraPrereqs not loaded — running inline checks)" -ForegroundColor Gray
        $infraScriptsDir = Join-Path $azRoot 'library\automation-scripts\01-infrastructure'

        # Check ADK
        if (-not $SkipISOBuild) {
            $oscdimgFound = $env:OSCDIMG_PATH -and (Test-Path $env:OSCDIMG_PATH)
            if (-not $oscdimgFound) {
                $adkPaths = @(
                    "${env:ProgramFiles(x86)}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe",
                    "${env:ProgramFiles}\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe"
                )
                foreach ($p in $adkPaths) { if (Test-Path $p) { $oscdimgFound = $true; break } }
            }
            if (-not $oscdimgFound) {
                $adkScript = Join-Path $infraScriptsDir '0101_Install-WindowsADK.ps1'
                if (Test-Path $adkScript) {
                    Write-Host " Installing Windows ADK..." -ForegroundColor Yellow
                    try { & $adkScript -ErrorAction Stop } catch { Write-Warning "ADK install failed: $($_.Exception.Message)" }
                }
            }
        }

        # Check OpenTofu
        if (-not $SkipTofuApply -and -not (Get-Command tofu -ErrorAction SilentlyContinue) -and -not (Get-Command terraform -ErrorAction SilentlyContinue)) {
            $tofuScript = Join-Path $infraScriptsDir '0102_Install-OpenTofu.ps1'
            if (Test-Path $tofuScript) {
                Write-Host " Installing OpenTofu..." -ForegroundColor Yellow
                try { & $tofuScript -IncludeHyperVProvider -ErrorAction Stop } catch { Write-Warning "OpenTofu install failed: $($_.Exception.Message)" }
            }
        }

        # Check Hyper-V
        if (-not $SkipTofuApply) {
            $hvScript = Join-Path $infraScriptsDir '0105_Enable-HyperV.ps1'
            if (Test-Path $hvScript) {
                try {
                    & $hvScript -SkipRebootCheck -ErrorAction Stop
                    if ($LASTEXITCODE -eq 200) {
                        Write-Warning "Hyper-V enabled but reboot required. ISO build will continue."
                        $SkipTofuApply = $true
                        $SkipPostInstall = $true
                    }
                }
                catch { Write-Warning "Hyper-V check failed: $($_.Exception.Message)" }
            }
        }

        $result.Phases['Prerequisites'] = @{ Status = 'InlineCheck' }
    }

    Write-Host ""

    if ($DryRun) {
        Write-Host "[DRY RUN] Pipeline steps:" -ForegroundColor Yellow
        Write-Host " 0. Auto-resolve prerequisites (ADK, OpenTofu, Hyper-V)"
        if (-not $SkipISOBuild) { Write-Host " 1. Build custom ISO from $SourceISO" }
        Write-Host " 2. Generate OpenTofu config for $($NodeName.Count) node(s)"
        if (-not $SkipTofuApply) { Write-Host " 3. tofu init + apply → create Hyper-V VMs" }
        if (-not $SkipPostInstall) { Write-Host " 4. Wait for WinRM ($WaitTimeoutMinutes min timeout)" }
        if (-not $SkipPostInstall) { Write-Host " 5. Post-install: Docker, mesh join, services" }
        $result.OverallStatus = 'DryRun'
        if ($PassThru) { return $result }
        return
    }

    # ═══════════════════════════════════════════
    # Phase 1: Build Custom ISO
    # ═══════════════════════════════════════════
    if (-not $SkipISOBuild) {
        Write-Host "`n━━━ Phase 1: Build Custom ISO ━━━" -ForegroundColor Yellow

        if (-not $SourceISO) {
            throw "SourceISO is required when not using -SkipISOBuild. Provide a stock Windows Server 2025 ISO path."
        }

        $isoScript = Join-Path $scriptsDir '3105_Build-WindowsISO.ps1'
        if (-not (Test-Path $isoScript)) {
            throw "ISO builder script not found: $isoScript"
        }

        $isoArgs = @{
            SourceISO     = $SourceISO
            ComputerName  = $NodeName[0]  # First node name as template
            NodeProfile   = $Profile
            MeshCoreUrl   = $MeshCoreUrl
        }
        if ($AdminPassword) { $isoArgs.AdminPassword = $AdminPassword }
        if ($Force) { $isoArgs.Force = $true }

        try {
            & $isoScript @isoArgs
            # Detect the output ISO
            $outputDir = 'C:\ISOs'  # Default from script
            $ISOPath = Join-Path $outputDir 'AitherOS-Server2025-Core.iso'
            if (-not (Test-Path $ISOPath)) {
                throw "ISO build completed but output not found at $ISOPath"
            }
            $result.CustomISO = $ISOPath
            $result.Phases['ISOBuild'] = @{ Status = 'Success'; Path = $ISOPath }
            Write-Host " ISO ready: $ISOPath" -ForegroundColor Green
        }
        catch {
            $result.Phases['ISOBuild'] = @{ Status = 'Failed'; Error = $_.Exception.Message }
            $result.OverallStatus = 'Failed'
            throw "ISO build failed: $($_.Exception.Message)"
        }
    }
    else {
        if (-not $ISOPath -or -not (Test-Path $ISOPath)) {
            throw "ISOPath '$ISOPath' not found. Either provide a valid path or remove -SkipISOBuild."
        }
        $result.Phases['ISOBuild'] = @{ Status = 'Skipped'; Path = $ISOPath }
        Write-Host " Using existing ISO: $ISOPath" -ForegroundColor Gray
    }

    # ═══════════════════════════════════════════
    # Phase 2: Generate OpenTofu Configuration
    # ═══════════════════════════════════════════
    Write-Host "`n━━━ Phase 2: Generate OpenTofu Config ━━━" -ForegroundColor Yellow

    # Ensure infrastructure directory exists
    if (-not (Test-Path $infraDir)) {
        New-Item -Path $infraDir -ItemType Directory -Force | Out-Null
    }

    # Generate terraform.tfvars dynamically
    $nodesDef = $NodeName | ForEach-Object -Begin { $i = 0 } -Process {
        $i++
        @"
  {
    name = "$_"
    profile = "$Profile"
    cpu_count = $CpuCount
    memory_gb = $MemoryGB
    disk_gb = $DiskGB
    failover_priority = $($i * 5)
    mesh_role = "standby"
  },
"@

    }

    $tfvarsContent = @"
# Auto-generated by New-AitherWindowsISO — $(Get-Date -Format 'o')

iso_path = "$($ISOPath -replace '\\','/')"

hyperv_host = "localhost"
hyperv_port = 5985
hyperv_https = false

switch_name = "$SwitchName"
switch_type = "$SwitchType"

vhd_path = "$($VhdPath -replace '\\','/')"

default_cpu_count = $CpuCount
default_memory_gb = $MemoryGB
default_memory_min_gb = 2
default_memory_max_gb = $([Math]::Max($MemoryGB * 2, 8))
default_disk_gb = $DiskGB

nodes = [
$($nodesDef -join "`n")
]
"@


    $tfvarsPath = Join-Path $infraDir 'terraform.tfvars'
    $tfvarsContent | Set-Content $tfvarsPath -Encoding UTF8
    Write-Host " Generated: $tfvarsPath" -ForegroundColor Green

    $result.Phases['TofuConfig'] = @{ Status = 'Success'; Path = $tfvarsPath }

    if ($SkipTofuApply) {
        Write-Host " Skipping tofu apply (--SkipTofuApply)" -ForegroundColor Gray
        $result.Phases['TofuApply'] = @{ Status = 'Skipped' }
        $result.OverallStatus = 'ConfigOnly'
        if ($PassThru) { return $result }
        return
    }

    # ═══════════════════════════════════════════
    # Phase 3: OpenTofu Init + Apply
    # ═══════════════════════════════════════════
    Write-Host "`n━━━ Phase 3: OpenTofu Apply ━━━" -ForegroundColor Yellow

    # Check for tofu/terraform
    $tofuCmd = if (Get-Command tofu -ErrorAction SilentlyContinue) { 'tofu' }
               elseif (Get-Command terraform -ErrorAction SilentlyContinue) { 'terraform' }
               else {
                   # Try auto-install as last resort
                   $tofuInstaller = Join-Path $azRoot 'library\automation-scripts\01-infrastructure\0102_Install-OpenTofu.ps1'
                   if (Test-Path $tofuInstaller) {
                       Write-Host " OpenTofu not found — auto-installing..." -ForegroundColor Yellow
                       & $tofuInstaller -IncludeHyperVProvider -ErrorAction SilentlyContinue
                       # Refresh PATH
                       $machinePath = [Environment]::GetEnvironmentVariable('PATH', 'Machine')
                       $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
                       $env:PATH = "$machinePath;$userPath"
                   }
                   if (Get-Command tofu -ErrorAction SilentlyContinue) { 'tofu' }
                   elseif (Get-Command terraform -ErrorAction SilentlyContinue) { 'terraform' }
                   else { throw "Neither 'tofu' nor 'terraform' found in PATH after auto-install attempt. Run: .\01-infrastructure\0102_Install-OpenTofu.ps1" }
               }

    Push-Location $infraDir
    try {
        Write-Host " Running $tofuCmd init..."
        $initOutput = & $tofuCmd init -no-color 2>&1 | Out-String
        if ($LASTEXITCODE -ne 0) { throw "tofu init failed:`n$initOutput" }
        Write-Host " Init complete" -ForegroundColor Green

        Write-Host " Running $tofuCmd plan..."
        $planOutput = & $tofuCmd plan -no-color 2>&1 | Out-String
        Write-Host $planOutput

        $applyArgs = @('apply', '-no-color')
        if ($TofuAutoApprove) { $applyArgs += '-auto-approve' }

        Write-Host " Running $tofuCmd apply..."
        $applyOutput = & $tofuCmd @applyArgs 2>&1 | Out-String
        if ($LASTEXITCODE -ne 0) { throw "tofu apply failed:`n$applyOutput" }

        Write-Host " VMs provisioned!" -ForegroundColor Green
        $result.Phases['TofuApply'] = @{ Status = 'Success'; Output = $applyOutput }
    }
    catch {
        $result.Phases['TofuApply'] = @{ Status = 'Failed'; Error = $_.Exception.Message }
        $result.OverallStatus = 'Failed'
        throw
    }
    finally {
        Pop-Location
    }

    if ($SkipPostInstall) {
        $result.OverallStatus = 'VMsProvisioned'
        Write-Host "`n VMs created. Skipping post-install (--SkipPostInstall)" -ForegroundColor Gray
        if ($PassThru) { return $result }
        return
    }

    # ═══════════════════════════════════════════
    # Phase 4: Wait for VM Installation
    # ═══════════════════════════════════════════
    Write-Host "`n━━━ Phase 4: Waiting for VMs to Install ━━━" -ForegroundColor Yellow
    Write-Host " VMs are booting from ISO and installing Windows Server 2025 Core."
    Write-Host " This typically takes 10-15 minutes. Timeout: $WaitTimeoutMinutes min."

    $deadline = (Get-Date).AddMinutes($WaitTimeoutMinutes)
    $readyNodes = @{}

    while ((Get-Date) -lt $deadline -and $readyNodes.Count -lt $NodeName.Count) {
        foreach ($node in $NodeName) {
            if ($readyNodes.ContainsKey($node)) { continue }

            # Try to detect the VM's IP via Hyper-V
            try {
                $vmNet = Get-VM -Name $node -ErrorAction SilentlyContinue |
                    Get-VMNetworkAdapter |
                    Where-Object { $_.IPAddresses.Count -gt 0 } |
                    Select-Object -First 1
                $ip = $vmNet.IPAddresses | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } | Select-Object -First 1

                if ($ip) {
                    # Test WinRM
                    $wsmanTest = Test-WSMan -ComputerName $ip -ErrorAction SilentlyContinue
                    if ($wsmanTest) {
                        $readyNodes[$node] = $ip
                        Write-Host " $node ($ip) — WinRM ready!" -ForegroundColor Green
                    }
                }
            }
            catch {
                # Not ready yet, continue polling
            }
        }

        if ($readyNodes.Count -lt $NodeName.Count) {
            $remaining = $NodeName | Where-Object { -not $readyNodes.ContainsKey($_) }
            $timeLeft = [math]::Round(($deadline - (Get-Date)).TotalMinutes, 1)
            Write-Host " Waiting... ($($remaining.Count) node(s) pending, ${timeLeft}m remaining)" -ForegroundColor Gray
            Start-Sleep -Seconds 30
        }
    }

    if ($readyNodes.Count -eq 0) {
        Write-Warning "No nodes became available within $WaitTimeoutMinutes minutes."
        Write-Host " VMs may still be installing. Check Hyper-V Manager." -ForegroundColor Yellow
        Write-Host " When ready, run: Invoke-AitherNodeDeploy -ComputerName <IP> -Action Deploy -SkipBootstrap"
        $result.Phases['WaitForInstall'] = @{ Status = 'Timeout'; ReadyNodes = @{} }
        $result.OverallStatus = 'PartialTimeout'
        if ($PassThru) { return $result }
        return
    }

    $result.Phases['WaitForInstall'] = @{ Status = 'Success'; ReadyNodes = $readyNodes }

    # ═══════════════════════════════════════════
    # Phase 5: Post-Install Configuration
    # ═══════════════════════════════════════════
    Write-Host "`n━━━ Phase 5: Post-Install Configuration ━━━" -ForegroundColor Yellow

    $postResults = @{}
    foreach ($node in $readyNodes.GetEnumerator()) {
        Write-Host " Configuring $($node.Key) at $($node.Value)..."
        try {
            # The first-boot script already did WinRM + Docker + mesh join.
            # Use Invoke-AitherNodeDeploy for service deployment.
            if (Get-Command Invoke-AitherNodeDeploy -ErrorAction SilentlyContinue) {
                Invoke-AitherNodeDeploy -ComputerName $node.Value -Action Deploy -SkipBootstrap -PassThru
            }
            else {
                # Fallback: call the deployment script directly
                $deployScript = Join-Path $scriptsDir '3101_Deploy-RemoteNode.ps1'
                if (Test-Path $deployScript) {
                    & $deployScript -ComputerName $node.Value -Profile $Profile
                }
            }
            $postResults[$node.Key] = 'Success'
            Write-Host " $($node.Key) — configured" -ForegroundColor Green
        }
        catch {
            $postResults[$node.Key] = "Failed: $($_.Exception.Message)"
            Write-Warning "$($node.Key) post-install failed: $($_.Exception.Message)"
        }
    }

    $result.Phases['PostInstall'] = @{ Status = 'Success'; Results = $postResults }

    # ═══════════════════════════════════════════
    # Summary
    # ═══════════════════════════════════════════
    $allSuccess = ($postResults.Values | Where-Object { $_ -ne 'Success' }).Count -eq 0
    $result.OverallStatus = if ($allSuccess) { 'Complete' } else { 'PartialFailure' }

    Write-Host "`n╔════════════════════════════════════════════╗" -ForegroundColor ($allSuccess ? 'Green' : 'Yellow')
    Write-Host "║ Pipeline $($result.OverallStatus)$((' ' * (33 - $result.OverallStatus.Length)))║" -ForegroundColor ($allSuccess ? 'Green' : 'Yellow')
    Write-Host "╚════════════════════════════════════════════╝" -ForegroundColor ($allSuccess ? 'Green' : 'Yellow')

    foreach ($node in $readyNodes.GetEnumerator()) {
        $status = $postResults[$node.Key]
        $icon = if ($status -eq 'Success') { '[OK]' } else { '[!!]' }
        Write-Host " $icon $($node.Key) → $($node.Value) ($status)"
    }

    $notReady = $NodeName | Where-Object { -not $readyNodes.ContainsKey($_) }
    foreach ($n in $notReady) {
        Write-Host " [--] $n → not ready (still installing)" -ForegroundColor Gray
    }

    Write-Host ""
    Write-Host " Check mesh: Get-AitherMeshStatus -Action Status" -ForegroundColor Cyan
    Write-Host " Infra view: Get-AitherInfraStatus -IncludeReplication -IncludeContainers" -ForegroundColor Cyan
    Write-Host ""

    if ($PassThru) { return $result }
}