Stepper.psm1

function Find-NewStepBlocks {
    <#
    .SYNOPSIS
        Finds all New-Step blocks and Stop-Stepper line in a script.

    .PARAMETER ScriptLines
        Array of script lines to analyze.

    .OUTPUTS
        Hashtable with NewStepBlocks array and StopStepperLine.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$ScriptLines
    )

    $newStepBlocks = @()
    $stopStepperLine = -1

    for ($i = 0; $i -lt $ScriptLines.Count; $i++) {
        if ($ScriptLines[$i] -match '^\s*New-Step\s+\{') {
            # Find the closing brace for this New-Step block
            $braceCount = 0
            $blockStart = $i
            $blockEnd = -1

            for ($j = $i; $j -lt $ScriptLines.Count; $j++) {
                $line = $ScriptLines[$j]
                $braceCount += ($line.ToCharArray() | Where-Object { $_ -eq '{' }).Count
                $braceCount -= ($line.ToCharArray() | Where-Object { $_ -eq '}' }).Count

                if ($braceCount -eq 0 -and $j -gt $i) {
                    $blockEnd = $j
                    break
                }
            }

            if ($blockEnd -ge 0) {
                $newStepBlocks += @{
                    Start = $blockStart
                    End   = $blockEnd
                }
            }
        }
        if ($ScriptLines[$i] -match '^\s*Stop-Stepper') {
            $stopStepperLine = $i
            break
        }
    }

    return @{
        NewStepBlocks   = $newStepBlocks
        StopStepperLine = $stopStepperLine
    }
}

function Find-NonResumableCodeBlocks {
    <#
    .SYNOPSIS
        Identifies non-resumable code blocks between New-Step blocks.

    .PARAMETER ScriptLines
        Array of script lines to analyze.

    .PARAMETER NewStepBlocks
        Array of New-Step block definitions (Start/End).

    .PARAMETER StopStepperLine
        Line number where Stop-Stepper is located.

    .OUTPUTS
        Array of non-resumable code blocks with Lines and IsBeforeStop properties.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$ScriptLines,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$NewStepBlocks,

        [Parameter(Mandatory)]
        [int]$StopStepperLine
    )

    $nonResumableBlocks = @()

    # Find all Stepper ignore regions
    $ignoredRegions = @()
    $inIgnoreRegion = $false
    $regionStart = -1
    for ($i = 0; $i -lt $ScriptLines.Count; $i++) {
        $line = $ScriptLines[$i].Trim()
        if ($line -match '^\s*#region\s+Stepper\s+ignore') {
            $inIgnoreRegion = $true
            $regionStart = $i
        }
        elseif ($line -match '^\s*#endregion\s+Stepper\s+ignore' -and $inIgnoreRegion) {
            $ignoredRegions += @{
                Start = $regionStart
                End   = $i
            }
            $inIgnoreRegion = $false
        }
    }

    # Find all multi-line comment blocks
    $commentBlocks = @()
    $inCommentBlock = $false
    $commentStart = -1
    for ($i = 0; $i -lt $ScriptLines.Count; $i++) {
        $line = $ScriptLines[$i]
        if (-not $inCommentBlock -and $line -match '<#') {
            $inCommentBlock = $true
            $commentStart = $i
        }
        if ($inCommentBlock -and $line -match '#>') {
            $commentBlocks += @{
                Start = $commentStart
                End   = $i
            }
            $inCommentBlock = $false
        }
    }

    if ($NewStepBlocks.Count -gt 0) {
        # Check code BEFORE the first New-Step block
        $firstBlock = $NewStepBlocks[0]
        $blockLines = @()
        for ($j = 0; $j -lt $firstBlock.Start; $j++) {
            # Skip if line is in an ignored region
            if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $ignoredRegions) {
                continue
            }

            # Skip if line is in a multi-line comment block
            if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $commentBlocks) {
                continue
            }

            $line = $ScriptLines[$j].Trim()
            # Skip comments, empty lines, and common non-executable statements
            if ($line -and
                $line -notmatch '^\s*#' -and
                $line -notmatch '^\s*\[CmdletBinding\(' -and
                $line -notmatch '^\s*param\s*\(' -and
                $line -notmatch '^\s*using\s+(namespace|module|assembly)' -and
                $line -notmatch '^\s*\)\s*$' -and
                $line -ne '.') {
                $blockLines += $j
            }
        }

        if ($blockLines.Count -gt 0) {
            $nonResumableBlocks += @{
                Lines        = $blockLines
                IsBeforeStop = $false
            }
        }

        # Check between consecutive New-Step blocks
        for ($i = 0; $i -lt $NewStepBlocks.Count - 1; $i++) {
            $gapStart = $NewStepBlocks[$i].End + 1
            $gapEnd = $NewStepBlocks[$i + 1].Start - 1

            $blockLines = @()
            for ($j = $gapStart; $j -le $gapEnd; $j++) {
                # Skip if line is in an ignored region
                if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $ignoredRegions) {
                    continue
                }

                # Skip if line is in a multi-line comment block
                if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $commentBlocks) {
                    continue
                }

                $line = $ScriptLines[$j].Trim()
                if ($line -and $line -notmatch '^\s*#') {
                    $blockLines += $j
                }
            }

            if ($blockLines.Count -gt 0) {
                $nonResumableBlocks += @{
                    Lines        = $blockLines
                    IsBeforeStop = $false
                }
            }
        }

        # Check between last New-Step and Stop-Stepper (or end of file if no Stop-Stepper)
        $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1]
        $gapStart = $lastBlock.End + 1
        # If Stop-Stepper exists, check up to it; otherwise check to end of file
        $gapEnd = if ($StopStepperLine -ge 0) {
            $StopStepperLine - 1 
        }
        else {
            $ScriptLines.Count - 1 
        }

        $blockLines = @()
        for ($j = $gapStart; $j -le $gapEnd; $j++) {
            # Skip if line is in an ignored region
            if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $ignoredRegions) {
                continue
            }

            # Skip if line is in a multi-line comment block
            if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $commentBlocks) {
                continue
            }

            $line = $ScriptLines[$j].Trim()
            if ($line -and $line -notmatch '^\s*#') {
                $blockLines += $j
            }
        }

        if ($blockLines.Count -gt 0) {
            $nonResumableBlocks += @{
                Lines        = $blockLines
                IsBeforeStop = ($StopStepperLine -ge 0)
            }
        }
    }

    return $nonResumableBlocks
}

