Public/Register-ADOpsKitScheduledTasks.ps1

function Register-ADOpsKitScheduledTasks {
<#
.SYNOPSIS
    Interactively registers ADOpsKit functions as Windows Scheduled Tasks.
 
.DESCRIPTION
    Guides you through selecting which ADOpsKit functions to schedule,
    setting run times and frequency, specifying a service account, and
    choosing an output base path. Registers all selected tasks under the
    \ADOpsKit\ folder in Windows Task Scheduler.
 
    This function MAKES CHANGES: it registers/replaces scheduled tasks,
    creates folders, writes task scripts, and restricts folder permissions.
    Supports -WhatIf / -Confirm. WinRM is not required.
 
    Service account handling:
        - Regular accounts are validated up front with a local batch logon
          check (LogonUser / LOGON32_LOGON_BATCH) which verifies both the
          password AND the 'Log on as a batch job' right in one call, so a
          mistyped password fails immediately instead of at the end of the
          wizard. Each failed check counts as one bad-password attempt
          toward the account lockout threshold.
        - Group Managed Service Accounts (gMSA) are supported as a
          passwordless alternative. The gMSA must be installed on / allowed
          to retrieve its password from this computer; this is verified
          with Test-ADServiceAccount when the ActiveDirectory module is
          available.
 
    Each task:
        - Runs under the specified service account or gMSA
        - Writes dated reports to OutputBasePath\<FunctionName>\yyyy-MM-dd_<report>
        - Logs transcript to OutputBasePath\Logs\<FunctionName>.log
          (rotated to .log.old when it exceeds 10 MB)
        - Optionally emails the report as an attachment via SMTP after each run
        - Deletes its own reports older than RetentionDays after each run
        - Exits 0 on success, 1 if the function failed, 2 if the report
          email could not be sent - visible as Last Run Result in Task
          Scheduler
 
    Security notes:
        - Task scripts with SMTP authentication store the SMTP password as
          a machine-scoped DPAPI-encrypted blob - the plaintext is never
          written to disk, and the blob only decrypts on this computer.
          If a task script is copied to another machine, re-run this
          function there to re-encrypt. The Scripts folder ACL is
          additionally restricted to SYSTEM, Administrators, and the
          service account.
        - The saved configuration file never contains passwords.
 
    After a successful run the chosen settings (minus passwords) are saved
    to OutputBasePath\ADOpsKitTasks.config.json. Re-running with -ConfigPath
    replays that setup, prompting only for passwords and confirmation.
 
.PARAMETER OutputBasePath
    Root folder for all reports, logs, task scripts, and the saved config.
    Defaults to C:\ADOpsKit\Reports
 
.PARAMETER ConfigPath
    Path to a previously saved ADOpsKitTasks.config.json. Replays the saved
    setup (account, domain, functions, schedules, email settings) so only
    passwords and the final confirmation are prompted.
 
.PARAMETER RetentionDays
    Days to keep dated report files in each task's output folder. Each task
    deletes its own reports older than this after each run. 0 disables
    cleanup. Default: 90.
 
.EXAMPLE
    Register-ADOpsKitScheduledTasks
 
.EXAMPLE
    Register-ADOpsKitScheduledTasks -OutputBasePath 'D:\Reports\ADOpsKit' -RetentionDays 30
 
.EXAMPLE
    Register-ADOpsKitScheduledTasks -ConfigPath 'C:\ADOpsKit\Reports\ADOpsKitTasks.config.json'
 
.EXAMPLE
    Register-ADOpsKitScheduledTasks -WhatIf
 
.NOTES
    Author: K Shankar R Karanth
    Website: https://karanth.ovh
    Requires: Run as Administrator, ADOpsKit module installed.
              gMSA verification additionally uses the ActiveDirectory module
              when present (skipped with a warning otherwise).
#>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$OutputBasePath = 'C:\ADOpsKit\Reports',

        [ValidateNotNullOrEmpty()]
        [string]$ConfigPath,

        [ValidateRange(0, 3650)]
        [int]$RetentionDays = 90
    )

    #Requires -RunAsAdministrator

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

    # --- helpers ---
    function Write-Banner {
        param([string]$Text)
        $line = '-' * ($Text.Length + 4)
        Write-Host "`n$line" -ForegroundColor DarkCyan
        Write-Host " $Text" -ForegroundColor Cyan
        Write-Host "$line" -ForegroundColor DarkCyan
    }

    function Read-MenuChoice {
        param([string]$Prompt, [string[]]$Options)
        Write-Host "`n$Prompt" -ForegroundColor White
        for ($i = 0; $i -lt $Options.Count; $i++) {
            Write-Host " [$($i+1)] $($Options[$i])"
        }
        do {
            $raw = Read-Host " Enter choice (1-$($Options.Count))"
            $n   = 0
            $valid = [int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Options.Count
            if (-not $valid) { Write-Host " Invalid - enter a number between 1 and $($Options.Count)." -ForegroundColor Yellow }
        } while (-not $valid)
        return $Options[$n - 1]
    }

    function Read-MultiMenuChoice {
        param([string]$Prompt, [string[]]$Options)
        Write-Host "`n$Prompt" -ForegroundColor White
        Write-Host " [0] All of the above" -ForegroundColor Green
        for ($i = 0; $i -lt $Options.Count; $i++) {
            Write-Host " [$($i+1)] $($Options[$i])"
        }
        Write-Host " Enter numbers separated by commas, or 0 for all."
        do {
            $raw   = (Read-Host " Choice").Trim()
            $parts = $raw -split ',' | ForEach-Object { $_.Trim() }
            if ($parts -contains '0') { return $Options }
            $selected = @()
            $valid = $true
            foreach ($p in $parts) {
                $n = 0
                if ([int]::TryParse($p, [ref]$n) -and $n -ge 1 -and $n -le $Options.Count) {
                    $selected += $Options[$n - 1]
                } else {
                    Write-Host " Invalid entry: $p" -ForegroundColor Yellow
                    $valid = $false
                    break
                }
            }
        } while (-not $valid -or $selected.Count -eq 0)
        return $selected
    }

    function Read-RunTime {
        param([string]$Prompt, [string]$Default)
        do {
            $raw = Read-Host "$Prompt (HH:mm, default $Default)"
            if ([string]::IsNullOrWhiteSpace($raw)) { $raw = $Default }
            $parsed = [datetime]::MinValue
            $ok = [datetime]::TryParseExact($raw.Trim(), [string[]]@('HH:mm', 'H:mm'),
                [System.Globalization.CultureInfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::None, [ref]$parsed)
            if (-not $ok) { Write-Host " Invalid time '$raw' - use 24-hour HH:mm (e.g. 06:30)." -ForegroundColor Yellow }
        } while (-not $ok)
        return $parsed.ToString('HH:mm')
    }

    function Get-PlainText {
        param([SecureString]$Secure)
        $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Secure)
        try { [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) }
        finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($ptr) }
    }

    function ConvertTo-EscapedLiteral {
        # Escapes a value for embedding inside a single-quoted string in generated task scripts
        param([AllowEmptyString()][string]$Value)
        return $Value -replace "'", "''"
    }

    function ConvertTo-EscapedExpandable {
        # Escapes a value for embedding inside a double-quoted string in generated task scripts
        param([AllowEmptyString()][string]$Value)
        return $Value.Replace('`', '``').Replace('"', '`"').Replace('$', '`$')
    }

    function Get-ConfigProp {
        # Safe property read from a JSON-imported object (tolerates missing properties)
        param($Object, [string]$Name, $Default)
        if ($null -ne $Object -and $Object.PSObject.Properties[$Name] -and $null -ne $Object.$Name) {
            return $Object.$Name
        }
        return $Default
    }

    function Assert-AccountHasDomainQualifier {
        # A bare username (no DOMAIN\ prefix, no UPN @domain suffix) is not
        # rejected by LogonUser - it is silently reinterpreted as a LOCAL
        # account instead of the intended domain account, which can produce
        # a confusing failure (or worse, a false success against an
        # unrelated same-named local account). Fail loudly instead.
        param([string]$Account)
        if ($Account -notmatch '\\' -and $Account -notmatch '@') {
            throw "Service account '$Account' has no domain qualifier. Use DOMAIN\username (or DOMAIN\name`$ for a gMSA) or a UPN (user@domain.com)."
        }
    }

    function Test-ServiceAccountCredential {
        <#
            Validates the account/password pair with a local batch logon
            (LogonUser, LOGON32_LOGON_BATCH). This verifies the password AND
            the 'Log on as a batch job' right - exactly what a scheduled task
            needs. Local API call only; no WinRM.
            Note: a failed check counts as one bad-password attempt against
            the account lockout policy.
        #>

        param(
            [string]$Account,
            [string]$Password
        )

        if (-not ('ADOpsKitInternal.LogonApi' -as [type])) {
            Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
namespace ADOpsKitInternal {
    public static class LogonApi {
        [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
        public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out IntPtr token);
        [DllImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool CloseHandle(IntPtr handle);
    }
}
'@

        }

        $domainPart = $null
        $userPart   = $Account
        if ($Account -match '^(?<dom>[^\\]+)\\(?<usr>.+)$') {
            $domainPart = $Matches['dom']
            $userPart   = $Matches['usr']
        } elseif ($Account -notmatch '@') {
            # Bare username - treat as local account. UPNs pass through with a null domain.
            $domainPart = '.'
        }

        $token = [IntPtr]::Zero
        try {
            # logonType 4 = LOGON32_LOGON_BATCH, logonProvider 0 = default
            $ok = [ADOpsKitInternal.LogonApi]::LogonUser($userPart, $domainPart, $Password, 4, 0, [ref]$token)
            if ($ok) {
                return [pscustomobject]@{ Status = 'Valid'; Message = 'Credentials valid; batch logon right confirmed.' }
            }
            $code = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
            $status = switch ($code) {
                1326    { 'BadCredentials' }
                1385    { 'NoBatchRight' }
                1909    { 'LockedOut' }
                1330    { 'PasswordExpired' }
                1907    { 'PasswordMustChange' }
                1331    { 'AccountDisabled' }
                1327    { 'AccountRestriction' }
                default { 'Unknown' }
            }
            $msg = (New-Object System.ComponentModel.Win32Exception($code)).Message
            return [pscustomobject]@{ Status = $status; Message = "$msg (Win32 error $code)" }
        } catch {
            return [pscustomobject]@{ Status = 'Unknown'; Message = $_.Exception.Message }
        } finally {
            if ($token -ne [IntPtr]::Zero) { [void][ADOpsKitInternal.LogonApi]::CloseHandle($token) }
        }
    }

    function Test-GmsaAccount {
        <#
            Verifies this computer can use the gMSA (retrieve its managed
            password). Returns $true / $false, or $null when verification is
            not possible (no ActiveDirectory module, no DC reachable).
        #>

        param([string]$Account)
        $name = ($Account -split '\\')[-1]
        try {
            Import-Module ActiveDirectory -ErrorAction Stop -Verbose:$false
            return [bool](Test-ADServiceAccount -Identity $name -ErrorAction Stop)
        } catch {
            Write-Warning "Could not verify gMSA '$Account': $($_.Exception.Message)"
            return $null
        }
    }

    function Protect-ScriptsFolder {
        <#
            Restricts the Scripts folder to SYSTEM, Administrators, and the
            service account. Task scripts with SMTP authentication embed the
            SMTP password, so ordinary users must not be able to read them.
        #>

        param([string]$Path, [string]$Account)
        try {
            $acl = Get-Acl -LiteralPath $Path
            $acl.SetAccessRuleProtection($true, $false)
            foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRule($rule) }
            foreach ($entry in @(
                @{ Id = 'NT AUTHORITY\SYSTEM';    Rights = [System.Security.AccessControl.FileSystemRights]::FullControl },
                @{ Id = 'BUILTIN\Administrators'; Rights = [System.Security.AccessControl.FileSystemRights]::FullControl },
                @{ Id = $Account;                 Rights = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute }
            )) {
                $ref  = New-Object System.Security.Principal.NTAccount($entry.Id)
                $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
                    $ref, $entry.Rights, 'ContainerInherit,ObjectInherit', 'None', 'Allow')
                $acl.AddAccessRule($rule)
            }
            Set-Acl -LiteralPath $Path -AclObject $acl
            Write-Host " [OK] Restricted permissions on $Path (SYSTEM, Administrators, $Account)" -ForegroundColor Green
        } catch {
            Write-Warning "Could not restrict permissions on '$Path': $($_.Exception.Message). Task scripts may contain SMTP credentials - restrict access manually."
        }
    }

    function New-ADOpsKitTask {
        [CmdletBinding(SupportsShouldProcess)]
        param(
            [string]$TaskName,
            [string]$Description,
            [string]$ScriptBlock,
            [CimInstance]$Trigger,
            [string]$Account,
            [string]$Password,
            [switch]$IsGmsa,
            [string]$BasePath,
            [int]$RetentionDays,
            [hashtable]$EmailConfig
        )

        if (-not $PSCmdlet.ShouldProcess("\ADOpsKit\$TaskName", 'Register scheduled task and write task script')) { return }

        $logFile = Join-Path $BasePath "Logs\$TaskName.log"
        $logDir  = Split-Path $logFile
        if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }

        $sqLog       = ConvertTo-EscapedLiteral $logFile
        $sqReportDir = ConvertTo-EscapedLiteral (Join-Path $BasePath $TaskName)

        # Build optional email block
        $emailBlock = ''
        if ($EmailConfig -and $EmailConfig.Enabled) {
            $sqServer = ConvertTo-EscapedLiteral $EmailConfig.SmtpServer
            $smtpPort = [int]$EmailConfig.Port
            $sqFrom   = ConvertTo-EscapedLiteral $EmailConfig.From
            $sqTo     = ConvertTo-EscapedLiteral $EmailConfig.To

            $sslLine = ''
            if ($EmailConfig.UseSsl) {
                $sslLine = " `$mailParams['UseSsl'] = `$true"
            }
            $credLine = ''
            if ($EmailConfig.Username) {
                $sqUser  = ConvertTo-EscapedLiteral $EmailConfig.Username
                # Machine-scoped DPAPI: only an encrypted blob is written into
                # the generated task script. It decrypts only on this computer,
                # so the plaintext password never reaches disk.
                $encPass = Protect-ADOKMachineSecret -PlainText $EmailConfig.Password
                $credLine = @"
            Add-Type -AssemblyName System.Security
            `$smtpPwdBytes = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('$encPass'), `$null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
            `$mailParams['Credential'] = New-Object System.Management.Automation.PSCredential('$sqUser', (ConvertTo-SecureString ([System.Text.Encoding]::UTF8.GetString(`$smtpPwdBytes)) -AsPlainText -Force))
            `$smtpPwdBytes = `$null
