src/public/Execution/Invoke-AitherScript.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Invoke automation scripts with runtime parameter discovery and validation

.DESCRIPTION
    Executes automation scripts from the automation-scripts directory with intelligent
    parameter discovery, validation, and help generation. Supports script numbers (e.g., 0501)
    or full script names.

    This cmdlet automatically discovers script parameters at runtime, validates them, and provides
    helpful error messages if parameters are missing or invalid. It also enables transcript logging
    by default to capture all script output for debugging and auditing purposes.

.PARAMETER Script
    Script identifier - can be a number (e.g., '0501') or script name pattern.
    This parameter is REQUIRED and identifies which automation script to execute.

    Examples:
    - "0501" - Executes script starting with 0501
    - "system-config" - Executes script with "system-config" in the name
    - You can also pipe script objects from Get-AitherScript

    If the script is not found, an error will be thrown with suggestions for similar scripts.

.PARAMETER Arguments
    Arguments to pass to the script. Can be provided in multiple formats:
    - Hashtable: @{ ParameterName = 'Value'; AnotherParam = 123 }
    - String: "-Parameter1 Value1 -Parameter2 Value2"
    - Array: @('-Parameter1', 'Value1', '-Parameter2', 'Value2')

    The cmdlet automatically parses these formats and passes them to the script.
    Use Get-AitherScript -Script <number> -ShowParameters to see what parameters a script accepts.

.PARAMETER Parameters
    Alias for Arguments parameter. Use whichever name is more intuitive for you.

.PARAMETER OutputPath
    Path for script output (if script supports it). This is automatically added to the script's
    parameters. Useful for redirecting script output to a specific location.

    Example: "C:\Reports\output.txt" or "/home/user/reports/output.txt"

.PARAMETER ShowHelp
    Show parameter help for the script without executing. This is useful to understand what
    parameters a script accepts before running it. Displays all parameters with their types,
    mandatory status, and descriptions.

.PARAMETER ListParameters
    List all available parameters for the script. Similar to ShowHelp but in a more compact format.

.PARAMETER DryRun
    Show what would be executed without actually running the script. Displays the script path
    and all parameters that would be passed. Use this to verify your command before execution.

.PARAMETER Transcript
    Enable transcript logging for this script execution. Default is $true (enabled).
    Transcripts capture all console output and are saved to the logs directory with a timestamp.
    Set to $false to disable transcript logging.

.PARAMETER TranscriptPath
    Custom path for transcript log. If not specified, transcripts are saved to the logs directory
    with an automatically generated filename based on the script name and timestamp.

.INPUTS
    System.String
    You can pipe script identifiers to Invoke-AitherScript.

    PSCustomObject
    You can pipe script objects from Get-AitherScript to Invoke-AitherScript.

.OUTPUTS
    The output depends on what the script returns. Most scripts return objects or write to console.

.EXAMPLE
    Invoke-AitherScript -Script 0501

    Executes script 0501 with default parameters and transcript logging enabled.

.EXAMPLE
    Invoke-AitherScript -Script 0501 -Arguments @{ OutputFormat = 'Detailed' }

    Executes script 0501 with OutputFormat parameter set to 'Detailed'.

.EXAMPLE
    Invoke-AitherScript -Script 0501 -Arguments "-OutputFormat Detailed -AsJson"

    Executes script 0501 with multiple parameters provided as a string.

.EXAMPLE
    Invoke-AitherScript -Script 0501 -ShowHelp

    Shows help for script 0501 without executing it.

.EXAMPLE
    Invoke-AitherScript -Script 0501 -OutputPath "C:\my home\dir with spaces\"

    Executes script 0501 and passes OutputPath parameter with a path containing spaces.

.EXAMPLE
    Get-AitherScript -Script 0501 | Invoke-AitherScript -Arguments @{ Verbose = $true }

    Pipes script information from Get-AitherScript to Invoke-AitherScript.

.EXAMPLE
    '0501', '0502' | Invoke-AitherScript

    Executes multiple scripts by piping script identifiers.

.EXAMPLE
    Invoke-AitherScript -Script 0501 -DryRun

    Shows what would be executed without actually running the script.

