Modules/Public/PublicFunctions/Start-AZSCWizard.ps1

<#
.SYNOPSIS
    Guided, interactive setup wizard for Azure Scout.

.DESCRIPTION
    Walks an operator through a Scout run without them having to know a single
    switch. A bare `Invoke-AzureScout` in an interactive session opens this
    wizard automatically; you can also call it directly.

    The wizard runs five steps:

        1. Tenant — show the signed-in context, or sign in / pick a tenant.
        2. Permissions — verify the account actually holds the rights the run
                         needs, and let the operator bail out before a long scan
                         fails halfway through.
        3. What to run — inventory, assessment, or both, then a checklist of the
                         individual categories/assessments. Everything is
                         pre-selected; the operator unchecks what they don't want.
        4. Output — report formats and the report directory (Enter accepts
                         the default).
        5. Confirm — echo the equivalent one-line command, then run it.

    Step 5 prints the exact `Invoke-AzureScout` command the answers map to, so
    the wizard doubles as a way to learn the parameters for later automation.

.PARAMETER AzureEnvironment
    Azure cloud environment to sign in against. Defaults to 'AzureCloud'.

.PARAMETER PlatOS
    Host platform as detected by Test-AZSCPS. Used to skip the interactive
    sign-in prompt in Azure Cloud Shell, which is already authenticated.

.OUTPUTS
    A hashtable with two keys:
      Answers — parameter name/value pairs to splat onto Invoke-AzureScout.
      RunBoth — $true when the operator asked for inventory *and* assessment.
    Returns $null if the operator cancels at any step.

.EXAMPLE
    PS C:\> Invoke-AzureScout
    Opens the wizard (interactive sessions only).

.EXAMPLE
    PS C:\> Invoke-AzureScout -NoWizard
    Skips the wizard and runs the default full ARM inventory.

.NOTES
    Tracks AB#5541. Never prompts in a non-interactive host — Invoke-AzureScout
    gates the call on Test-AZSCInteractiveHost so CI and scheduled runs can
    never block waiting for input.
#>