function Get-NonResumableCodeAction {
    <#
    .SYNOPSIS
        Prompts user for action on a non-resumable code block.

    .PARAMETER ScriptName
        Name of the script file.

    .PARAMETER ScriptLines
        Array of script lines.

    .PARAMETER Block
        Code block with Lines and IsBeforeStop properties.

    .OUTPUTS
        String with chosen action: 'Wrap', 'MarkIgnored', 'Delete', or 'Ignore'.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$ScriptLines,

        [Parameter(Mandatory)]
        [hashtable]$Block
    )

    $blockLineNums = $Block.Lines | ForEach-Object { $_ + 1 }  # Convert to 1-based
    $blockCode = $Block.Lines | ForEach-Object { $ScriptLines[$_].Trim() }
    $hasStepperVar = ($blockCode -join ' ') -match '\$Stepper\.'

    Write-Host ""
    Write-Host "[!] Non-resumable code detected in ${ScriptName}." -ForegroundColor Magenta
    Write-Host " This code will execute on every run of this script," -ForegroundColor Magenta
    Write-Host " including resumed runs:" -ForegroundColor Magenta
    Write-Host ""
    foreach ($lineNum in $blockLineNums) {
        $lineContent = $ScriptLines[$lineNum - 1].Trim()
        Write-Host " ${lineNum}: $lineContent" -ForegroundColor Gray
    }
    Write-Host ""

    Write-Host "How would you like to handle this?"
    Write-Host ""
    Write-Host " [W] Wrap in New-Step block (Default)" -ForegroundColor Cyan
    Write-Host " [M] Mark as expected to ignore this code on future script runs" -ForegroundColor White
    Write-Host " [D] Delete this code" -ForegroundColor White
    if ($hasStepperVar) {
        Write-Host " WARNING: Because this code references `$Stepper variables," -ForegroundColor Yellow
        Write-Host " deleting it may impact functionality." -ForegroundColor Yellow
    }
    Write-Host " [I] Ignore and continue" -ForegroundColor White
    Write-Host " [Q] Quit" -ForegroundColor White
    Write-Host ""

    Write-Host "Choice? [" -NoNewline
    Write-Host "W" -NoNewline -ForegroundColor Cyan
    Write-Host "/m/d/i/q]: " -NoNewline
    $choice = Read-Host

    switch ($choice.ToLower()) {
        'w' {
            return 'Wrap' 
        }
        '' {
            return 'Wrap' 
        }  # Default to Wrap
        'm' {
            return 'MarkIgnored' 
        }
        'd' {
            return 'Delete' 
        }
        'i' {
            return 'Ignore' 
        }
        'q' {
            return 'Quit' 
        }
        default {
            return 'Wrap' 
        }  # Default to Wrap
    }
}

function Get-ScriptHash {
    <#
    .SYNOPSIS
        Calculates SHA256 hash of a script file.

    .DESCRIPTION
        Reads the content of a script file and returns its SHA256 hash.
        Used to detect if the script has been modified since the last run.

    .PARAMETER ScriptPath
        The path to the script file.

    .OUTPUTS
        System.String - SHA256 hash of the script content
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ScriptPath
    )
    
    try {
        $content = Get-Content -Path $ScriptPath -Raw -ErrorAction Stop
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($content)
        $hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
        [System.BitConverter]::ToString($hash).Replace('-', '')
    }
    catch {
        throw "Failed to calculate hash for script '$ScriptPath': $_"
    }
}

function Get-StepIdentifier {
    <#
    .SYNOPSIS
        Gets a unique identifier for the current step based on caller location.

    .DESCRIPTION
        Analyzes the call stack to find the script and line number where New-Step was called.
        Returns an identifier in the format "filepath:line".

    .OUTPUTS
        System.String - Step identifier (e.g., "C:\script.ps1:42")
    #>

    [CmdletBinding()]
    param()

    $callStack = Get-PSCallStack

    # Walk up the call stack to find the first non-module caller
    # Stack typically looks like: [0]=Get-StepIdentifier, [1]=New-Step, [2]=UserScript
    for ($i = 0; $i -lt $callStack.Count; $i++) {
        $frame = $callStack[$i]
        $scriptName = $frame.ScriptName

        # Skip frames without a script name
        if (-not $scriptName) {
            continue
        }

        # Skip frames from the Stepper module directory
        $stepperDir = Split-Path -Path $PSScriptRoot -Parent
        # Normalize paths for cross-platform comparison
        $normalizedScript = $scriptName -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
        $normalizedStepperDir = $stepperDir -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar

        # Skip if it's from the module's Private/Public folders or the main PSM1
        if ($normalizedScript -like "$normalizedStepperDir$([System.IO.Path]::DirectorySeparatorChar)Private$([System.IO.Path]::DirectorySeparatorChar)*" -or
            $normalizedScript -like "$normalizedStepperDir$([System.IO.Path]::DirectorySeparatorChar)Public$([System.IO.Path]::DirectorySeparatorChar)*" -or
            $normalizedScript -like "$normalizedStepperDir$([System.IO.Path]::DirectorySeparatorChar)Stepper.psm1" -or
            $normalizedScript -like "*$([System.IO.Path]::DirectorySeparatorChar)Modules$([System.IO.Path]::DirectorySeparatorChar)Stepper$([System.IO.Path]::DirectorySeparatorChar)*") {
            continue
        }

        # This is the user's script - return its location
        $line = $frame.ScriptLineNumber
        return "${scriptName}:${line}"
    }

    throw "Unable to determine step identifier from call stack"
}

function Get-StepperStatePath {
    <#
    .SYNOPSIS
        Gets the path to the Stepper state file for the calling script.
    
    .DESCRIPTION
        Generates a state file path based on the calling script's location.
        State files are stored in the same directory as the script with a .stepper extension.
    
    .PARAMETER ScriptPath
        The path to the script file.
    
    .OUTPUTS
        System.String - Path to the state file
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ScriptPath
    )
    
    $scriptDir = Split-Path -Path $ScriptPath -Parent
    $scriptName = Split-Path -Path $ScriptPath -Leaf
    $stateFileName = "$scriptName.stepper"
    
    Join-Path -Path $scriptDir -ChildPath $stateFileName
}

function Read-StepperState {
    <#
    .SYNOPSIS
        Reads the Stepper state file.

    .DESCRIPTION
        Reads and deserializes the state file if it exists.
        Returns null if the file doesn't exist or can't be read.

    .PARAMETER StatePath
        The path to the state file.

    .OUTPUTS
        PSCustomObject or $null - The state object containing ScriptHash, ScriptContents, LastCompletedStep, Timestamp, and StepperData
    #>

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

    if (-not (Test-Path -Path $StatePath)) {
        return $null
    }

    try {
        Import-Clixml -Path $StatePath -ErrorAction Stop
    }
    catch {
        Write-Warning "Failed to read state file '$StatePath': $_"
        return $null
    }
}

function Remove-StepperState {
    <#
    .SYNOPSIS
        Removes the Stepper state file.
    
    .DESCRIPTION
        Deletes the state file if it exists.
        Used when starting fresh or when the script completes successfully.
    
    .PARAMETER StatePath
        The path to the state file.
    
    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$StatePath
    )
    
    if (Test-Path -Path $StatePath) {
        try {
            Remove-Item -Path $StatePath -Force -ErrorAction Stop
        }
        catch {
            Write-Warning "Failed to remove state file '$StatePath': $_"
        }
    }
}