.NOTES
    Scripts are located in library/automation-scripts/ directory.
    Each script has its own transcript logging enabled by default for debugging and auditing.

    If a script fails, check the transcript log in the logs directory for detailed error information.
    Transcript logs are named: transcript-<scriptname>-<timestamp>.log

.LINK
    Get-AitherScript
    Get-AitherExecutionHistory
#>

function Invoke-AitherScript {
    [OutputType([System.Object])]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "The script identifier (number or name) to execute.")]
        [Alias('ScriptNumber', 'Number', 'Id')]
        [AllowEmptyString()]
        [ArgumentCompleter({
                param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

                if (Get-Command Get-AitherScript -ErrorAction SilentlyContinue) {
                    Get-AitherScript |
                    Where-Object { $_.Name -like "*$wordToComplete*" -or $_.Number -like "$wordToComplete*" } |
                    ForEach-Object {
                        [System.Management.Automation.CompletionResult]::new(
                            $_.Number,
                            $_.Name,
                            [System.Management.Automation.CompletionResultType]::ParameterValue,
                            $_.Name
                        )
                    }
                }
            })]
        [object]$Script,

        [Parameter(HelpMessage = "Arguments to pass to the script (Hashtable, String, or Array).")]
        [Alias('Parameters')]
        [object]$Arguments,

        [Parameter(HelpMessage = "Condition to evaluate before running (Hashtable). Keys: Path, Command, ScriptBlock, Negate.")]
        [Alias('If')]
        [object]$Condition,

        [Parameter(HelpMessage = "Path to redirect script output.")]
        [string]$OutputPath,

        [Parameter(HelpMessage = "Show parameter help for the script without executing.")]
        [switch]$ShowHelp,

        [Parameter(HelpMessage = "List all available parameters for the script.")]
        [switch]$ListParameters,

        [Parameter(HelpMessage = "Show what would be executed without actually running the script.")]
        [switch]$DryRun,

        [Parameter(HelpMessage = "Run scripts in parallel.")]
        [switch]$Parallel,

        [Parameter(HelpMessage = "Maximum number of concurrent scripts.")]
        [int]$ThrottleLimit = 5,

        [Parameter(HelpMessage = "Enable transcript logging.")]
        [bool]$Transcript = $true,

        [Parameter(HelpMessage = "Custom path for transcript log.")]
        [string]$TranscriptPath,

        [Parameter(HelpMessage = "Show script output in console.")]
        [switch]$ShowOutput,

        [Parameter(HelpMessage = "Display the transcript content after execution.")]
        [switch]$ShowTranscript
    )

    begin {
        # Manage logging targets for this execution
        $originalLogTargets = $script:AitherLogTargets
        if ($ShowOutput) {
            if ($script:AitherLogTargets -notcontains 'Console') {
                $script:AitherLogTargets += 'Console'
            }
        }
        else {
            # Ensure Console is NOT in targets if ShowOutput is not specified
            $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' }
        }

        # Get scripts directory using robust discovery
        try {
            $scriptsPath = Get-AitherScriptsPath
            Write-Verbose "Using scripts path: $scriptsPath"
        }
        catch {
            Write-AitherLog -Level Warning -Message "Could not resolve scripts path using Get-AitherScriptsPath: $($_.Exception.Message)" -Source 'Invoke-AitherScript'
            # Fallback for extreme cases
            $scriptsPath = Join-Path $PSScriptRoot "library/automation-scripts"
        }

        Write-Verbose "ScriptsPath resolved to: $scriptsPath"

        if (-not (Test-Path $scriptsPath)) {
            Write-AitherLog -Level Error -Message "Scripts path does not exist: $scriptsPath" -Source 'Invoke-AitherScript'
            throw "Scripts path does not exist: $scriptsPath"
        }

        # Use shared helper function

        function Get-ScriptParameters {
            param([string]$ScriptPath)

            try {
                $ast = [System.Management.Automation.Language.Parser]::ParseFile(
                    $ScriptPath,
                    [ref]$null,
                    [ref]$null
                )

                $params = @{}
                $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.ParameterAst] }, $true) |
                ForEach-Object {
                    $paramName = $_.Name.VariablePath.UserPath
                    $paramInfo = @{
                        Name      = $paramName
                        Type      = if ($_.TypeName) { $_.TypeName.FullName } else { 'object' }
                        Mandatory = $_.Attributes.Where({ $_.TypeName.Name -eq 'Parameter' }).Attributes.Where({ $_.NamedArguments.ArgumentName -eq 'Mandatory' }).Argument.Value -eq $true
                        Help      = ''
                    }

                    # Try to get help from comment-based help
                    $help = $null
                    try {
                        $commentAstType = [System.Management.Automation.Language.CommentAst]
                        $help = $ast.FindAll({ param($node) $node -is $commentAstType }, $true) |
                        Where-Object { $_.Text -match "\.PARAMETER\s+$paramName" } |
                        Select-Object -First 1
                    }
                    catch {
                        # Fallback: skip help extraction if AST type not available
                        $help = $null
                    }

                    if ($help) {
                        $lines = $help.Text -split "`n"
                        $inParam = $false
                        $helpText = @()
                        foreach ($line in $lines) {
                            if ($line -match "\.PARAMETER\s+$paramName") {
                                $inParam = $true
                                continue
                            }
                            if ($inParam -and $line -match '^\s*\.') {
                                break
                            }
                            if ($inParam) {
                                $helpText += $line.Trim()
                            }
                        }
                        $paramInfo.Help = ($helpText -join ' ').Trim()
                    }

                    $params[$paramName] = $paramInfo
                }
                return $params
            }
            catch {
                Write-AitherLog -Level Warning -Message "Failed to parse script parameters: $_" -Source 'Invoke-AitherScript'
                return @{}
            }
        }

        function Format-Arguments {
            param(
                [object]$Arguments,
                [hashtable]$ScriptParams
            )

            $paramHash = @{}
            if ($null -eq $Arguments) {
                return $paramHash
            }

            # Handle hashtable
            if ($Arguments -is [hashtable]) {
                return $Arguments
            }

            # Handle string arguments
            if ($Arguments -is [string]) {
                # Parse string like "-Parameter1 Value1 -Parameter2 Value2"
                $parts = $Arguments -split '\s+(?=-)' | Where-Object { $_ }
                for ($i = 0; $i -lt $parts.Length; $i++) {
                    if ($parts[$i] -match '^-(\w+)') {
                        $paramName = $matches[1]
                        $paramValue = $null

                        # Check if value is in the same part (e.g., "-Path ./src")
                        # Regex replace to remove -ParamName and leading spaces
                        $rest = $parts[$i] -replace "^-$paramName\s*", ""

                        if (-not [string]::IsNullOrWhiteSpace($rest)) {
                            $paramValue = $rest.Trim()
                            # Strip surrounding quotes if present
                            if ($paramValue -match "^['`"](.*)['`"]$") {
                                $paramValue = $matches[1]
                            }
                        }
                        elseif ($i + 1 -lt $parts.Length -and $parts[$i + 1] -notmatch '^-') {
                            $paramValue = $parts[$i + 1]
                            $i++
                        }
                        else {
                            $paramValue = $true  # Switch parameter
                        }

                        $paramHash[$paramName] = $paramValue
                    }
                }
                return $paramHash
            }

            # Handle array
            if ($Arguments -is [array]) {
                for ($i = 0; $i -lt $Arguments.Length; $i++) {
                    if ($Arguments[$i] -match '^-(\w+)') {
                        $paramName = $matches[1]
                        $paramValue = $null

                        if ($i + 1 -lt $Arguments.Length -and $Arguments[$i + 1] -notmatch '^-') {
                            $paramValue = $Arguments[$i + 1]
                            $i++
                        }
                        else {
                            $paramValue = $true
                        }

                        $paramHash[$paramName] = $paramValue
                    }
                }
                return $paramHash
            }

            return $paramHash
        }
    }

    process {
        # Manage logging targets for this execution
        $originalLogTargets = $script:AitherLogTargets
        if ($ShowOutput) {
            if ($script:AitherLogTargets -notcontains 'Console') {
                $script:AitherLogTargets += 'Console'
            }
        }
        else {
            # Ensure Console is NOT in targets if ShowOutput is not specified
            # This enforces "no output by default" even if global default changes
            $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' }
        }

        try {
            # During module validation, Script may be empty - skip validation
            if ($PSCmdlet.MyInvocation.InvocationName -eq '.' -and [string]::IsNullOrWhiteSpace($Script)) {
                return
            }

            # Handle piped script objects or multiple scripts
            $scriptsToRun = @()
            if ($Script -is [PSCustomObject] -and $Script.Path) {
                $scriptsToRun += @{
                    Path = $Script.Path
                    Id   = if ($Script.Number) { $Script.Number } else { $Script.Name }
                }
            }
            elseif ($Script -is [array]) {
                foreach ($s in $Script) {
                    $scriptFile = Find-AitherScriptFile -ScriptId $s -ScriptsPath $scriptsPath -ThrowOnNotFound
                    $scriptsToRun += @{
                        Path = $scriptFile.FullName
                        Id   = $s
                    }
                }
            }
            else {
                # Find script file
                $scriptFile = Find-AitherScriptFile -ScriptId $Script -ScriptsPath $scriptsPath -ThrowOnNotFound
                $scriptsToRun += @{
                    Path = $scriptFile.FullName
                    Id   = $Script
                }
            }

            # Parallel Execution
            if ($Parallel -and $scriptsToRun.Count -gt 1) {
                Write-ScriptLog "Executing $($scriptsToRun.Count) scripts in parallel (ThrottleLimit: $ThrottleLimit)"

                $scriptsToRun | ForEach-Object -Parallel {
                    $scriptInfo = $_
                    $scriptPath = $scriptInfo.Path
                    $scriptId = $scriptInfo.Id

                    # Re-import module in parallel runspace if needed, or rely on scope
                    # Note: Functions defined in 'begin' block are NOT available here automatically
                    # We need to duplicate logic or use a shared module

                    # Simple execution for now
                    Write-AitherLog -Level Information -Message "Starting parallel execution: $scriptId" -Source 'Invoke-AitherScript'
                    try {
                        & $scriptPath
                    }
                    catch {
                        Write-AitherLog -Level Error -Message "Failed executing $scriptId : $_" -Source 'Invoke-AitherScript' -Exception $_
                        throw
                    }
                } -ThrottleLimit $ThrottleLimit

                return
            }

            foreach ($scriptItem in $scriptsToRun) {
                $scriptPath = $scriptItem.Path
                $scriptId = $scriptItem.Id
                Write-ScriptLog "Found script: $scriptPath"

                # Get script parameters
                $scriptParams = Get-ScriptParameters -ScriptPath $scriptPath

                # Handle help requests
                if ($ShowHelp -or $ListParameters) {
                    Write-AitherLog -Level Information -Message "Script: $scriptId" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message "Path: $scriptPath" -Source 'Invoke-AitherScript'

                    if ($scriptParams.Count -eq 0) {
                        Write-AitherLog -Level Warning -Message "No parameters found or script could not be parsed." -Source 'Invoke-AitherScript'
                        continue
                    }

                    Write-AitherLog -Level Information -Message "Available Parameters:" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message ("=" * 50) -Source 'Invoke-AitherScript'

                    foreach ($param in $scriptParams.Values | Sort-Object Name) {
                        $mandatory = if ($param.Mandatory) { "[MANDATORY] " } else { "" }
                        $paramLine = "-$($param.Name) $mandatory($($param.Type))"
                        Write-AitherLog -Level Information -Message $paramLine -Source 'Invoke-AitherScript'

                        if ($param.Help) {
                            Write-AitherLog -Level Information -Message " $($param.Help)" -Source 'Invoke-AitherScript'
                        }
                    }

                    continue
                }

                # Format arguments
                $argsToUse = if ($Arguments) { $Arguments } else { $Parameters }
                $paramHash = Format-Arguments -Arguments $argsToUse -ScriptParams $scriptParams

                # Add OutputPath if specified
                if ($OutputPath) {
                    $paramHash['OutputPath'] = $OutputPath
                }

                # Add Configuration if Get-AitherConfigs is available AND script accepts it
                if ($scriptParams.ContainsKey('Configuration') -and (Get-Command Get-AitherConfigs -ErrorAction SilentlyContinue)) {
                    $config = Get-AitherConfigs
                    $paramHash['Configuration'] = $config
                }

                # Check Condition (Idempotency)
                if ($Condition) {
                    Write-ScriptLog "Evaluating execution condition..."

                    if (Get-Command Test-AitherCondition -ErrorAction SilentlyContinue) {
                        # Flatten hashtable for splatting to Test-AitherCondition
                        # Note: Test-AitherCondition accepts Path, Command, ScriptBlock, Negate
                        $conditionResult = $true
                        try {
                            $conditionResult = Test-AitherCondition @Condition
                        }
                        catch {
                            Write-AitherLog -Level Warning -Message "Failed to evaluate condition: $_" -Source 'Invoke-AitherScript' -Exception $_
                            # Default to running if check fails, or fail safely?
                            # Safer to fail or run? Let's assume run if check fails is risky, but failing stops workflow.
                            # For now, treat error as 'False' (don't run) or just log.
                            # Let's treat as FALSE to prevent destructive actions if check is bad.
                            $conditionResult = $false
                        }

                        if (-not $conditionResult) {
                            Write-AitherLog -Level Warning -Message "Condition met (Result: False). Skipping script execution." -Source 'Invoke-AitherScript'
                            Write-AitherLog -Level Information -Message "Skipping $scriptId (Condition not met)" -Source 'Invoke-AitherScript'
                            continue
                        }
                    }
                    else {
                        Write-AitherLog -Level Warning -Message "Test-AitherCondition cmdlet not found. Skipping condition check." -Source 'Invoke-AitherScript'
                    }
                }

                # Dry run
                if ($DryRun) {
                    Write-AitherLog -Level Information -Message "[DRY RUN] Would execute:" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message " Script: $scriptPath" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message " Parameters:" -Source 'Invoke-AitherScript'
                    $paramHash.GetEnumerator() | ForEach-Object {
                        Write-AitherLog -Level Information -Message " -$($_.Key): $($_.Value)" -Source 'Invoke-AitherScript'
                    }
                    continue
                }

                # Start transcript if enabled
                $transcriptStarted = $false
                if ($Transcript) {
                    try {
                        if (-not $TranscriptPath) {
                            $logsDir = Join-Path $moduleRoot 'logs'
                            if (-not (Test-Path $logsDir)) {
                                New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
                            }
                            $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($scriptPath)
                            $currentTranscriptPath = Join-Path $logsDir "transcript-${scriptName}-$(Get-Date -Format 'yyyy-MM-dd-HHmmss').log"
                        }
                        else {
                            $currentTranscriptPath = $TranscriptPath
                        }

                        # Try to stop any existing transcript first, ignoring errors if none is running
                        try { Stop-Transcript -ErrorAction Stop | Out-Null } catch { }

                        Start-Transcript -Path $currentTranscriptPath -Append -IncludeInvocationHeader | Out-Null
                        $transcriptStarted = $true
                        Write-AitherLog -Level Information -Message "Transcript logging enabled: $currentTranscriptPath" -Source 'Invoke-AitherScript'
                    }
                    catch {
                        Write-AitherLog -Level Warning -Message "Failed to start transcript: $_" -Source 'Invoke-AitherScript' -Exception $_
                    }
                }

                # Execute script
                try {
                    Write-ScriptLog "Executing script: $scriptId"

                    if ($PSCmdlet.ShouldProcess($scriptPath, "Execute script")) {
                        & $scriptPath @paramHash
                    }
                }
                finally {
                    if ($transcriptStarted) {
                        try {
                            Stop-Transcript | Out-Null
                        }
                        catch {
                            # Ignore errors stopping transcript
                        }

                        if ($ShowTranscript) {
                            Write-AitherLog -Level Information -Message "--- Transcript: $currentTranscriptPath ---" -Source 'Invoke-AitherScript'
                            if (Test-Path $currentTranscriptPath) {
                                Get-Content $currentTranscriptPath | ForEach-Object { Write-AitherLog -Level Information -Message $_ -Source 'Invoke-AitherScript' }
                            }
                            Write-AitherLog -Level Information -Message "--- End Transcript ---" -Source 'Invoke-AitherScript'
                        }
                    }
                }
            }
        }
        catch {
            Invoke-AitherErrorHandler -ErrorRecord $_ -Operation "Invoking script: $Script" -Parameters $PSBoundParameters -ThrowOnError
        }
        finally {
            # Restore original log targets
            $script:AitherLogTargets = $originalLogTargets
        }
    }


}