#Requires -Version 5.1
<#
.SYNOPSIS
    SilverMetal Enhanced - Windows : custom packed ISO build pipeline.

.DESCRIPTION
    Turns an official Windows 11 IoT Enterprise LTSC ISO (an INPUT, never
    redistributed) plus the SilverMetal config layer into a hardened, branded,
    UEFI-bootable ISO with an SBOM + SHA-256 attestation.

    Design: ../iso-builder.md      Controls: ../hardening-spec.md

    Build host requirement: Windows x64 + Windows ADK (oscdimg) + DISM (built in).
    Must run ELEVATED (DISM image mount requires admin).

.NOTES
    M2 implementation. Validated by running on the silverlabs-runner-win Gitea
    runner (this repo cannot be built on the ARM64 dev box). Offline policy
    baking is intentionally minimal here -- the §A-H hardening runs at first
    boot via SetupComplete.cmd; this pipeline injects drivers, debloat, the
    hardening payload, the answer file, then repacks + attests.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string] $SourceIso,
    [string] $Manifest   = "$PSScriptRoot\inputs.manifest.json",
    [string] $WorkDir    = "$env:TEMP\silvermetal-build",
    [string] $OutputIso  = "$PSScriptRoot\out\SilverMetal-Enhanced-Windows.iso",
    [string] $WindowsDir = "$PSScriptRoot\..",           # repo windows/ root
    [switch] $SkipInputVerify
)

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

function Write-Stage { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan }
function Resolve-Tool {
    param([string]$Name,[string[]]$Globs)
    $c = Get-Command $Name -EA SilentlyContinue
    if ($c) { return $c.Source }
    foreach ($g in $Globs) { $p = Get-ChildItem $g -EA SilentlyContinue | Select-Object -First 1; if ($p) { return $p.FullName } }
    throw "$Name not found. Install the Windows ADK Deployment Tools."
}

if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
        ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw 'build.ps1 must run elevated (DISM image servicing requires admin).'
}

$oscdimg = Resolve-Tool 'oscdimg.exe' @(
    'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\*\Oscdimg\oscdimg.exe')
$m       = Get-Content $Manifest -Raw | ConvertFrom-Json
$isoRoot = Join-Path $WorkDir 'iso'      # writable copy of ISO contents
$mount   = Join-Path $WorkDir 'mount'    # install.wim mount point
$bootmnt = Join-Path $WorkDir 'bootmnt'  # boot.wim mount point

# --- 0. Discard stale state from a prior interrupted build -----------------
# An aborted run can leave a DISM image mounted (locking install.wim/boot.wim)
# or registry hives loaded, which breaks the Stage 2 extract clean-up with
# "the process cannot access the file ... because it is being used by another
# process". Discard anything of ours before (re)creating the work dirs. Match
# by 'silvermetal' so orphans from any prior WorkDir are cleaned too.
Write-Stage 'Stage 0: discard stale SilverMetal image mounts / hives from prior runs'
Get-WindowsImage -Mounted -ErrorAction SilentlyContinue |
    Where-Object { $_.ImagePath -match 'silvermetal' -or $_.MountPath -match 'silvermetal' } |
    ForEach-Object {
        Write-Host "   discarding stale mount: $($_.MountPath)"
        Dismount-WindowsImage -Path $_.MountPath -Discard -ErrorAction SilentlyContinue | Out-Null
    }
foreach ($h in 'SM_BRAND_SW','SM_BRAND_DU','SM_OFFLINE','SM_BOOT') { & reg unload "HKLM\$h" 2>$null | Out-Null }
if (Test-Path $WorkDir) { Remove-Item $WorkDir -Recurse -Force -ErrorAction SilentlyContinue }

$null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,$bootmnt,(Split-Path $OutputIso)

# --- 1. Verify input -------------------------------------------------------
function Invoke-VerifyInput {
    Write-Stage 'Stage 1: verify input ISO against pinned manifest'
    $expected = $m.baseImage.isoSha256
    if ($SkipInputVerify -or $expected -like 'TODO*') {
        $actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash
        Write-Warning "Input hash not pinned. Observed SHA-256: $actual (record in inputs.manifest.json)."
        return
    }
    $actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash
    if ($actual -ne $expected) { throw "Source ISO SHA-256 mismatch.`n expected: $expected`n actual:   $actual" }
    Write-Host '   ISO hash verified.'
}