function Show-MoreDetails {
    <#
    .SYNOPSIS
        Displays detailed state and script context for an incomplete run.

    .PARAMETER ExistingState
        The object returned by Read-StepperState.

    .PARAMETER ScriptPath
        Path to the current script.

    .PARAMETER CurrentHash
        The current script hash.

    .PARAMETER LastStep
        Identifier of the last completed step (format: "path:line").

    .PARAMETER NextStepLine
        Line number where the next step will execute.

    .PARAMETER ShowHashComparison
        If set, prints both previous and current script hashes (used when script differs).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [pscustomobject]$ExistingState,
        [Parameter(Mandatory)] [string]$ScriptPath,
        [Parameter(Mandatory)] [string]$CurrentHash,
        [Parameter(Mandatory)] [string]$LastStep,
        [Parameter(Mandatory)] [int]$NextStepLine,
        [switch]$ShowHashComparison
    )

    Write-Host ""
    Write-Host "More details:" -ForegroundColor Yellow
    Write-Host ""
    if ($ShowHashComparison) {
        Write-Host " Previous script hash: $($ExistingState.ScriptHash)"
    }
    Write-Host " Current script hash: $CurrentHash"

    # Show Stepper variables and their contents
    Write-Host ""
    Write-Host "Stepper variables:"
    Write-Host ""
    if ($ExistingState.StepperData -and $ExistingState.StepperData.Count -gt 0) {
        foreach ($var in ($ExistingState.StepperData.Keys | Sort-Object)) {
            $val = $ExistingState.StepperData[$var]
            try {
                $valStr = $val | ConvertTo-Json -Depth 4 -ErrorAction Stop
            }
            catch {
                $valStr = ($val | Out-String).TrimEnd()
            }

            if ($valStr -match "`n") {
                Write-Host (" {0}:" -f $var)
                $valStr -split "`n" | ForEach-Object { Write-Host " $_" }
            }
            else {
                Write-Host (" {0}: {1}" -f $var, $valStr)
            }
        }
    }
    else {
        Write-Host " None"
    }

    # Show full contents of the last completed New-Step using saved script contents if available
    Write-Host ""
    Write-Host "Last completed step:"
    Write-Host ""
    if ($ExistingState.ScriptContents) {
        $prevLines = $ExistingState.ScriptContents -split "`n"
        $prevStepLine = $null
        if ($LastStep -and ($LastStep -match ':(\d+)$')) {
            $prevStepLine = [int]$Matches[1]
        }
        else {
            Write-Host " No valid LastCompletedStep recorded." -ForegroundColor Yellow
        }

        # Attempt to extract the full New-Step block using PowerShell's AST,
        # so that braces inside strings or comments don't confuse the logic.
        $block = @()
        $tokens = $null
        $parseErrors = $null
        $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput(
            $ExistingState.ScriptContents,
            [ref]$tokens,
            [ref]$parseErrors
        )

        if (-not $parseErrors -or $parseErrors.Count -eq 0) {
            # Find the New-Step command that contains the previous step line, then get its script-block argument
            $cmdAst = $scriptAst.Find(
                {
                    param($node)
                    $node -is [System.Management.Automation.Language.CommandAst] -and
                    ($node.GetCommandName() -eq 'New-Step') -and
                    $node.Extent.StartLineNumber -le $prevStepLine -and
                    $node.Extent.EndLineNumber -ge $prevStepLine
                },
                $true
            )

            # Collect all New-Step commands with scriptblock arguments
            $cmdList = $scriptAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] -and ($node.GetCommandName() -eq 'New-Step') }, $true) |
                ForEach-Object {
                    $sb = $_.CommandElements | Where-Object { $_ -is [System.Management.Automation.Language.ScriptBlockAst] } | Select-Object -First 1
                    if ($sb) {
                        [pscustomobject]@{
                            Command     = $_
                            ScriptBlock = $sb
                            Start       = $sb.Extent.StartLineNumber
                            End         = $sb.Extent.EndLineNumber
                            CmdStart    = $_.Extent.StartLineNumber
                            CmdEnd      = $_.Extent.EndLineNumber
                        }
                    }
                }

            $scriptBlockAst = $null

            # Prefer a scriptblock that actually spans the previous step line
            $candidate = $cmdList | Where-Object { $_.Start -le $prevStepLine -and $_.End -ge $prevStepLine } | Select-Object -First 1

            # If none span the line, prefer a command whose declaration line matches the step line
            if (-not $candidate) {
                $candidate = $cmdList | Where-Object { $_.CmdStart -eq $prevStepLine } | Select-Object -First 1
            }

            # If still none, pick the closest preceding scriptblock (highest Start <= prevStepLine)
            if (-not $candidate) {
                $candidate = $cmdList | Where-Object { $_.Start -lt $prevStepLine } | Sort-Object -Property Start -Descending | Select-Object -First 1
            }

            if ($candidate) {
                $scriptBlockAst = $candidate.ScriptBlock
                $startLine = $candidate.Start
                $endLine = $candidate.End

                for ($i = $startLine; $i -le $endLine -and $i -le $prevLines.Count; $i++) {
                    $block += $prevLines[$i - 1]
                }
            }
            else {
                Write-Verbose "Show-MoreDetails: AST lookup failed for New-Step around line $prevStepLine; attempting manual brace-match fallback"

                # Search upward for the nearest 'New-Step {' start line
                $foundStart = $null
                for ($s = $prevStepLine; $s -ge 1; $s--) {
                    if ($prevLines[$s - 1] -match '^\s*New-Step\s*\{') {
                        $foundStart = $s
                        break
                    }
                }

                if ($foundStart) {
                    $level = 0
                    $endLine = $foundStart

                    for ($i = $foundStart; $i -le $prevLines.Count; $i++) {
                        $lineText = $prevLines[$i - 1]

                        # Strip simple quoted strings to reduce brace noise
                        $stripped = $lineText -replace "'[^']*'", '' -replace '"[^"]*"', ''

                        $opens = ($stripped -split '{').Count - 1
                        $closes = ($stripped -split '}').Count - 1
                        $level += $opens - $closes

                        if ($level -le 0) {
                            $endLine = $i
                            break
                        }
                    }

                    if ($endLine -ge $foundStart) {
                        for ($i = $foundStart; $i -le $endLine -and $i -le $prevLines.Count; $i++) {
                            $block += $prevLines[$i - 1]
                        }
                    }
                    else {
                        Write-Verbose "Show-MoreDetails: Manual brace-match failed to find end for New-Step starting at $foundStart"
                    }
                }
                else {
                    Write-Verbose "Show-MoreDetails: No 'New-Step {' found above line $prevStepLine"
                }
            }
        }

        if ($block.Count -gt 0) {
            $block | ForEach-Object { Write-Host " $_" }
        }
        else {
            Write-Host " Unable to extract step body." -ForegroundColor Yellow
        }
    }
    else {
        Write-Host " No saved script contents available." -ForegroundColor Yellow
    }

    # Show context around the restart line in the current script (2 lines before / 3 lines after)
    Write-Host ""
    Write-Host "Context around next restart line:"
    Write-Host ""
    $ns = [int]$NextStepLine
    $start2 = [Math]::Max(1, $ns - 2)
    $end2 = $ns + 3

    # Read only as many lines as we need for context, instead of the entire file
    $contextLines = Get-Content -Path $ScriptPath -TotalCount $end2
    $lastLineToShow = [Math]::Min($end2, $contextLines.Count)

    for ($ln = $start2; $ln -le $lastLineToShow; $ln++) {
        $line = $contextLines[$ln - 1]
        $display = ($line -replace "`t", " ").TrimEnd()
        if ($ln -eq $ns) {
            Write-Host ("{0,5}: {1}" -f $ln, $display) -ForegroundColor Cyan
        }
        else {
            Write-Host ("{0,5}: {1}" -f $ln, $display)
        }
    }

    Write-Host ""

    # Prompt header for available actions
    Write-Host "How would you like to proceed?"
    Write-Host ""
}

