lib/ConfigScaffold.ps1

function New-ConfigFromTemplate {
    <#
    .SYNOPSIS
        Copies the bundled Initialize-DeveloperMachine.config.json
        template (passed via -TemplatePath, typically the file shipped
        next to the entry-point script) to -Path. Creates the parent
        directory if it doesn't exist. Throws when the destination
        already exists - pass -Force to overwrite.

        We read from the bundled file rather than embedding a string
        literal here so there's a single source of truth for the
        starter config.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $TemplatePath,

        [Parameter()]
        [switch] $Force
    )

    if (-not (Test-Path -LiteralPath $TemplatePath -PathType Leaf)) {
        throw "Config template not found at '$TemplatePath'."
    }

    if (-not $Force -and (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Config already exists at '$Path'. Pass -Force to overwrite."
    }

    $dir = Split-Path -Parent $Path
    if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) {
        if ($PSCmdlet.ShouldProcess($dir, 'Create parent directory')) {
            $null = New-Item -ItemType Directory -Path $dir -Force
        }
    }

    if ($PSCmdlet.ShouldProcess($Path, "Copy template from '$TemplatePath'")) {
        $content = Get-Content -LiteralPath $TemplatePath -Raw -Encoding UTF8
        # UTF-8 without BOM, atomic write.
        [System.IO.File]::WriteAllText(
            $Path,
            $content,
            [System.Text.UTF8Encoding]::new($false)
        )
    }
}

function Show-ConfigPreview {
    <#
    .SYNOPSIS
        Prints the contents of a config file to the host with a gray
        background and white text so it stands out from regular log
        output. Falls back to plain text on hosts without VT support.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        Write-Warning "Config file not found at: $Path"
        return
    }

    $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
    $lines   = $content -split "`r?`n"

    if ($script:UseColor) {
        $esc   = [char]27
        $bg    = "$esc[48;5;238m"   # medium-gray background
        $fg    = "$esc[97m"         # bright white text
        $reset = "$esc[0m"

        # Pad each line to a consistent visual width so the gray
        # block reads as a single rectangle even with ragged content.
        $width = ($lines | Measure-Object -Property Length -Maximum).Maximum
        Write-Host ''
        foreach ($line in $lines) {
            $padded = $line.PadRight($width)
            Write-Host "$bg$fg $padded $reset"
        }
        Write-Host ''
    }
    else {
        Write-Host ''
        Write-Host '----- config -----'
        foreach ($line in $lines) { Write-Host $line }
        Write-Host '------------------'
        Write-Host ''
    }
}

function Read-YesNo {
    <#
    .SYNOPSIS
        Prompts the user for a Y/N answer. Default is Y when the user
        just hits Enter. Returns $true for yes, $false for no.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Prompt
    )

    while ($true) {
        $answer = Read-Host -Prompt "$Prompt [Y/n]"
        if (-not $answer) { return $true }
        switch -Regex ($answer.Trim().ToLowerInvariant()) {
            '^(y|yes)$' { return $true }
            '^(n|no)$'  { return $false }
            default     { Write-Host "Please answer Y or N." }
        }
    }
}

function Read-ProceedOrEdit {
    <#
    .SYNOPSIS
        Prompts the user with two choices:
          P - proceed with the current config
          E - open the file in the default editor and wait for save,
              then reload from disk
        Returns the upper-case letter chosen. Re-asks on invalid input.
        (Quit was removed - users can Ctrl+C if they really need to bail.)
    #>

    [CmdletBinding()]
    param()

    while ($true) {
        $answer = Read-Host -Prompt '[P]roceed or [E]dit and reload'
        switch -Regex ($answer.Trim().ToUpperInvariant()) {
            '^P$' { return 'P' }
            '^E$' { return 'E' }
            default { Write-Host 'Please pick P or E.' }
        }
    }
}

function Open-EditorAndWaitForSave {
    <#
    .SYNOPSIS
        Opens $Path in the OS default editor (Start-Process uses the
        file association for .json - Notepad, VS Code, whatever the
        user has configured), then calls Wait-ForFileSave to spin
        until the file is saved.

        If Start-Process can't launch (no association, denied, etc.)
        the function logs a [WARN] and falls through to Wait-ForFileSave
        anyway - the user can open the file manually and the spinner
        will still detect the save.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter()]
        [int] $TimeoutSeconds = 1800,

        [Parameter()]
        [int] $PollIntervalMs = 500
    )

    try {
        Start-Process -FilePath $Path -ErrorAction Stop | Out-Null
    }
    catch {
        Write-Status -Level Warn -Message " [WARN] Couldn't auto-open '$Path' ($($_.Exception.Message)). Open it manually."
    }

    Wait-ForFileSave -Path $Path -TimeoutSeconds $TimeoutSeconds -PollIntervalMs $PollIntervalMs
}

function Wait-ForFileSave {
    <#
    .SYNOPSIS
        Polls $Path's LastWriteTimeUtc until it changes, indicating
        the user saved the file in whatever editor they prefer. Shows
        the script's spinner with a "Waiting for changes - save the
        file to continue" message so the terminal isn't silent.

        Returns $true when a save was detected, $false on timeout.
        Default timeout is 30 min (generous - users can take a while
        to compose a config).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter()]
        [int] $TimeoutSeconds = 1800,

        [Parameter()]
        [int] $PollIntervalMs = 500
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "File not found: $Path"
    }
    $initial = (Get-Item -LiteralPath $Path -Force).LastWriteTimeUtc

    $spinner = New-Spinner -InitialMessage 'Waiting for changes - save the file to continue...'
    try {
        $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
        while ((Get-Date) -lt $deadline) {
            Start-Sleep -Milliseconds $PollIntervalMs
            $current = (Get-Item -LiteralPath $Path -Force).LastWriteTimeUtc
            if ($current -ne $initial) { return $true }
        }
        return $false
    }
    finally {
        Stop-Spinner -Spinner $spinner
    }
}