function Start-AZSCWizard {
    # The 'Start' verb trips PSUseShouldProcessForStateChangingFunctions, but this
    # function only prompts and returns a hashtable — it changes nothing. The
    # caller (Invoke-AzureScout) is what acts on the answers.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
        [string]$AzureEnvironment = 'AzureCloud',
        [string]$PlatOS
    )

    $inventoryCategories = @(
        'AI', 'Analytics', 'Compute', 'Containers', 'Databases', 'Hybrid', 'Identity',
        'Integration', 'IoT', 'Management', 'Monitor', 'Networking', 'Security', 'Storage', 'Web'
    )
    $inventoryFormats = @('Excel', 'Json', 'Markdown', 'AsciiDoc', 'PowerBI')
    $assessmentFormats = @('Html', 'PowerBI', 'Excel', 'Json', 'React', 'Pptx', 'Word', 'Pdf')

    Write-AZSCWizardBanner

    # ── Step 1: tenant ───────────────────────────────────────────────────────
    Write-AZSCWizardStep -Number 1 -Total 5 -Title 'Tenant'

    $tenantId = $null
    $context = $null
    try { $context = Get-AzContext -ErrorAction Stop } catch { $context = $null }

    if ($context -and $context.Tenant) {
        Write-Host " Signed in as : " -NoNewline -ForegroundColor DarkGray
        Write-Host $context.Account.Id -ForegroundColor Cyan
        Write-Host " Tenant : " -NoNewline -ForegroundColor DarkGray
        Write-Host $context.Tenant.Id -ForegroundColor Cyan
        Write-Host ''
        if (Read-AZSCWizardConfirm -Prompt 'Use this account and tenant?' -Default $true) {
            $tenantId = $context.Tenant.Id
        }
    }
    else {
        Write-Host ' No active Azure session found.' -ForegroundColor Yellow
        Write-Host ''
    }

    if (-not $tenantId) {
        if ($PlatOS -eq 'Azure CloudShell') {
            Write-Host ' Running in Cloud Shell — using the ambient session.' -ForegroundColor DarkGray
            try { $tenantId = (Get-AzContext -ErrorAction Stop).Tenant.Id } catch { $tenantId = $null }
        }
        else {
            $useDeviceLogin = Read-AZSCWizardConfirm -Prompt 'Sign in with a device code (needed on headless/SSH hosts)?' -Default $false
            Write-Host ' Opening Azure sign-in...' -ForegroundColor DarkGray
            try {
                $tenantId = Connect-AZSCLoginSession -AzureEnvironment $AzureEnvironment -DeviceLogin:$useDeviceLogin
            }
            catch {
                Write-Host " Sign-in failed: $_" -ForegroundColor Red
                return $null
            }
        }
    }

    # If the account can see more than one tenant, let the operator choose.
    $tenants = @()
    try { $tenants = @(Get-AzTenant -ErrorAction Stop) } catch { $tenants = @() }
    if ($tenants.Count -gt 1) {
        Write-Host ''
        Write-Host ' This account has access to multiple tenants:' -ForegroundColor DarkGray
        $tenantChoice = Read-AZSCWizardChoice -Title 'Select the tenant to scan' -Items @(
            $tenants | ForEach-Object {
                $label = if ($_.Name) { "$($_.Name) ($($_.Id))" } else { $_.Id }
                [pscustomobject]@{ Label = $label; Value = $_.Id }
            }
        )
        if ($null -eq $tenantChoice) { return $null }
        $tenantId = $tenantChoice
    }

    if (-not $tenantId) {
        Write-Host ' Could not determine a tenant to scan.' -ForegroundColor Red
        return $null
    }

    # ── Step 2: permissions ──────────────────────────────────────────────────
    Write-AZSCWizardStep -Number 2 -Total 5 -Title 'Permissions'
    Write-Host ' Checking that this account holds the rights the run needs...' -ForegroundColor DarkGray
    Write-Host ''

    $blocked = $false
    try {
        $perm = Test-AZSCPermissions -TenantID $tenantId -Scope 'All'
        foreach ($detail in $perm.Details) {
            switch ($detail.Status) {
                'Pass' { Write-Host " [PASS] $($detail.Check)" -ForegroundColor Green }
                'Warn' { Write-Host " [WARN] $($detail.Check): $($detail.Message)" -ForegroundColor Yellow }
                'Fail' { Write-Host " [FAIL] $($detail.Check): $($detail.Message)" -ForegroundColor Red
                         Write-Host " $($detail.Remediation)" -ForegroundColor DarkGray
                         $blocked = $true }
                default { Write-Host " [INFO] $($detail.Check): $($detail.Message)" -ForegroundColor DarkGray }
            }
        }
    }
    catch {
        Write-Host " [WARN] Permission pre-flight could not complete: $_" -ForegroundColor Yellow
    }

    Write-Host ''
    if ($blocked) {
        Write-Host ' One or more permission checks failed. The run will be incomplete.' -ForegroundColor Yellow
        if (-not (Read-AZSCWizardConfirm -Prompt 'Continue anyway?' -Default $false)) { return $null }
    }

    # ── Step 3: what to run ──────────────────────────────────────────────────
    Write-AZSCWizardStep -Number 3 -Total 5 -Title 'What to run'

    $mode = Read-AZSCWizardChoice -Title 'Choose a run type' -Items @(
        [pscustomobject]@{ Label = 'Inventory — list everything in the tenant';                      Value = 'Inventory' }
        [pscustomobject]@{ Label = 'Assessment — score the tenant against CAF/WAF';                   Value = 'Assessment' }
        [pscustomobject]@{ Label = 'Both — inventory first, then the scored assessment';        Value = 'Both' }
    )
    if ($null -eq $mode) { return $null }

    $answers = @{ TenantID = $tenantId }
    $runBoth = ($mode -eq 'Both')
    $wantsInventory = ($mode -in @('Inventory', 'Both'))
    $wantsAssessment = ($mode -in @('Assessment', 'Both'))

    if ($wantsInventory) {
        Write-Host ''
        $categories = Read-AZSCWizardChecklist -Title 'Resource categories to inventory' -Items $inventoryCategories
        if ($null -eq $categories) { return $null }
        # All 15 selected is exactly what -Category All means — keep the command
        # line short and let the default sentinel through instead.
        $answers.Category = if ($categories.Count -eq $inventoryCategories.Count) { @('All') } else { $categories }

        Write-Host ''
        $extras = Read-AZSCWizardChecklist -Title 'Optional inventory data (slower, but richer)' -Items @(
            'Resource tags', 'Security Center findings', 'Cost data', 'Quota usage', 'Network diagrams'
        ) -DefaultSelected @('Resource tags')
        if ($null -eq $extras) { return $null }
        if ($extras -contains 'Resource tags')             { $answers.IncludeTags = [switch]$true }
        if ($extras -contains 'Security Center findings')  { $answers.SecurityCenter = [switch]$true }
        if ($extras -contains 'Cost data')                 { $answers.IncludeCosts = [switch]$true }
        if ($extras -contains 'Quota usage')               { $answers.QuotaUsage = [switch]$true }
        if ($extras -notcontains 'Network diagrams')       { $answers.SkipDiagram = [switch]$true }
    }

    if ($wantsAssessment) {
        Write-Host ''
        $manifestPath = Join-Path (Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent) 'manifests/assessments.psd1'
        $assessmentNames = @('LandingZone')
        if (Test-Path $manifestPath) {
            try { $assessmentNames = @((Import-PowerShellDataFile $manifestPath).Keys | Sort-Object) }
            catch { Write-Verbose "Start-AZSCWizard: could not read the assessment manifest, falling back to LandingZone: $_" }
        }
        $chosen = Read-AZSCWizardChecklist -Title 'Assessments to run' -Items $assessmentNames -DefaultSelected @('LandingZone')
        if ($null -eq $chosen) { return $null }
        $answers.Assessment = $chosen
    }

    # ── Step 4: output ───────────────────────────────────────────────────────
    Write-AZSCWizardStep -Number 4 -Total 5 -Title 'Output'

    $formatPool = if ($wantsAssessment -and -not $wantsInventory) { $assessmentFormats } else { $inventoryFormats }
    $defaultFormats = if ($wantsAssessment -and -not $wantsInventory) { @('Html') } else { $inventoryFormats }
    $formats = Read-AZSCWizardChecklist -Title 'Report formats' -Items $formatPool -DefaultSelected $defaultFormats
    if ($null -eq $formats) { return $null }
    if ($formats.Count -eq $formatPool.Count) { $formats = @('All') }
    $answers.OutputFormat = $formats

    Write-Host ''
    $defaultDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'AzureScout'
    $dir = Read-AZSCWizardText -Prompt 'Report directory' -Default $defaultDir
    if ($dir) { $answers.ReportDir = $dir }

    # ── Step 5: confirm ──────────────────────────────────────────────────────
    Write-AZSCWizardStep -Number 5 -Total 5 -Title 'Confirm'
    Write-Host ' Equivalent command:' -ForegroundColor DarkGray
    Write-Host ''
    Write-Host " $(Format-AZSCWizardCommand -Answers $answers)" -ForegroundColor Cyan
    Write-Host ''
    Write-Host ' Save that line to skip the wizard next time.' -ForegroundColor DarkGray
    Write-Host ''

    if (-not (Read-AZSCWizardConfirm -Prompt 'Start the run?' -Default $true)) { return $null }
    Write-Host ''

    return @{ Answers = $answers; RunBoth = $runBoth }
}