"@

            }

            $emailBlock = @"
 
    # --- Email report ---
    try {
        `$reportFiles = Get-ChildItem -Path '$sqReportDir' -File -ErrorAction SilentlyContinue |
                        Sort-Object LastWriteTime -Descending | Select-Object -First 1
        if (`$reportFiles) {
            `$mailParams = @{
                SmtpServer = '$sqServer'
                Port = $smtpPort
                From = '$sqFrom'
                To = '$sqTo'
                Subject = "ADOpsKit Report: $TaskName `$(Get-Date -Format 'yyyy-MM-dd')"
                Body = "Please find the attached ADOpsKit report for $TaskName generated on `$(Get-Date -Format 'yyyy-MM-dd HH:mm')."
                Attachments = `$reportFiles.FullName
                ErrorAction = 'Stop'
            }
$sslLine
$credLine
            Send-MailMessage @mailParams
            Write-Host ' Report emailed to $sqTo'
        } else {
            Write-Warning "No report file found to email for $TaskName"
            `$exitCode = 2
        }
    } catch {
        Write-Warning "Email failed for $TaskName : `$_"
        `$exitCode = 2
    }
"@

        }

        # Optional retention block
        $retentionBlock = ''
        if ($RetentionDays -gt 0) {
            $retentionBlock = @"
 
    # --- Retention: remove reports older than $RetentionDays days ---
    try {
        Get-ChildItem -Path '$sqReportDir' -File -ErrorAction Stop |
            Where-Object { `$_.LastWriteTime -lt (Get-Date).AddDays(-$RetentionDays) } |
            Remove-Item -Force -ErrorAction Stop
    } catch {
        Write-Warning "Retention cleanup failed for $TaskName : `$_"
    }