# --- 2. Extract ------------------------------------------------------------
function Invoke-Extract {
    Write-Stage 'Stage 2: extract ISO to writable work dir'
    if (Test-Path $isoRoot) { Remove-Item $isoRoot -Recurse -Force }
    $null = New-Item -ItemType Directory -Force $isoRoot
    $img = Mount-DiskImage -ImagePath $SourceIso -PassThru
    try {
        $drive = ($img | Get-Volume).DriveLetter + ':'
        Write-Host "   mounted at $drive ; copying..."
        Copy-Item "$drive\*" $isoRoot -Recurse -Force
    } finally { Dismount-DiskImage -ImagePath $SourceIso | Out-Null }
    attrib -r "$isoRoot\*" /s /d 2>$null    # clear read-only carried from the ISO
}

# --- 2b. Force legacy Setup (bypass the 24H2 "ConX" front-end) -------------
function Invoke-ForceLegacySetup {
    Write-Stage 'Stage 2b: force legacy Setup (patch sources\boot.wim)'
    # Win11 24H2/25H2 ship a redesigned WinPE Setup front-end (setup.exe ->
    # SetupHost.exe -> SetupPrep.exe) that IGNORES the windowsPE pass of
    # autounattend.xml (manual language/keyboard/region prompts). No answer-file
    # element toggles it; the verified fix is to point WinPE's shell at the
    # LEGACY installer via HKLM\SYSTEM\Setup\CmdLine in boot.wim index 2 (the
    # Setup image). The legacy engine consumes all four passes -> hands-off.
    $bootwim = Join-Path $isoRoot 'sources\boot.wim'
    if (-not (Test-Path $bootwim)) { throw "boot.wim not found: $bootwim" }
    Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bootmnt | Out-Null
    try {
        # Inject the answer file into the WinPE image at a fixed X: path, and launch
        # legacy setup with an EXPLICIT /unattend -- the implicit media search is
        # unreliable when setup is launched via the CmdLine override (legacy Setup
        # otherwise still shows the language page).
        Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force
        # Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE.
        $adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
        foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
            $cab = Join-Path $adk $oc
            if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null; Write-Host "   added WinPE OC: $oc" }
            else { Write-Warning "   WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on; boot.wim assertions will fail without it)" }
        }
        # Stage the collector + winpeshl so WinPE launches it instead of Setup.
        $smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
        Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
        Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
        Write-Host "   staged collector to boot.wim \sm\ + winpeshl.ini"
        # Setup\CmdLine is the WinPE setup-image shell launch and is AUTHORITATIVE over
        # winpeshl.ini -- point it at the SilverMetal collector so the pre-config UI runs
        # FIRST. The collector then launches the LEGACY setup.exe itself (X:\sources\setup.exe,
        # preserving the legacy-Setup bypass) with its generated answer file, or falls back to
        # the default autounattend.xml on cancel/error. (Pointing Setup\CmdLine straight at
        # setup.exe bypassed the collector entirely -- it won over winpeshl.ini.)
        $cmdline = "cmd /c X:\sm\Start-Collector.cmd"
        $hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
        & reg load 'HKLM\SM_BOOT' $hive | Out-Null
        try {
            & reg add 'HKLM\SM_BOOT\Setup' /v CmdLine /t REG_SZ /d $cmdline /f | Out-Null
            Write-Host "   WinPE Setup\CmdLine = $cmdline (legacy Setup + explicit unattend)"
        } finally {
            [gc]::Collect(); Start-Sleep -Seconds 2
            & reg unload 'HKLM\SM_BOOT' | Out-Null
        }
    } finally {
        Dismount-WindowsImage -Path $bootmnt -Save | Out-Null
    }
}

# --- 3b. Publish Welcome app (runs before WIM mount; no mount needed) --------
function Invoke-PublishWelcome {
    if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') {
        Write-Host '   SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome app publish.' -ForegroundColor Yellow
        return
    }
    Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)'
    $proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
    $out  = Join-Path $WorkDir 'welcome-publish'
    & dotnet publish $proj -c Release -f net9.0-windows10.0.19041.0 -r win-x64 --self-contained true -o $out
    if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' }
    Write-Host "   Published to: $out"
}