<#
.SYNOPSIS
    Returns $true when the current host can safely prompt the operator.

.DESCRIPTION
    Guards the wizard. A prompt in a non-interactive host hangs the process
    forever, which would break every CI job, scheduled task, and container that
    calls Invoke-AzureScout with no arguments — so this returns $false unless
    the session is genuinely interactive.

    Checked, in order: the -NonInteractive host switch, redirected stdin, an
    explicit CI environment variable, and finally [Environment]::UserInteractive.
#>

function Test-AZSCInteractiveHost {
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    # PowerShell launched with -NonInteractive.
    if ($null -ne $Host.UI -and $null -ne $Host.UI.RawUI) {
        try { if (-not [Environment]::UserInteractive) { return $false } }
        catch { return $false }
    }
    else { return $false }

    # stdin redirected from a file or pipe — no operator on the other end.
    try { if ([Console]::IsInputRedirected) { return $false } }
    catch { Write-Verbose "Test-AZSCInteractiveHost: console stdin state unavailable in this host, continuing: $_" }

    # Common CI signals. Any of these means an automated runner, not a person.
    foreach ($var in @('CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'JENKINS_URL', 'SYSTEM_TEAMPROJECTID')) {
        if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($var))) { return $false }
    }

    return $true
}

# ── Wizard UI primitives ─────────────────────────────────────────────────────
# Small, dependency-free console helpers. Kept private to the module (they are
# not exported) so they can change shape without breaking anyone's script.

function Write-AZSCWizardBanner {
    Write-Host ''
    Write-Host ' ╭────────────────────────────────────────────────────────╮' -ForegroundColor Cyan
    Write-Host ' │ Azure Scout — guided setup │' -ForegroundColor Cyan
    Write-Host ' │ Press Enter to accept the default shown in [brackets] │' -ForegroundColor Cyan
    Write-Host ' ╰────────────────────────────────────────────────────────╯' -ForegroundColor Cyan
}

function Write-AZSCWizardStep {
    param([int]$Number, [int]$Total, [string]$Title)
    Write-Host ''
    Write-Host " Step $Number/$Total — $Title" -ForegroundColor White
    Write-Host " $('─' * 56)" -ForegroundColor DarkGray
}

function Read-AZSCWizardConfirm {
    param([string]$Prompt, [bool]$Default = $true)
    $hint = if ($Default) { 'Y/n' } else { 'y/N' }
    while ($true) {
        $raw = (Read-Host " $Prompt [$hint]").Trim()
        if ([string]::IsNullOrWhiteSpace($raw)) { return $Default }
        switch -Regex ($raw) {
            '^(y|yes)$' { return $true }
            '^(n|no)$'  { return $false }
            default     { Write-Host ' Enter y or n.' -ForegroundColor Yellow }
        }
    }
}