"@

        }

        # Write script to a .ps1 file - avoids all escaping issues with -Command
        $scriptDir  = Join-Path $BasePath 'Scripts'
        if (-not (Test-Path -LiteralPath $scriptDir)) { New-Item -ItemType Directory -Path $scriptDir -Force | Out-Null }
        $scriptFile = Join-Path $scriptDir "$TaskName.ps1"

        $fullScript = @"
# Generated by Register-ADOpsKitScheduledTasks (ADOpsKit) on $(Get-Date -Format 'yyyy-MM-dd HH:mm')
# Exit codes: 0 = success, 1 = task function failed, 2 = report email problem
`$exitCode = 0
`$logFile = '$sqLog'
if ((Test-Path -LiteralPath `$logFile) -and ((Get-Item -LiteralPath `$logFile).Length -gt 10MB)) {
    Move-Item -LiteralPath `$logFile -Destination (`$logFile + '.old') -Force
}
Start-Transcript -Path `$logFile -Append -Force
try {
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    Import-Module ADOpsKit -ErrorAction Stop
    $ScriptBlock
$emailBlock
$retentionBlock
} catch {
    Write-Error "ADOpsKit task '$TaskName' failed: `$_"
    `$exitCode = 1
} finally {
    Stop-Transcript
}
exit `$exitCode
"@

        $fullScript | Set-Content -LiteralPath $scriptFile -Encoding UTF8

        $psExe  = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
        $action = New-ScheduledTaskAction -Execute $psExe -Argument "-NonInteractive -NoProfile -ExecutionPolicy Bypass -File `"$scriptFile`""
        $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) -MultipleInstances IgnoreNew -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5)

        $existing = Get-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName $TaskName -ErrorAction SilentlyContinue
        if ($existing) {
            Unregister-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName $TaskName -Confirm:$false
        }

        if ($IsGmsa) {
            $principal = New-ScheduledTaskPrincipal -UserId $Account -LogonType Password -RunLevel Highest
            Register-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName $TaskName -Description $Description `
                -Action $action -Trigger $Trigger -Settings $settings -Principal $principal | Out-Null
        } else {
            Register-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName $TaskName -Description $Description `
                -Action $action -Trigger $Trigger -Settings $settings `
                -User $Account -Password $Password -RunLevel Highest | Out-Null
        }

        Write-Host " [OK] Registered: \ADOpsKit\$TaskName" -ForegroundColor Green
    }

    # --- Available functions ---
    $allFunctions = @(
        'Get-AccountLockoutReport',
        'Get-InsecureLDAPBinds',
        'Get-ADForestHealth',
        'Test-DCPortHealth',
        'Get-EntraConnectSyncStatus',
        'Get-GPOInventoryWithSettings',
        'Get-ADArchitectureAssessment',
        'Get-ADReplicationTopologyDiagram'
    )

    $dailySuggested  = @('Get-AccountLockoutReport','Get-InsecureLDAPBinds','Get-ADForestHealth','Test-DCPortHealth','Get-EntraConnectSyncStatus')
    $weeklySuggested = @('Get-GPOInventoryWithSettings','Get-ADArchitectureAssessment','Get-ADReplicationTopologyDiagram')

    # --- Load saved config (replay mode) ---
    $importedConfig = $null
    if ($ConfigPath) {
        if (-not (Test-Path -LiteralPath $ConfigPath)) {
            throw "Config file not found: $ConfigPath"
        }
        try {
            $importedConfig = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json
        } catch {
            throw "Could not parse config file '$ConfigPath': $($_.Exception.Message)"
        }
        if (-not $PSBoundParameters.ContainsKey('OutputBasePath')) {
            $cfgBase = [string](Get-ConfigProp $importedConfig 'OutputBasePath' '')
            if ($cfgBase) { $OutputBasePath = $cfgBase }
        }
        if (-not $PSBoundParameters.ContainsKey('RetentionDays')) {
            $RetentionDays = [int](Get-ConfigProp $importedConfig 'RetentionDays' $RetentionDays)
        }
    }

    # --- Welcome ---
    Write-Banner "ADOpsKit - Scheduled Task Setup"
    Write-Host "`n This wizard registers ADOpsKit functions as Windows Scheduled Tasks."
    Write-Host " Tasks will be created under \ADOpsKit\ in Task Scheduler."
    Write-Host " Reports will be saved to: $OutputBasePath\<FunctionName>\"
    if ($RetentionDays -gt 0) {
        Write-Host " Reports older than $RetentionDays days are removed after each run." -ForegroundColor DarkGray
    }
    if ($importedConfig) {
        Write-Host " Replaying saved configuration from: $ConfigPath" -ForegroundColor Cyan
    }
    Write-Host ""

    # --- Step 1: Service account ---
    Write-Banner "Step 1 of 4 - Service Account"

    if ($importedConfig) {
        $account = [string](Get-ConfigProp $importedConfig 'Account' '')
        if (-not $account) { throw "Config file has no 'Account' value." }
        Assert-AccountHasDomainQualifier -Account $account
        $isGmsa = ([string](Get-ConfigProp $importedConfig 'AccountType' 'Regular')) -eq 'gMSA'
        Write-Host " Account (from config): $account$(if ($isGmsa) { ' (gMSA)' })"
    } else {
        $typeChoice = Read-MenuChoice -Prompt " Account type" -Options @(
            'Regular service account (DOMAIN\username + password)',
            'Group Managed Service Account (gMSA - passwordless)'
        )
        $isGmsa = $typeChoice -like 'Group Managed*'
    }

    $plainPwd = ''
    if ($isGmsa) {
        $accountOk = $false
        do {
            if (-not $importedConfig) {
                $account = Read-Host ' Enter gMSA account (DOMAIN\name$)'
                Assert-AccountHasDomainQualifier -Account $account
                if ($account -notmatch '\$$') { $account = $account + '$' }
            }
            Write-Host " Verifying gMSA on this computer..." -ForegroundColor DarkGray
            $gmsaCheck = Test-GmsaAccount -Account $account
            if ($gmsaCheck -eq $true) {
                Write-Host " [OK] gMSA verified - this computer can retrieve its managed password." -ForegroundColor Green
                $accountOk = $true
            } elseif ($gmsaCheck -eq $false) {
                Write-Host " [FAIL] This computer cannot use gMSA '$account'." -ForegroundColor Red
                Write-Host " Check PrincipalsAllowedToRetrieveManagedPassword and run Install-ADServiceAccount." -ForegroundColor Yellow
                if ($importedConfig) {
                    if ((Read-Host " Continue anyway? (Y/N)") -match '^[Yy]') { $accountOk = $true }
                    else { Write-Host "`n Cancelled." -ForegroundColor Yellow; return }
                } else {
                    if ((Read-Host " Try a different account? (Y/N)") -notmatch '^[Yy]') {
                        Write-Host "`n Cancelled." -ForegroundColor Yellow; return
                    }
                }
            } else {
                if ((Read-Host " Could not verify the gMSA. Continue anyway? (Y/N)") -match '^[Yy]') {
                    Write-Host " [SKIP] Proceeding with unverified gMSA." -ForegroundColor Yellow
                    $accountOk = $true
                } else { Write-Host "`n Cancelled." -ForegroundColor Yellow; return }
            }
        } while (-not $accountOk)
    } else {
        $credentialsOk = $false
        do {
            if (-not $importedConfig) {
                $account = Read-Host " Enter service account (DOMAIN\username)"
                Assert-AccountHasDomainQualifier -Account $account
            }
            $securePwd = Read-Host " Password for $account" -AsSecureString
            $plainPwd  = Get-PlainText -Secure $securePwd

            if ($WhatIfPreference) {
                # -WhatIf must not perform a live authentication attempt: each failed
                # check counts toward the account's AD lockout threshold, which is a
                # real side effect a dry run must never cause.
                Write-Host " [WhatIf] Skipping live credential validation (no changes previewed)." -ForegroundColor DarkGray
                $check = [pscustomobject]@{ Status = 'Unknown'; Message = 'Skipped under -WhatIf' }
            }
            else {
                Write-Host " Validating credentials (batch logon check)..." -ForegroundColor DarkGray
                $check = Test-ServiceAccountCredential -Account $account -Password $plainPwd
            }

            switch ($check.Status) {
                'Valid' {
                    Write-Host " [OK] Credentials valid; 'Log on as a batch job' right confirmed." -ForegroundColor Green
                    $credentialsOk = $true
                }
                'NoBatchRight' {
                    Write-Host " [OK] Credentials valid." -ForegroundColor Green
                    Write-Host " [WARN] '$account' currently lacks the 'Log on as a batch job' right here." -ForegroundColor Yellow
                    Write-Host " Task Scheduler normally grants it at registration, but a Group Policy" -ForegroundColor Yellow
                    Write-Host " managing that right centrally can strip it again and break the tasks." -ForegroundColor Yellow
                    $credentialsOk = $true
                }
                'BadCredentials' {
                    Write-Host " [FAIL] Wrong username or password for $account." -ForegroundColor Red
                    Write-Host " Note: each failed check counts toward the account lockout threshold." -ForegroundColor Yellow
                    if ((Read-Host " Try again? (Y/N)") -notmatch '^[Yy]') {
                        Write-Host "`n Cancelled." -ForegroundColor Yellow; return
                    }
                }
                'Unknown' {
                    Write-Warning "Could not validate credentials: $($check.Message)"
                    if ((Read-Host " Continue anyway? (Y/N)") -match '^[Yy]') {
                        Write-Host " [SKIP] Proceeding with unvalidated credentials." -ForegroundColor Yellow
                        $credentialsOk = $true
                    } else { Write-Host "`n Cancelled." -ForegroundColor Yellow; return }
                }
                default {
                    # LockedOut, PasswordExpired, PasswordMustChange, AccountDisabled, AccountRestriction
                    Write-Host " [FAIL] $($check.Status) - $($check.Message)" -ForegroundColor Red
                    if ((Read-Host " Try again? (Y/N)") -notmatch '^[Yy]') {
                        Write-Host "`n Cancelled." -ForegroundColor Yellow; return
                    }
                }
            }
        } while (-not $credentialsOk)
    }

    # --- Step 2: Domain name ---
    Write-Banner "Step 2 of 4 - Domain"
    if ($importedConfig) {
        $domainName = [string](Get-ConfigProp $importedConfig 'DomainName' '')
        if ($domainName) {
            Write-Host " Domain (from config): $domainName"
        } else {
            $domainName = Read-Host " Enter domain FQDN (e.g. corp.contoso.com)"
        }
    } else {
        $domainName = Read-Host " Enter domain FQDN (e.g. corp.contoso.com)"
    }

    # --- Steps 3 & 4: Function selection, schedule, email ---
    $taskConfigs = @()

    if ($importedConfig) {
        Write-Banner "Steps 3 & 4 of 4 - Tasks (from config)"
        $smtpPwdCache = @{}
        $cfgTasks = @(Get-ConfigProp $importedConfig 'Tasks' @())
        foreach ($t in $cfgTasks) {
            $name = [string](Get-ConfigProp $t 'Name' '')
            if ($allFunctions -notcontains $name) {
                Write-Warning "Skipping unknown function in config: '$name'"
                continue
            }
            $freq = [string](Get-ConfigProp $t 'Frequency' 'Daily')
            if ($freq -ne 'Weekly') { $freq = 'Daily' }
            # @(...) normalizes both the old single-string DayOfWeek format
            # and the current multi-day array format to an array.
            $days    = @(Get-ConfigProp $t 'DayOfWeek' @('Monday')) | ForEach-Object { [string]$_ }
            $timeStr = [string](Get-ConfigProp $t 'Time' '06:00')
            $parsedTime = [datetime]::MinValue
            if (-not [datetime]::TryParseExact($timeStr, [string[]]@('HH:mm', 'H:mm'),
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [System.Globalization.DateTimeStyles]::None, [ref]$parsedTime)) {
                Write-Warning "Invalid time '$timeStr' for $name in config - using 06:00."
                $timeStr = '06:00'
            }
            $trigger = if ($freq -eq 'Weekly') {
                New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 -DaysOfWeek $days -At $timeStr
            } else {
                New-ScheduledTaskTrigger -Daily -At $timeStr
            }

            $emailConfig = @{ Enabled = $false }
            $emailNode = Get-ConfigProp $t 'Email' $null
            if ($emailNode -and [bool](Get-ConfigProp $emailNode 'Enabled' $false)) {
                $smtpServer = [string](Get-ConfigProp $emailNode 'SmtpServer' '')
                $smtpUser   = [string](Get-ConfigProp $emailNode 'Username' '')
                $smtpPass   = ''
                if ($smtpUser) {
                    $cacheKey = "$smtpServer|$smtpUser"
                    if (-not $smtpPwdCache.ContainsKey($cacheKey)) {
                        $sec = Read-Host " SMTP password for '$smtpUser' on $smtpServer" -AsSecureString
                        $smtpPwdCache[$cacheKey] = Get-PlainText -Secure $sec
                    }
                    $smtpPass = $smtpPwdCache[$cacheKey]
                }
                $emailConfig = @{
                    Enabled    = $true
                    SmtpServer = $smtpServer
                    Port       = [int](Get-ConfigProp $emailNode 'Port' 587)
                    UseSsl     = [bool](Get-ConfigProp $emailNode 'UseSsl' $true)
                    From       = [string](Get-ConfigProp $emailNode 'From' '')
                    To         = [string](Get-ConfigProp $emailNode 'To' '')
                    Username   = $smtpUser
                    Password   = $smtpPass
                }
            }

            $taskConfigs += [PSCustomObject]@{
                Name        = $name
                Frequency   = $freq
                DayOfWeek   = if ($freq -eq 'Weekly') { $days } else { @() }
                Time        = $timeStr
                Trigger     = $trigger
                EmailConfig = $emailConfig
            }
        }
        if ($taskConfigs.Count -eq 0) {
            Write-Warning "No valid tasks found in config file - nothing to register."
            return
        }
    } else {
        # --- Step 3: Function selection ---
        Write-Banner "Step 3 of 4 - Select Functions"
        Write-Host "`n Suggested daily : $($dailySuggested -join ', ')" -ForegroundColor DarkGray
        Write-Host " Suggested weekly : $($weeklySuggested -join ', ')" -ForegroundColor DarkGray

        $selectedFunctions = Read-MultiMenuChoice `
            -Prompt  "Which functions do you want to schedule?" `
            -Options $allFunctions

        # --- Step 4: Schedule + Email per function ---
        Write-Banner "Step 4 of 4 - Schedule & Email"
        Write-Host "`n Configure schedule and optional email for each selected function." -ForegroundColor White

        # Collect SMTP server settings once if user wants email for any task
        $sharedSmtp = $null

        foreach ($fn in $selectedFunctions) {
            Write-Host "`n ---- $fn ----" -ForegroundColor Cyan

            # Schedule
            $freq = Read-MenuChoice -Prompt " Frequency" -Options @('Daily', 'Weekly')
            $days = @()

            if ($freq -eq 'Daily') {
                $timeStr = Read-RunTime -Prompt " Run time" -Default '06:00'
                $trigger = New-ScheduledTaskTrigger -Daily -At $timeStr
            } else {
                $days    = Read-MultiMenuChoice -Prompt " Day(s) of week" -Options @('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')
                $timeStr = Read-RunTime -Prompt " Run time" -Default '02:00'
                $trigger = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 -DaysOfWeek $days -At $timeStr
            }

            # Email
            $emailConfig = @{ Enabled = $false }
            $wantEmail = Read-Host " Email report after run? (Y/N)"
            if ($wantEmail -match '^[Yy]') {

                # Collect SMTP server/port/SSL/auth once and reuse unless user wants different settings
                if ($sharedSmtp -and (Read-Host " Use same SMTP settings as previous task? (Y/N)") -match '^[Yy]') {
                    $smtpServer = $sharedSmtp.SmtpServer
                    $smtpPort   = $sharedSmtp.Port
                    $useSsl     = $sharedSmtp.UseSsl
                    $smtpUser   = $sharedSmtp.Username
                    $smtpPass   = $sharedSmtp.Password
                } else {
                    $smtpServer = Read-Host " SMTP server (e.g. smtp.office365.com)"
                    $portRaw    = Read-Host " SMTP port (default 587)"
                    $smtpPort   = if ([string]::IsNullOrWhiteSpace($portRaw)) { 587 } else { [int]$portRaw }
                    $sslAnswer  = Read-MenuChoice -Prompt " Use SSL/TLS?" -Options @('Yes', 'No')
                    $useSsl     = $sslAnswer -eq 'Yes'
                    $authAnswer = Read-MenuChoice -Prompt " SMTP authentication?" -Options @('Username and password', 'No authentication (relay)')
                    if ($authAnswer -eq 'Username and password') {
                        $smtpUser   = Read-Host " SMTP username"
                        $smtpSecure = Read-Host " SMTP password" -AsSecureString
                        $smtpPass   = Get-PlainText -Secure $smtpSecure
                    } else {
                        $smtpUser = ''
                        $smtpPass = ''
                    }
                    $sharedSmtp = @{ SmtpServer = $smtpServer; Port = $smtpPort; UseSsl = $useSsl; Username = $smtpUser; Password = $smtpPass }
                }

                $fromAddr = Read-Host " From address"
                $toAddr   = Read-Host " To address"

                $emailConfig = @{
                    Enabled    = $true
                    SmtpServer = $smtpServer
                    Port       = $smtpPort
                    UseSsl     = $useSsl
                    From       = $fromAddr
                    To         = $toAddr
                    Username   = $smtpUser
                    Password   = $smtpPass
                }
                Write-Host " [OK] Report will be emailed to $toAddr" -ForegroundColor Green
            }

            $taskConfigs += [PSCustomObject]@{
                Name        = $fn
                Frequency   = $freq
                DayOfWeek   = $days
                Time        = $timeStr
                Trigger     = $trigger
                EmailConfig = $emailConfig
            }
        }
    }

    # --- Confirm ---
    Write-Banner "Review - Tasks to Register"
    $taskConfigs | ForEach-Object {
        $emailSummary = if ($_.EmailConfig.Enabled) { $_.EmailConfig.To } else { 'No email' }
        [PSCustomObject]@{ Name = $_.Name; Frequency = $_.Frequency; Day = ($_.DayOfWeek -join ', '); Time = $_.Time; Email = $emailSummary }
    } | Format-Table -AutoSize
    $confirm = Read-Host "`n Proceed? (Y/N)"
    if ($confirm -notmatch '^[Yy]') {
        Write-Host "`n Cancelled." -ForegroundColor Yellow
        return
    }

    # --- Create Task Scheduler folder ---
    if ($PSCmdlet.ShouldProcess('\ADOpsKit', 'Create Task Scheduler folder')) {
        $scheduler = New-Object -ComObject Schedule.Service
        $scheduler.Connect()
        $root = $scheduler.GetFolder('\')
        try { $root.GetFolder('ADOpsKit') | Out-Null }
        catch { $root.CreateFolder('ADOpsKit') | Out-Null }
    }

    if (-not (Test-Path -LiteralPath $OutputBasePath)) {
        if ($PSCmdlet.ShouldProcess($OutputBasePath, 'Create output base folder')) {
            New-Item -ItemType Directory -Path $OutputBasePath -Force | Out-Null
        }
    }

    # --- Lock down the Scripts folder (defense in depth; task scripts hold at
    # most a machine-scoped DPAPI-encrypted SMTP password, no plaintext) ---
    $scriptDir = Join-Path $OutputBasePath 'Scripts'
    if (-not (Test-Path -LiteralPath $scriptDir)) {
        if ($PSCmdlet.ShouldProcess($scriptDir, 'Create Scripts folder')) {
            New-Item -ItemType Directory -Path $scriptDir -Force | Out-Null
        }
    }
    if ($PSCmdlet.ShouldProcess($scriptDir, 'Restrict permissions to SYSTEM, Administrators and the service account')) {
        Protect-ScriptsFolder -Path $scriptDir -Account $account
    }

    # --- Register tasks ---
    Write-Banner "Registering Tasks"

    $sqBase   = ConvertTo-EscapedLiteral $OutputBasePath
    $dqBase   = ConvertTo-EscapedExpandable $OutputBasePath
    $sqDomain = ConvertTo-EscapedLiteral $domainName

    $scriptBlocks = @{
        'Get-AccountLockoutReport'         = "Get-AccountLockoutReport -TempPath '$sqBase\Get-AccountLockoutReport' -SharedPath '$sqBase\Get-AccountLockoutReport'"
        'Get-InsecureLDAPBinds'            = "Get-InsecureLDAPBinds -Hours 24 -OutputPath '$sqBase\Get-InsecureLDAPBinds'"
        'Get-ADForestHealth'               = "Get-ADForestHealth -OutputFolder '$sqBase\Get-ADForestHealth'"
        'Test-DCPortHealth'                = "`$date = Get-Date -Format 'yyyy-MM-dd'; Test-DCPortHealth -TimeoutSeconds 5 -ExportPath `"$dqBase\Test-DCPortHealth\`${date}_DCPortHealth.csv`""
        'Get-EntraConnectSyncStatus'       = "`$date = Get-Date -Format 'yyyy-MM-dd'; Get-EntraConnectSyncStatus -ExportPath `"$dqBase\Get-EntraConnectSyncStatus\`${date}_EntraConnectStatus.csv`""
        'Get-GPOInventoryWithSettings'     = "`$date = Get-Date -Format 'yyyy-MM-dd'; Get-GPOInventoryWithSettings -DomainName '$sqDomain' -OutputPath `"$dqBase\Get-GPOInventoryWithSettings\`${date}_GPOInventoryWithSettings.html`""
        'Get-ADArchitectureAssessment'     = "Get-ADArchitectureAssessment -DomainName '$sqDomain' -OutputFolder '$sqBase\Get-ADArchitectureAssessment'"
        'Get-ADReplicationTopologyDiagram' = "`$date = Get-Date -Format 'yyyy-MM-dd'; Get-ADReplicationTopologyDiagram -OutputPath `"$dqBase\Get-ADReplicationTopologyDiagram\`${date}_ADReplicationTopology.html`""
    }

    foreach ($cfg in $taskConfigs) {
        New-ADOpsKitTask `
            -TaskName      $cfg.Name `
            -Description   "ADOpsKit scheduled task - $($cfg.Name)" `
            -ScriptBlock   $scriptBlocks[$cfg.Name] `
            -Trigger       $cfg.Trigger `
            -Account       $account `
            -Password      $plainPwd `
            -IsGmsa:$isGmsa `
            -BasePath      $OutputBasePath `
            -RetentionDays $RetentionDays `
            -EmailConfig   $cfg.EmailConfig
    }

    # --- Save replayable config (never contains passwords) ---
    $configFile = Join-Path $OutputBasePath 'ADOpsKitTasks.config.json'
    if (-not $WhatIfPreference) {
        try {
            $configExport = [ordered]@{
                SavedOn        = (Get-Date).ToString('s')
                Account        = $account
                AccountType    = if ($isGmsa) { 'gMSA' } else { 'Regular' }
                DomainName     = $domainName
                OutputBasePath = $OutputBasePath
                RetentionDays  = $RetentionDays
                Tasks          = @(
                    foreach ($cfg in $taskConfigs) {
                        $emailOut = [ordered]@{ Enabled = $false }
                        if ($cfg.EmailConfig.Enabled) {
                            $emailOut = [ordered]@{
                                Enabled    = $true
                                SmtpServer = $cfg.EmailConfig.SmtpServer
                                Port       = $cfg.EmailConfig.Port
                                UseSsl     = [bool]$cfg.EmailConfig.UseSsl
                                From       = $cfg.EmailConfig.From
                                To         = $cfg.EmailConfig.To
                                Username   = $cfg.EmailConfig.Username
                            }
                        }
                        [ordered]@{
                            Name      = $cfg.Name
                            Frequency = $cfg.Frequency
                            DayOfWeek = $cfg.DayOfWeek
                            Time      = $cfg.Time
                            Email     = $emailOut
                        }
                    }
                )
            }
            $configExport | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $configFile -Encoding UTF8
        } catch {
            Write-Warning "Could not save config file '$configFile': $($_.Exception.Message)"
        }
    }

    # --- Done ---
    Write-Banner "Done"
    Write-Host "`n Tasks registered under \ADOpsKit\ in Task Scheduler."
    Write-Host " Reports : $OutputBasePath\<FunctionName>\"
    Write-Host " Logs : $OutputBasePath\Logs\ (rotated at 10 MB)"
    if ($RetentionDays -gt 0) {
        Write-Host " Retention : reports older than $RetentionDays days are removed after each run"
    }
    Write-Host " Exit codes: 0 = success, 1 = function failed, 2 = email problem (see Last Run Result)"
    $emailEnabled = $taskConfigs | Where-Object { $_.EmailConfig.Enabled }
    if ($emailEnabled) {
        Write-Host " Email : $(@($emailEnabled).Count) task(s) configured to send reports by email." -ForegroundColor Cyan
    }
    if (Test-Path -LiteralPath $configFile) {
        Write-Host " Config : $configFile"
        Write-Host ""
        Write-Host " Re-run this setup later (passwords prompted, everything else saved):"
        Write-Host " Register-ADOpsKitScheduledTasks -ConfigPath '$configFile'" -ForegroundColor DarkGray
    }
    Write-Host ""
    Write-Host " Verify with:"
    Write-Host " Get-ScheduledTask -TaskPath '\ADOpsKit\' | Select-Object TaskName, State" -ForegroundColor DarkGray
    Write-Host ""
    Write-Host " Test a task immediately:"
    Write-Host " Start-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName 'Test-DCPortHealth'" -ForegroundColor DarkGray
}