# --- 3c. Copy Welcome payload into mounted WIM (called inside Invoke-ServiceWim) ---
function Copy-WelcomePayload {
    if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') {
        Write-Host '   SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome payload copy.' -ForegroundColor Yellow
        return
    }
    Write-Stage 'Stage 3c: stage SilverOS Welcome app + flavours into mounted image'
    $out = Join-Path $WorkDir 'welcome-publish'
    if (-not (Test-Path $out)) { throw "Welcome publish output not found at '$out' -- did Invoke-PublishWelcome succeed?" }
    # Destination: C:\Program Files\SilverOS\Welcome (+ flavours subdir)
    $dest        = Join-Path $mount 'Program Files\SilverOS\Welcome'
    $destFlavours = Join-Path $dest 'flavours'
    $null = New-Item -ItemType Directory -Force $dest, $destFlavours
    # Copy published app (SilverOS.Welcome.App.exe + all runtime deps)
    Copy-Item "$out\*" $dest -Recurse -Force
    # Copy flavour definitions (*.json)
    $flavoursDir = Join-Path $WindowsDir 'flavours'
    $flavourFiles = Get-ChildItem $flavoursDir -Filter '*.json' -EA SilentlyContinue
    if ($flavourFiles) {
        Copy-Item $flavourFiles.FullName $destFlavours -Force
        Write-Host "   Copied $($flavourFiles.Count) flavour(s) to $destFlavours"
    } else {
        Write-Warning "   No *.json flavour files found in $flavoursDir -- image will ship with no flavours."
    }
    # Stage the app catalog + configure/bootstrap scripts next to the Welcome app
    # (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1.
    $appsDest = Join-Path $dest 'apps'
    $null = New-Item -ItemType Directory -Force $appsDest
    $appsDir = Join-Path $WindowsDir 'apps'
    if (Test-Path $appsDir) {
        Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force
        Write-Host "   Copied app catalog + scripts to $appsDest"
    } else {
        Write-Warning "   No apps dir found at $appsDir -- image will ship with no app catalog."
    }
    # Stage the fixed-version WebView2 runtime, if vendored, next to the app.
    # Cold-start + air-gap: a fixed-version runtime is just files (no installer
    # step at first boot) and removes the dependency on whether IoT Enterprise LTSC
    # ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\
    # with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the
    # folder that contains msedgewebview2.exe) -- handled like the drivers dir:
    # absent is allowed (VM/dev test), in which case the app falls back to Evergreen.
    $wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2'
    if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) {
        $wv2Dest = Join-Path $dest 'webview2'
        $null = New-Item -ItemType Directory -Force $wv2Dest
        Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force
        Write-Host "   Staged fixed-version WebView2 runtime to $wv2Dest"
    } else {
        Write-Warning "   No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md."
    }
    # --- Guard: verify the payload actually landed in the mounted image -------
    $stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
    if (-not (Test-Path $stagedExe)) {
        throw "Welcome bake failed: SilverOS.Welcome.App.exe missing from image (expected at '$stagedExe'). Check that dotnet publish produced the exe and Copy-Item succeeded."
    }
    $stagedFlavours = Get-ChildItem $destFlavours -Filter '*.json' -EA SilentlyContinue
    if (-not $stagedFlavours) {
        throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices."
    }
    $stagedCatalog = Join-Path $appsDest 'catalog.json'
    if (-not (Test-Path $stagedCatalog)) {
        throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty."
    }
    Write-Host "   Welcome payload staged at $dest"
}