function Test-LineInIgnoredRegion {
    <#
    .SYNOPSIS
        Checks if a line is within a Stepper ignore region.

    .PARAMETER LineIndex
        The zero-based line index to check.

    .PARAMETER IgnoredRegions
        Array of hashtables with Start and End properties marking ignored regions.

    .OUTPUTS
        Boolean - True if the line is in an ignored region, false otherwise.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [int]$LineIndex,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [array]$IgnoredRegions
    )

    foreach ($region in $IgnoredRegions) {
        if ($LineIndex -ge $region.Start -and $LineIndex -le $region.End) {
            return $true
        }
    }
    return $false
}

function Test-StepperScriptRequirements {
    <#
    .SYNOPSIS
        Checks if script has required declarations and offers to add them.

    .PARAMETER ScriptPath
        Path to the script to check.

    .OUTPUTS
        $true if script was modified and needs to be re-run, $false otherwise.
    #>

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

    $scriptLines = Get-Content -Path $ScriptPath
    $scriptName = Split-Path $ScriptPath -Leaf

    # Check for CmdletBinding
    $hasCmdletBinding = $scriptLines | Where-Object { $_ -match '^\s*\[CmdletBinding\(\)\]' }

    # Check for #requires statement (case-insensitive)
    $hasRequires = $scriptLines | Where-Object { $_ -match '(?i)^\s*#requires\s+-Modules?\s+Stepper' }

    $needsChanges = -not $hasCmdletBinding -or -not $hasRequires

    if ($needsChanges) {
        Write-Host ""
        Write-Host "[!] Script requirements check for ${scriptName}:" -ForegroundColor Magenta
        Write-Host ""

        if (-not $hasCmdletBinding) {
            Write-Host " Missing [CmdletBinding()] declaration" -ForegroundColor Gray
        }

        if (-not $hasRequires) {
            Write-Host " Missing #requires -Modules Stepper statement" -ForegroundColor Gray
        }

        Write-Host ""
        Write-Host "How would you like to handle this?"
        Write-Host ""
        Write-Host " [A] Add missing declarations (Default)" -ForegroundColor Cyan
        Write-Host " [S] Skip" -ForegroundColor White
        Write-Host " [Q] Quit" -ForegroundColor White
        Write-Host ""
        Write-Host "Choice? [" -NoNewline
        Write-Host "A" -NoNewline -ForegroundColor Cyan
        Write-Host "/s/q]: " -NoNewline
        $response = Read-Host

        if ($response -eq 'Q' -or $response -eq 'q') {
            Write-Host ""
            Write-Host "Exiting..." -ForegroundColor Yellow
            exit
        }

        if ($response -eq '' -or $response -eq 'A' -or $response -eq 'a') {
            $newScriptLines = @()
            $addedDeclarations = $false

            # Find where to insert (after shebang/comments at top, before first code)
            $insertIndex = 0
            for ($i = 0; $i -lt $scriptLines.Count; $i++) {
                $line = $scriptLines[$i].Trim()
                # Skip empty lines, comments (but not #requires), and shebang
                if ($line -eq '' -or $line -match '^#(?!requires)' -or $line -match '^#!/') {
                    $insertIndex = $i + 1
                }
                else {
                    break
                }
            }

            # Copy lines before insertion point
            for ($i = 0; $i -lt $insertIndex; $i++) {
                $newScriptLines += $scriptLines[$i]
            }

            # Add missing declarations
            if (-not $hasRequires) {
                $newScriptLines += "#requires -Modules Stepper"
                $addedDeclarations = $true
            }

            if (-not $hasCmdletBinding) {
                $newScriptLines += "[CmdletBinding()]"
                $newScriptLines += "param()"
                $addedDeclarations = $true
            }

            if ($addedDeclarations) {
                $newScriptLines += ""
            }

            # Copy remaining lines, but skip existing param() if we added one
            $skipNextParam = (-not $hasCmdletBinding)
            for ($i = $insertIndex; $i -lt $scriptLines.Count; $i++) {
                if ($skipNextParam -and $scriptLines[$i] -match '^\s*param\s*\(\s*\)\s*$') {
                    $skipNextParam = $false
                    continue
                }
                $newScriptLines += $scriptLines[$i]
            }

            # Write back to file
            $newScriptLines | Set-Content -Path $ScriptPath -Force

            # Delete state file since script was modified
            $statePath = Get-StepperStatePath -ScriptPath $ScriptPath
            Remove-StepperState -StatePath $statePath

            Write-Host ""
            Write-Host "Declarations added. Please re-run $scriptName." -ForegroundColor Green
            return $true
        }
    }

    return $false
}