function Read-AZSCWizardText {
    param([string]$Prompt, [string]$Default)
    $raw = (Read-Host " $Prompt [$Default]").Trim()
    if ([string]::IsNullOrWhiteSpace($raw)) { return $Default }
    return $raw
}

<#
.SYNOPSIS
    Single-select menu. Returns the chosen item's Value, or $null if cancelled.
#>

function Read-AZSCWizardChoice {
    param([string]$Title, [object[]]$Items)

    Write-Host ''
    Write-Host " $Title" -ForegroundColor White
    for ($i = 0; $i -lt $Items.Count; $i++) {
        Write-Host (" {0}. {1}" -f ($i + 1), $Items[$i].Label)
    }
    Write-Host ''

    while ($true) {
        $raw = (Read-Host ' Selection (number, or q to quit)').Trim()
        if ($raw -match '^(q|quit)$') { return $null }
        if ([string]::IsNullOrWhiteSpace($raw)) { $raw = '1' }
        $n = 0
        if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Items.Count) {
            return $Items[$n - 1].Value
        }
        Write-Host " Enter a number between 1 and $($Items.Count)." -ForegroundColor Yellow
    }
}

<#
.SYNOPSIS
    Multi-select checklist. Everything is selected by default; the operator
    unchecks what they don't want. Returns the selected items, or $null if
    cancelled.
#>

function Read-AZSCWizardChecklist {
    param(
        [string]$Title,
        [string[]]$Items,
        [string[]]$DefaultSelected
    )

    $selected = [System.Collections.Generic.HashSet[string]]::new()
    $initial = if ($PSBoundParameters.ContainsKey('DefaultSelected')) { $DefaultSelected } else { $Items }
    foreach ($item in $initial) { [void]$selected.Add($item) }

    while ($true) {
        Write-Host ''
        Write-Host " $Title" -ForegroundColor White
        for ($i = 0; $i -lt $Items.Count; $i++) {
            $mark = if ($selected.Contains($Items[$i])) { 'x' } else { ' ' }
            $colour = if ($selected.Contains($Items[$i])) { 'Green' } else { 'DarkGray' }
            Write-Host (" [{0}] {1,2}. {2}" -f $mark, ($i + 1), $Items[$i]) -ForegroundColor $colour
        }
        Write-Host ''
        Write-Host ' Toggle with numbers (e.g. "3" or "3,5,9"), a = all, n = none,' -ForegroundColor DarkGray
        Write-Host ' Enter = accept, q = quit' -ForegroundColor DarkGray

        $raw = (Read-Host ' >').Trim()

        if ([string]::IsNullOrWhiteSpace($raw)) {
            if ($selected.Count -eq 0) {
                Write-Host ' Select at least one item.' -ForegroundColor Yellow
                continue
            }
            # Preserve the caller's original ordering rather than hash order.
            return @($Items | Where-Object { $selected.Contains($_) })
        }
        if ($raw -match '^(q|quit)$') { return $null }
        if ($raw -match '^a(ll)?$')   { foreach ($item in $Items) { [void]$selected.Add($item) }; continue }
        if ($raw -match '^n(one)?$')  { $selected.Clear(); continue }

        foreach ($token in ($raw -split '[,\s]+' | Where-Object { $_ })) {
            $n = 0
            if ([int]::TryParse($token, [ref]$n) -and $n -ge 1 -and $n -le $Items.Count) {
                $item = $Items[$n - 1]
                if ($selected.Contains($item)) { [void]$selected.Remove($item) } else { [void]$selected.Add($item) }
            }
            else {
                Write-Host " Ignored '$token' — not a number between 1 and $($Items.Count)." -ForegroundColor Yellow
            }
        }
    }
}

<#
.SYNOPSIS
    Renders the wizard's answers as the equivalent Invoke-AzureScout command so
    the operator can copy it into a script and skip the wizard next time.
#>

function Format-AZSCWizardCommand {
    param([hashtable]$Answers)

    $parts = [System.Collections.Generic.List[string]]::new()
    $parts.Add('Invoke-AzureScout')
    foreach ($key in ($Answers.Keys | Sort-Object)) {
        $value = $Answers[$key]
        if ($value -is [switch] -or $value -is [bool]) {
            if ($value) { $parts.Add("-$key") }
        }
        elseif ($value -is [array]) {
            $parts.Add("-$key $((@($value) | ForEach-Object { if ($_ -match '[\s]') { "'$_'" } else { $_ } }) -join ',')")
        }
        elseif ($value -match '\s') { $parts.Add("-$key '$value'") }
        else { $parts.Add("-$key $value") }
    }
    return ($parts -join ' ')
}