# --- 3. Service the WIM offline (DISM) -------------------------------------
function Invoke-ServiceWim {
    Write-Stage 'Stage 3: offline-service install.wim'
    $sources = Join-Path $isoRoot 'sources'
    $wim = Join-Path $sources 'install.wim'
    $esd = Join-Path $sources 'install.esd'
    if (-not (Test-Path $wim) -and (Test-Path $esd)) {
        Write-Host '   converting install.esd -> install.wim'
        $idx = (Get-WindowsImage -ImagePath $esd | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
        if (-not $idx) { $idx = 1 }
        Export-WindowsImage -SourceImagePath $esd -SourceIndex $idx -DestinationImagePath $wim -CompressionType Max
        Remove-Item $esd -Force
    }
    $idx = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
    if (-not $idx) { $idx = ($m.baseImage.wimImageIndex); if (-not $idx) { $idx = 1 } }
    Write-Host "   servicing image index $idx"

    Mount-WindowsImage -ImagePath $wim -Index $idx -Path $mount | Out-Null
    try {
        # Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test).
        $drv = Join-Path $WindowsDir 'drivers'
        if ((Get-ChildItem $drv -Recurse -Filter *.inf -EA SilentlyContinue)) {
            # -ForceUnsigned: skip the offline-inject signature check (the virtio NetKVM
            # driver is WHQL-signed and loads fine at boot; the offline check can still
            # reject it on the build host). Non-fatal: a driver issue must not brick the
            # whole image build -- warn and continue without it.
            Write-Host '   adding drivers'
            try { Add-WindowsDriver -Path $mount -Driver $drv -Recurse -ForceUnsigned -ErrorAction Stop | Out-Null }
            catch { Write-Warning "   driver inject failed (continuing without it): $($_.Exception.Message)" }
        } else { Write-Host '   no .inf drivers staged (ok for VM test)' }

        # Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC.
        if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
            Write-Host '   enabling Shell Launcher + Keyboard Filter features'
            Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-EmbeddedShellLauncher -All -NoRestart | Out-Null
            Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-KeyboardFilter        -All -NoRestart | Out-Null
        }

        # Debloat: remove provisioned appx listed in debloat/appx-remove.txt (best-effort).
        $list = Join-Path $WindowsDir 'debloat\appx-remove.txt'
        if (Test-Path $list) {
            $prov = Get-AppxProvisionedPackage -Path $mount
            Get-Content $list | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object {
                $name = $_.Trim()
                $prov | Where-Object DisplayName -like "$name*" | ForEach-Object {
                    try { Remove-AppxProvisionedPackage -Path $mount -PackageName $_.PackageName | Out-Null; Write-Host "   removed $($_.DisplayName)" } catch {}
                }
            }
        }

        # Stage the hardening payload: SetupComplete.cmd (native auto-run as SYSTEM) + modules.
        $scripts = Join-Path $mount 'Windows\Setup\Scripts'
        $null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening')
        Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force
        Copy-Item (Join-Path $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force
        Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force
        # Stage the branding module so SetupComplete.cmd can re-apply branding ONLINE
        # (Windows resets the offline personalization bake during OOBE).
        $brandDest = Join-Path $scripts 'branding'
        $null = New-Item -ItemType Directory -Force $brandDest
        Copy-Item (Join-Path $WindowsDir 'branding\*') $brandDest -Recurse -Force

        # Stage Welcome app + flavours while the WIM is still mounted.
        Copy-WelcomePayload

        # Bake the four branding layers into the offline hives (must be inside the mount).
        Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)'
        & (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount
        if ($LASTEXITCODE -ne 0) { throw 'branding apply failed' }

        # Bake offline UAC auto-approve policy so the Welcome wizard (launched via
        # Shell Launcher v2 (Configure-Kiosk.ps1) as the sm-bootstrap shell, which
        # elevates the app) silently elevates during the ephemeral sm-bootstrap
        # session without a UAC prompt.
        # UAC stays enabled (EnableLUA=1); the wizard's hardening re-tightens the
        # policy for the daily user.  Only applies when Welcome is enabled.
        if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
            Write-Stage 'Stage 3e: bake offline UAC auto-approve policy (silent elevation for sm-bootstrap)'
            $hive = Join-Path $mount 'Windows\System32\config\SOFTWARE'
            & reg load HKLM\SM_OFFLINE "$hive" | Out-Null
            if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' }
            try {
                & reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f | Out-Null
                & reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v PromptOnSecureDesktop /t REG_DWORD /d 0 /f | Out-Null
                Write-Host '   ConsentPromptBehaviorAdmin=0, PromptOnSecureDesktop=0 written to offline SOFTWARE hive.'
            } finally {
                [gc]::Collect(); Start-Sleep -Milliseconds 500
                & reg unload HKLM\SM_OFFLINE | Out-Null
            }
        }
    } finally {
        Dismount-WindowsImage -Path $mount -Save | Out-Null
    }
}