function Update-ScriptWithNonResumableActions {
    <#
    .SYNOPSIS
        Applies wrap/move/delete actions to a script.

    .PARAMETER ScriptPath
        Path to the script file to update.

    .PARAMETER ScriptLines
        Array of script lines.

    .PARAMETER Actions
        Hashtable mapping line indices to actions (Wrap/MarkIgnored/Delete).

    .PARAMETER NewStepBlocks
        Array of New-Step block definitions.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$ScriptLines,

        [Parameter(Mandatory)]
        [hashtable]$Actions,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$NewStepBlocks
    )

    $newScriptLines = @()
    $linesToMove = @()

    # Group lines to wrap by consecutive sequences
    $linesToWrap = @($Actions.Keys | Where-Object { $Actions[$_].Action -eq 'Wrap' } | Sort-Object)
    $wrapGroups = @()
    if ($linesToWrap.Count -gt 0) {
        $currentGroup = @($linesToWrap[0])
        for ($i = 1; $i -lt $linesToWrap.Count; $i++) {
            if ($linesToWrap[$i] -eq $linesToWrap[$i - 1] + 1) {
                $currentGroup += $linesToWrap[$i]
            }
            else {
                $wrapGroups += , @($currentGroup)
                $currentGroup = @($linesToWrap[$i])
            }
        }
        $wrapGroups += , @($currentGroup)
    }

    # Group lines to mark as ignored by consecutive sequences
    $linesToMarkIgnored = @($Actions.Keys | Where-Object { $Actions[$_].Action -eq 'MarkIgnored' } | Sort-Object)
    $markIgnoredGroups = @()
    if ($linesToMarkIgnored.Count -gt 0) {
        $currentGroup = @($linesToMarkIgnored[0])
        for ($i = 1; $i -lt $linesToMarkIgnored.Count; $i++) {
            if ($linesToMarkIgnored[$i] -eq $linesToMarkIgnored[$i - 1] + 1) {
                $currentGroup += $linesToMarkIgnored[$i]
            }
            else {
                $markIgnoredGroups += , @($currentGroup)
                $currentGroup = @($linesToMarkIgnored[$i])
            }
        }
        $markIgnoredGroups += , @($currentGroup)
    }

    # Separate lines to move
    foreach ($lineIdx in $Actions.Keys) {
        if ($Actions[$lineIdx].Action -eq 'Move') {
            $linesToMove += $lineIdx
        }
    }

    # Process all lines
    $wrappedLines = @{}
    foreach ($group in $wrapGroups) {
        foreach ($idx in $group) {
            $wrappedLines[$idx] = $true
        }
    }

    $markedIgnoredLines = @{}
    foreach ($group in $markIgnoredGroups) {
        foreach ($idx in $group) {
            $markedIgnoredLines[$idx] = $true
        }
    }

    for ($i = 0; $i -lt $ScriptLines.Count; $i++) {
        # Check if this line starts a wrap group
        $startsWrapGroup = $false
        $wrapGroup = $null
        foreach ($group in $wrapGroups) {
            if ($group[0] -eq $i) {
                $startsWrapGroup = $true
                $wrapGroup = $group
                break
            }
        }

        if ($startsWrapGroup) {
            # Start the New-Step block
            $newScriptLines += "New-Step {"
            foreach ($idx in $wrapGroup) {
                $newScriptLines += " $($ScriptLines[$idx])"
            }
            $newScriptLines += "}"
            # Skip to after this group
            $i = $wrapGroup[-1]
            continue
        }

        # Check if this line starts a mark ignored group
        $startsMarkIgnoredGroup = $false
        $markIgnoredGroup = $null
        foreach ($group in $markIgnoredGroups) {
            if ($group[0] -eq $i) {
                $startsMarkIgnoredGroup = $true
                $markIgnoredGroup = $group
                break
            }
        }

        if ($startsMarkIgnoredGroup) {
            # Add region start
            $newScriptLines += "#region Stepper ignore"
            foreach ($idx in $markIgnoredGroup) {
                $newScriptLines += $ScriptLines[$idx]
            }
            $newScriptLines += "#endregion Stepper ignore"
            # Skip to after this group
            $i = $markIgnoredGroup[-1]
            continue
        }

        # Skip lines that are wrapped/marked/moved/deleted (but not the start of a group)
        if ($wrappedLines.ContainsKey($i) -or
            $markedIgnoredLines.ContainsKey($i) -or
            ($Actions.ContainsKey($i) -and $Actions[$i].Action -in @('Move', 'Delete'))) {
            continue
        }

        # Copy line as-is
        $newScriptLines += $ScriptLines[$i]
    }

    # Add moved code at the end
    if ($linesToMove.Count -gt 0) {
        $newScriptLines += ""
        foreach ($lineIdx in ($linesToMove | Sort-Object)) {
            $newScriptLines += $ScriptLines[$lineIdx]
        }
    }

    # Write back to file
    $newScriptLines | Set-Content -Path $ScriptPath -Force

    # Delete state file since script was modified
    $statePath = Get-StepperStatePath -ScriptPath $ScriptPath
    Remove-StepperState -StatePath $statePath

    $scriptName = Split-Path $ScriptPath -Leaf
    Write-Host ""
    Write-Host "Changes applied to non-resumable code. Please re-run $scriptName." -ForegroundColor Green
}

function Write-StepperState {
    <#
    .SYNOPSIS
        Writes the Stepper state file.

    .DESCRIPTION
        Serializes and writes the state object to disk.

    .PARAMETER StatePath
        The path to the state file.

    .PARAMETER ScriptHash
        SHA256 hash of the script content.

    .PARAMETER LastCompletedStep
        Identifier of the last successfully completed step (format: "filepath:line").

    .PARAMETER StepperData
        The $Stepper hashtable to persist.

    .PARAMETER ScriptContents
        The full contents of the script at the time of saving (string). Useful for inspection when the script changes.

    .OUTPUTS
        None
    #>

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

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

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

        [Parameter()]
        [hashtable]$StepperData,

        [Parameter()]
        [string]$ScriptContents
    )

    $state = [PSCustomObject]@{
        ScriptHash        = $ScriptHash
        ScriptContents    = $ScriptContents
        LastCompletedStep = $LastCompletedStep
        Timestamp         = (Get-Date).ToString('o')
        StepperData       = $StepperData
    }

    try {
        Export-Clixml -Path $StatePath -InputObject $state -ErrorAction Stop
    }
    catch {
        Write-Warning "Failed to write state file '$StatePath': $_"
    }
}