# --- 4. Inject answer file -------------------------------------------------
function Invoke-InjectUnattend {
    Write-Stage 'Stage 4: inject autounattend.xml (ISO root + \sources)'
    $src = Join-Path $PSScriptRoot 'autounattend\autounattend.xml'
    Copy-Item $src (Join-Path $isoRoot 'autounattend.xml') -Force
    # Also place in \sources (the implicit search location the windowsPE pass uses).
    Copy-Item $src (Join-Path $isoRoot 'sources\autounattend.xml') -Force
}

# --- 5. Brand --------------------------------------------------------------
# NOTE: branding edits the OFFLINE hives, so it must run while the WIM is still
# mounted. We therefore call it from inside Invoke-ServiceWim (see Step 2), and
# this stage just asserts the staged result for the SBOM/log.
function Invoke-Brand {
    Write-Stage 'Stage 5: branding (applied during WIM servicing)'
    Write-Host '   branding layers baked via branding\Apply-Branding.ps1 -Mode Offline'
}

# --- 6. Repack -------------------------------------------------------------
function Invoke-Repack {
    Write-Stage 'Stage 6: repack UEFI-bootable ISO (oscdimg)'
    $etfs = Join-Path $isoRoot 'boot\etfsboot.com'
    # Use the STANDARD prompt boot image (efisys.bin). The no-prompt variant + a
    # media-first boot order causes a reinstall LOOP: every post-copy reboot
    # re-boots the media before the disk install completes, so it never finishes.
    # The "press any key to boot from CD/USB" prompt lets reboots fall through to
    # the installed disk. (Initial media boot = one keypress or a firmware boot-menu
    # selection — expected for a USB-installed SKU.)
    $efi  = Join-Path $isoRoot 'efi\microsoft\boot\efisys.bin'
    if (-not (Test-Path $efi)) { throw "missing UEFI boot image: $efi" }
    # Work paths have no spaces (SYSTEM TEMP / runner temp), so omit oscdimg's
    # inner quotes around the boot images -- otherwise PowerShell mangles the
    # native -bootdata arg into doubled quotes (oscdimg Error 123).
    $bootdata = "2#p0,e,b$etfs#pEF,e,b$efi"
    if (Test-Path $OutputIso) { Remove-Item $OutputIso -Force }
    & $oscdimg -m -o -u2 -udfver102 -lSILVERMETAL "-bootdata:$bootdata" $isoRoot $OutputIso
    if ($LASTEXITCODE -ne 0) { throw "oscdimg failed ($LASTEXITCODE)" }
}

# --- 7. Attest -------------------------------------------------------------
function Invoke-Attest {
    Write-Stage 'Stage 7: SHA-256 + SBOM'
    $hash = (Get-FileHash -Algorithm SHA256 $OutputIso).Hash
    "$hash *$(Split-Path $OutputIso -Leaf)" | Set-Content "$OutputIso.sha256"
    $sbom = [ordered]@{
        product   = $m.product
        builtFrom = @{ sourceIsoSha256 = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash; manifest = $m.baseImage }
        output    = @{ iso = (Split-Path $OutputIso -Leaf); sha256 = $hash }
        tooling   = @{ oscdimg = $oscdimg; dism = (Get-Command dism.exe).Version.ToString() }
        note      = 'Reproducibility: pinned inputs + SBOM + SHA, NOT bit-identical (iso-builder.md §5). Signing: TODO-M3.'
    }
    $sbom | ConvertTo-Json -Depth 6 | Set-Content "$OutputIso.sbom.json"
    Write-Host "   ISO SHA-256: $hash"
}

# --- orchestrate -----------------------------------------------------------
Invoke-VerifyInput
Invoke-Extract
Invoke-ForceLegacySetup
Invoke-PublishWelcome   # publish Welcome app before mount (no WIM needed)
Invoke-ServiceWim       # mounts WIM, stages hardening + Welcome payload, dismounts
Invoke-InjectUnattend
Invoke-Brand
Invoke-Repack
Invoke-Attest
Write-Host "`nBuild complete: $OutputIso" -ForegroundColor Green