function New-Step {
    <#
    .SYNOPSIS
        Executes a step in a resumable script.

    .DESCRIPTION
        New-Step allows scripts to be resumed from the last successfully completed step.
        On first execution, it checks for an existing state file and offers to resume.
        Each step is automatically tracked by its location (file:line) in the script.

        The script content is hashed to detect modifications. If the script changes
        between runs, the state is invalidated and execution starts fresh.

    .PARAMETER ScriptBlock
        The code to execute for this step.

    .EXAMPLE
        New-Step {
            Write-Host "Downloading files..."
            Start-Sleep -Seconds 2
        }

        New-Step {
            Write-Host "Processing data..."
            Start-Sleep -Seconds 2
        }

        If the script fails during processing, the next run will skip the download step.

    .NOTES
        State files are stored alongside the script with a .stepper extension.
        Call Stop-Stepper at the end of your script to remove the state file upon successful completion.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [scriptblock]$ScriptBlock
    )

    # Inherit verbose preference by walking the call stack
    $callStack = Get-PSCallStack
    foreach ($frame in $callStack) {
        if ($frame.InvocationInfo.BoundParameters.ContainsKey('Verbose') -and
            $frame.InvocationInfo.BoundParameters['Verbose']) {
            $VerbosePreference = 'Continue'
            break
        }
    }

    # Get step identifier and script info
    $stepId = Get-StepIdentifier
    # Extract script path from identifier (format: "path:line")
    $lastColonIndex = $stepId.LastIndexOf(':')
    $scriptPath = $stepId.Substring(0, $lastColonIndex)
    $currentHash = Get-ScriptHash -ScriptPath $scriptPath
    $statePath = Get-StepperStatePath -ScriptPath $scriptPath

    # Initialize $Stepper hashtable in calling script scope if it doesn't exist
    $callingScope = $PSCmdlet.SessionState
    try {
        $existingStepper = $callingScope.PSVariable.Get('Stepper')
        if (-not $existingStepper) {
            $callingScope.PSVariable.Set('Stepper', @{})
            Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Initialized `$Stepper hashtable"
        }
    }
    catch {
        $callingScope.PSVariable.Set('Stepper', @{})
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Initialized `$Stepper hashtable"
    }

    # Check if this is the first step of this script execution
    # We use a variable in the calling scope to track initialization per execution
    $initVarName = '__StepperInitialized'
    $isFirstStep = $false
    try {
        $initVar = $callingScope.PSVariable.Get($initVarName)
        if (-not $initVar -or -not $initVar.Value) {
            $isFirstStep = $true
            $callingScope.PSVariable.Set($initVarName, $true)
        }
    }
    catch {
        $isFirstStep = $true
        $callingScope.PSVariable.Set($initVarName, $true)
    }

    # Initialize execution state on first step
    if ($isFirstStep) {
        # Store execution state in calling scope
        $executionState = @{
            RestoreMode       = $false
            TargetStep        = $null
            CurrentScriptPath = $scriptPath
            CurrentScriptHash = $currentHash
            StatePath         = $statePath
        }
        $callingScope.PSVariable.Set('__StepperExecutionState', $executionState)

        # Check script requirements (declarations) first
        $requirementsModified = Test-StepperScriptRequirements -ScriptPath $scriptPath
        if ($requirementsModified) {
            exit
        }

        # Check for non-resumable code between New-Step blocks and before Stop-Stepper
        $scriptLines = Get-Content -Path $scriptPath

        $blockInfo = Find-NewStepBlocks -ScriptLines $scriptLines
        $newStepBlocks = $blockInfo.NewStepBlocks
        $stopStepperLine = $blockInfo.StopStepperLine

        $nonResumableBlocks = Find-NonResumableCodeBlocks -ScriptLines $scriptLines -NewStepBlocks $newStepBlocks -StopStepperLine $stopStepperLine

        # Process each non-resumable block individually
        if ($nonResumableBlocks.Count -gt 0) {
            $scriptName = Split-Path $scriptPath -Leaf
            $allLinesToRemove = @{}

            foreach ($block in $nonResumableBlocks) {
                $action = Get-NonResumableCodeAction -ScriptName $scriptName -ScriptLines $scriptLines -Block $block

                if ($action -eq 'Quit') {
                    Write-Host ""
                    Write-Host "Exiting..." -ForegroundColor Yellow
                    exit
                }

                if ($action -ne 'Ignore') {
                    # Mark these lines with the chosen action
                    foreach ($line in $block.Lines) {
                        $allLinesToRemove[$line] = @{ Action = $action; Code = $scriptLines[$line] }
                    }
                }
            }

            # Apply all the changes
            if ($allLinesToRemove.Count -gt 0) {
                Update-ScriptWithNonResumableActions -ScriptPath $scriptPath -ScriptLines $scriptLines -Actions $allLinesToRemove -NewStepBlocks $newStepBlocks
                exit
            }
        }

        # Verify the script contains Stop-Stepper (last check)
        $scriptContent = Get-Content -Path $scriptPath -Raw
        if ($scriptContent -notmatch 'Stop-Stepper') {
            $scriptName = Split-Path $scriptPath -Leaf
            Write-Host ""
            Write-Host "[!] Script '$scriptName' does not call Stop-Stepper." -ForegroundColor Magenta
            Write-Host ""
            Write-Host "Stop-Stepper ensures the state file is removed when the script completes successfully."
            Write-Host ""
            Write-Host "How would you like to proceed?"
            Write-Host ""
            Write-Host " [A] Add 'Stop-Stepper' to the end of the script (Default)" -ForegroundColor Cyan
            Write-Host " [C] Continue without Stop-Stepper" -ForegroundColor White
            Write-Host " [Q] Quit" -ForegroundColor White
            Write-Host ""
            Write-Host "Choice? [" -NoNewline
            Write-Host "A" -NoNewline -ForegroundColor Cyan
            Write-Host "/c/q]: " -NoNewline
            $response = Read-Host

            if ($response -eq '' -or $response -eq 'A' -or $response -eq 'a') {
                # Add Stop-Stepper to the end of the script
                $updatedContent = $scriptContent.TrimEnd()
                if (-not $updatedContent.EndsWith("`n")) {
                    $updatedContent += "`n"
                }
                $updatedContent += "`nStop-Stepper`n"

                Set-Content -Path $scriptPath -Value $updatedContent -NoNewline

                # Delete state file since script was modified
                Remove-StepperState -StatePath $statePath

                Write-Host ""
                Write-Host "Stop-Stepper added. Please re-run $scriptName." -ForegroundColor Green

                # Exit this execution - the script will need to be run again
                exit
            }
            elseif ($response -eq 'C' -or $response -eq 'c') {
                Write-Warning "Continuing without Stop-Stepper. State file will not be cleaned up automatically."
            }
            elseif ($response -eq 'Q' -or $response -eq 'q') {
                Write-Host ""
                Write-Host "Exiting..." -ForegroundColor Yellow
                exit
            }
            else {
                # Default to Add for invalid input
                # Add Stop-Stepper to the end of the script
                $updatedContent = $scriptContent.TrimEnd()
                if (-not $updatedContent.EndsWith("`n")) {
                    $updatedContent += "`n"
                }
                $updatedContent += "`nStop-Stepper`n"

                Set-Content -Path $scriptPath -Value $updatedContent -NoNewline

                # Delete state file since script was modified
                Remove-StepperState -StatePath $statePath

                Write-Host ""
                Write-Host "Stop-Stepper added. Please re-run $scriptName." -ForegroundColor Green

                # Exit this execution - the script will need to be run again
                exit
            }
        }

        $existingState = Read-StepperState -StatePath $statePath

        # Try to load persisted $Stepper data from state
        if ($existingState -and $existingState.StepperData) {
            try {
                $callingScope.PSVariable.Set('Stepper', $existingState.StepperData)
                $variableNames = ($existingState.StepperData.Keys | Sort-Object) -join ', '
                Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Loaded `$Stepper data from disk ($variableNames)"
            }
            catch {
                Write-Warning "Failed to load persisted `$Stepper data: $_"
            }
        }

        if ($existingState) {
            # Check if script has been modified
            if ($existingState.ScriptHash -ne $currentHash) {
                # Script has been modified since last run — prompt user for action
                $scriptContent = Get-Content -Path $scriptPath -Raw
                $stepMatches = [regex]::Matches($scriptContent, '^\s*New-Step\s+\{', [System.Text.RegularExpressions.RegexOptions]::Multiline)
                $totalSteps = $stepMatches.Count

                # Find all step line numbers
                $stepLines = @()
                $lineNumber = 1
                $lines = $scriptContent -split "`r?`n"
                foreach ($line in $lines) {
                    if ($line -match '^\s*New-Step\s+\{') {
                        $stepLines += "${scriptPath}:${lineNumber}"
                    }
                    $lineNumber++
                }

                $lastStep = $existingState.LastCompletedStep
                $lastStepIndex = $stepLines.IndexOf($lastStep)
                $nextStepNumber = $lastStepIndex + 2  # +1 for next step, +1 because index is 0-based

                $timestamp = [DateTime]::Parse($existingState.Timestamp).ToString('yyyy-MM-dd HH:mm:ss')
                $availableVars = if ($existingState.StepperData -and $existingState.StepperData.Count -gt 0) {
                    ($existingState.StepperData.Keys | Sort-Object) -join ', '
                }
                else {
                    'None'
                }

                $scriptName = Split-Path $scriptPath -Leaf
                $nextStepId = $stepLines[$lastStepIndex + 1]
                $nextStepLine = ($nextStepId -split ':')[-1]

                while ($true) {
                    Write-Host ""
                    Write-Host "[!] Incomplete script run detected, but $scriptName has been modified." -ForegroundColor Magenta
                    Write-Host ""
                    Write-Host "Total Steps: $totalSteps"
                    Write-Host "Steps Completed: $($lastStepIndex + 1)"
                    Write-Host "Variables: $availableVars"
                    Write-Host "Last Activity: $timestamp"
                    Write-Host ""

                    Write-Host "How would you like to proceed?"
                    Write-Host ""
                    Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (May produce inconsistent results)" -ForegroundColor White
                    Write-Host " [S] Start over (Default)" -ForegroundColor Cyan
                    Write-Host " [M] More details" -ForegroundColor White
                    Write-Host " [Q] Quit" -ForegroundColor White
                    Write-Host ""
                    Write-Host "Choice? [" -NoNewline
                    Write-Host "r" -NoNewline -ForegroundColor White
                    Write-Host "/S" -NoNewline -ForegroundColor Cyan
                    Write-Host "/m/q]: " -NoNewline
                    $response = Read-Host

                    if ($response -eq '' -or $response -eq 'S' -or $response -eq 's') {
                        Write-Host "Starting fresh..." -ForegroundColor Yellow
                        Remove-StepperState -StatePath $statePath
                        break
                    }
                    elseif ($response -eq 'R' -or $response -eq 'r') {
                        Write-Host ""
                        Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green
                        $executionState.RestoreMode = $true
                        $executionState.TargetStep = $lastStep
                        break
                    }
                    elseif ($response -eq 'M' -or $response -eq 'm') {
                        Show-MoreDetails -ExistingState $existingState -ScriptPath $scriptPath -CurrentHash $currentHash -LastStep $lastStep -NextStepLine $nextStepLine -ShowHashComparison

                        # Re-display the bottom inline menu and accept an immediate choice
                        Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (May produce inconsistent results)" -ForegroundColor White
                        Write-Host " [S] Start over (Default)" -ForegroundColor Cyan
                        Write-Host " [M] More details" -ForegroundColor White
                        Write-Host " [Q] Quit" -ForegroundColor White
                        Write-Host ""
                        Write-Host "Choice? [r/S/m/q]: " -NoNewline
                        $moreResponse = Read-Host

                        if ($moreResponse -eq '' -or $moreResponse -eq 'S' -or $moreResponse -eq 's') {
                            Write-Host "Starting fresh..." -ForegroundColor Yellow
                            Remove-StepperState -StatePath $statePath
                            break
                        }
                        elseif ($moreResponse -eq 'R' -or $moreResponse -eq 'r') {
                            Write-Host ""
                            Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green
                            $executionState.RestoreMode = $true
                            $executionState.TargetStep = $lastStep
                            break
                        }
                        elseif ($moreResponse -eq 'M' -or $moreResponse -eq 'm') {
                            # Re-display details (loop)
                            continue
                        }
                        elseif ($moreResponse -eq 'Q' -or $moreResponse -eq 'q') {
                            Write-Host ""
                            Write-Host "Exiting..." -ForegroundColor Yellow
                            exit
                        }
                        else {
                            # Default to Start over for invalid input
                            Write-Host "Starting fresh..." -ForegroundColor Yellow
                            Remove-StepperState -StatePath $statePath
                            break
                        }
                    }
                    elseif ($response -eq 'Q' -or $response -eq 'q') {
                        Write-Host ""
                        Write-Host "Exiting..." -ForegroundColor Yellow
                        exit
                    }
                    else {
                        # Default to Start over for invalid input
                        Write-Host "Starting fresh..." -ForegroundColor Yellow
                        Remove-StepperState -StatePath $statePath
                        break
                    }
                }
            }
            else {
                # Count total steps in the script by finding all New-Step calls
                $scriptContent = Get-Content -Path $scriptPath -Raw
                $stepMatches = [regex]::Matches($scriptContent, '^\s*New-Step\s+\{', [System.Text.RegularExpressions.RegexOptions]::Multiline)
                $totalSteps = $stepMatches.Count

                # Find all step line numbers to determine which step number we're on
                $stepLines = @()
                $lineNumber = 1
                foreach ($line in (Get-Content -Path $scriptPath)) {
                    if ($line -match '^\s*New-Step\s+\{') {
                        $stepLines += "${scriptPath}:${lineNumber}"
                    }
                    $lineNumber++
                }

                # Find the index of the last completed step
                $lastStep = $existingState.LastCompletedStep
                $lastStepIndex = $stepLines.IndexOf($lastStep)
                $nextStepNumber = $lastStepIndex + 2  # +1 for next step, +1 because index is 0-based

                $timestamp = [DateTime]::Parse($existingState.Timestamp).ToString('yyyy-MM-dd HH:mm:ss')

                # Get available variable names from StepperData
                $availableVars = if ($existingState.StepperData -and $existingState.StepperData.Count -gt 0) {
                    ($existingState.StepperData.Keys | Sort-Object) -join ', '
                }
                else {
                    'None'
                }

                Write-Host ""
                Write-Host "[!] Incomplete script run detected!" -ForegroundColor Magenta
                Write-Host ""
                Write-Host "Total Steps: $totalSteps"
                Write-Host "Steps Completed: $($lastStepIndex + 1)"
                Write-Host "Variables: $availableVars"
                Write-Host "Last Activity: $timestamp"
                Write-Host ""

                if ($nextStepNumber -le $totalSteps) {
                    # Get the script name and next step line number
                    $scriptName = Split-Path $scriptPath -Leaf
                    $nextStepId = $stepLines[$lastStepIndex + 1]
                    $nextStepLine = ($nextStepId -split ':')[-1]

                    while ($true) {
                        Write-Host "How would you like to proceed?"
                        Write-Host ""
                        Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (Default)" -ForegroundColor Cyan
                        Write-Host " [S] Start over" -ForegroundColor White
                        Write-Host " [M] More details" -ForegroundColor White
                        Write-Host " [Q] Quit" -ForegroundColor White
                        Write-Host ""
                        Write-Host "Choice? [" -NoNewline
                        Write-Host "R" -NoNewline -ForegroundColor Cyan
                        Write-Host "/s/m/q]: " -NoNewline
                        $response = Read-Host

                        if ($response -eq '' -or $response -eq 'R' -or $response -eq 'r') {
                            Write-Host ""
                            Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green
                            $executionState.RestoreMode = $true
                            $executionState.TargetStep = $lastStep
                            break
                        }
                        elseif ($response -eq 'S' -or $response -eq 's') {
                            Write-Host "Starting fresh..." -ForegroundColor Yellow
                            Remove-StepperState -StatePath $statePath
                            break
                        }
                        elseif ($response -eq 'M' -or $response -eq 'm') {
                            Show-MoreDetails -ExistingState $existingState -ScriptPath $scriptPath -CurrentHash $currentHash -LastStep $lastStep -NextStepLine $nextStepLine

                            # Print the action menu again at the bottom of the details and accept an immediate choice
                            Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (Default)" -ForegroundColor Cyan
                            Write-Host " [S] Start over" -ForegroundColor White
                            Write-Host " [M] More details" -ForegroundColor White
                            Write-Host " [Q] Quit" -ForegroundColor White
                            Write-Host ""
                            Write-Host "Choice? [R/s/m/q]: " -NoNewline
                            $moreResponse = Read-Host

                            if ($moreResponse -eq '' -or $moreResponse -eq 'S' -or $moreResponse -eq 's') {
                                Write-Host "Starting fresh..." -ForegroundColor Yellow
                                Remove-StepperState -StatePath $statePath
                                break
                            }
                            elseif ($moreResponse -eq 'R' -or $moreResponse -eq 'r') {
                                Write-Host ""
                                Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green
                                $executionState.RestoreMode = $true
                                $executionState.TargetStep = $lastStep
                                break
                            }
                            elseif ($moreResponse -eq 'M' -or $moreResponse -eq 'm') {
                                # Re-display details (loop)
                                continue
                            }
                            elseif ($moreResponse -eq 'Q' -or $moreResponse -eq 'q') {
                                Write-Host ""
                                Write-Host "Exiting..." -ForegroundColor Yellow
                                exit
                            }
                            else {
                                # Default to Start over for invalid input
                                Write-Host "Starting fresh..." -ForegroundColor Yellow
                                Remove-StepperState -StatePath $statePath
                                break
                            }
                        }
                        elseif ($response -eq 'Q' -or $response -eq 'q') {
                            Write-Host ""
                            Write-Host "Exiting..." -ForegroundColor Yellow
                            exit
                        }
                        else {
                            # Default to Resume for invalid input
                            Write-Host ""
                            Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green
                            $executionState.RestoreMode = $true
                            $executionState.TargetStep = $lastStep
                            break
                        }
                    }
                }
                else {
                    Write-Host "All steps were completed. Starting fresh..." -ForegroundColor Yellow
                    Remove-StepperState -StatePath $statePath
                }
                Write-Host ""
            }
        }
    }

    # Determine if we should execute this step
    $shouldExecute = $true

    # Get execution state from calling scope
    try {
        $executionState = $callingScope.PSVariable.Get('__StepperExecutionState').Value
    }
    catch {
        $executionState = $null
    }

    if ($executionState -and $executionState.RestoreMode) {
        # Format step identifier for display messages
        $stepIdParts = $stepId -split ':'
        $scriptName = Split-Path $stepIdParts[0] -Leaf
        $displayStepId = "${scriptName}:$($stepIdParts[1])"

        # In restore mode: skip steps until we reach the target
        if ($stepId -eq $executionState.TargetStep) {
            # This is the last completed step, skip it and disable restore mode
            $executionState.RestoreMode = $false
            $shouldExecute = $false
        }
        elseif ($executionState.RestoreMode) {
            # Still skipping
            $shouldExecute = $false
        }
    }

    # Execute the step if needed
    if ($shouldExecute) {
        # Format step identifier for display (scriptname:line instead of full path)
        $stepIdParts = $stepId -split ':'
        $scriptName = Split-Path $stepIdParts[0] -Leaf
        $displayStepId = "${scriptName}:$($stepIdParts[1])"

        # Calculate step number (X/Y)
        $scriptContent = Get-Content -Path $scriptPath -Raw
        $stepMatches = [regex]::Matches($scriptContent, '^\s*New-Step\s+\{', [System.Text.RegularExpressions.RegexOptions]::Multiline)
        $totalSteps = $stepMatches.Count

        # Find all step line numbers
        $stepLines = @()
        $lineNumber = 1
        foreach ($line in (Get-Content -Path $scriptPath)) {
            if ($line -match '^\s*New-Step\s+\{') {
                $stepLines += "${scriptPath}:${lineNumber}"
            }
            $lineNumber++
        }
        $currentStepNumber = $stepLines.IndexOf($stepId) + 1

        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Executing step $currentStepNumber/$totalSteps ($displayStepId)"

        # Show current $Stepper data
        try {
            $stepperData = $callingScope.PSVariable.Get('Stepper').Value
            if ($stepperData -and $stepperData.Count -gt 0) {
                $variableNames = ($stepperData.Keys | Sort-Object) -join ', '
                Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Available `$Stepper data ($variableNames)"
            }
        }
        catch {
            # Ignore if unable to read $Stepper
        }

        try {
            & $ScriptBlock

            # Update state file after successful execution (including $Stepper data)
            $stepperData = $callingScope.PSVariable.Get('Stepper').Value
            # Persist state including the script contents for better change inspection
            $scriptContents = Get-Content -Path $scriptPath -Raw
            Write-StepperState -StatePath $statePath -ScriptHash $currentHash -LastCompletedStep $stepId -StepperData $stepperData -ScriptContents $scriptContents
            Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Step $currentStepNumber/$totalSteps completed ($displayStepId)"

            if ($stepperData -and $stepperData.Count -gt 0) {
                Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Saved `$Stepper data ($($stepperData.Count) items)"
            }
        }
        catch {
            Write-Error "Step failed at $stepId : $_"
            throw
        }
    }
}

function Stop-Stepper {
    <#
    .SYNOPSIS
        Stops Stepper and clears the state file for the calling script.

    .DESCRIPTION
        Removes the state file, typically called at the end of a script
        when it completes successfully. This ensures the next run starts fresh.

    .EXAMPLE
        # At the end of your script:
        New-Step { Write-Host "Final step" }
        Stop-Stepper

    .NOTES
        This function automatically determines which script called it and
        removes the corresponding state file.
    #>

    [CmdletBinding()]
    param()

    # Inherit verbose preference by walking the call stack
    $callStack = Get-PSCallStack
    foreach ($frame in $callStack) {
        if ($frame.InvocationInfo.BoundParameters.ContainsKey('Verbose') -and
            $frame.InvocationInfo.BoundParameters['Verbose']) {
            $VerbosePreference = 'Continue'
            break
        }
    }

    Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] All steps complete. Cleaning up..."

    try {
        $callStack = Get-PSCallStack

        # Find the calling script (skip this function)
        for ($i = 1; $i -lt $callStack.Count; $i++) {
            $frame = $callStack[$i]
            $scriptPath = $frame.ScriptName

            # Skip frames without a script name
            if (-not $scriptPath) {
                continue
            }

            # Skip frames from within the Stepper module
            # Normalize path for cross-platform comparison
            $normalizedPath = $scriptPath -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
            $sep = [System.IO.Path]::DirectorySeparatorChar
            if ($normalizedPath -like '*Stepper.psm1' -or
                $normalizedPath -like "*${sep}Private${sep}*.ps1" -or
                $normalizedPath -like "*${sep}Public${sep}*.ps1") {
                continue
            }

            # Found the user's script
            $statePath = Get-StepperStatePath -ScriptPath $scriptPath
            Remove-StepperState -StatePath $statePath
            $scriptName = Split-Path $scriptPath -Leaf
            Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Cleared Stepper state for $scriptName"

            Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Cleanup complete!"

            return
        }

        Write-Warning "Unable to determine calling script from call stack"
    }
    catch {
        Write-Error "Failed to clear Stepper state: $_"
    }
}



# Export functions and aliases as required
Export-ModuleMember -Function @('New-Step', 'Stop-Stepper') -Alias @()