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
        if ($StopStepperLine -ge 0) {
            $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1]
            $gapStart = $lastBlock.End + 1
            $gapEnd = $StopStepperLine - 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 = $true
                }
            }
        }
    }

    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, 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 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
            Write-Host ""
            Write-Host "Declarations added to $scriptName. Please re-run the script." -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 = @()
    $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1]

    # 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
    Write-Host ""
    Write-Host "Changes applied. Please re-run the script." -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.

    .OUTPUTS
        None
    #>

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

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

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

        [Parameter()]
        [hashtable]$StepperData
    )

    $state = [PSCustomObject]@{
        ScriptHash        = $ScriptHash
        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) {
        # Verify the script contains Stop-Stepper
        $scriptContent = Get-Content -Path $scriptPath -Raw
        if ($scriptContent -notmatch 'Stop-Stepper') {
            Write-Host "Script '$scriptPath' does not call Stop-Stepper." -ForegroundColor Yellow
            $response = Read-Host "Add 'Stop-Stepper' to the end of the script? (Y/N)"

            if ($response -eq 'Y' -or $response -eq 'y') {
                Write-Host "Adding 'Stop-Stepper' to the end of the script..." -ForegroundColor Yellow

                # 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

                Write-Host "Stop-Stepper added. Please run the script again." -ForegroundColor Green

                # Exit this execution - the script will need to be run again
                throw "Script modified to include Stop-Stepper. Please run the script again."
            }
            else {
                Write-Warning "Continuing without Stop-Stepper. State file will not be cleaned up automatically."
            }
        }

        # 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
        $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
            }
        }

        $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) {
                Write-Host "Script has been modified since last run. Starting fresh." -ForegroundColor Yellow
                Remove-StepperState -StatePath $statePath
            }
            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]

                    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 " [Q] Quit" -ForegroundColor White
                    Write-Host ""
                    Write-Host "Choice? [" -NoNewline
                    Write-Host "R" -NoNewline -ForegroundColor Cyan
                    Write-Host "/s/q]: " -NoNewline
                    $response = Read-Host

                    if ($response -eq '' -or $response -eq 'R' -or $response -eq 'r') {
                        Write-Host "Resuming from step $nextStepNumber..." -ForegroundColor Green
                        $executionState.RestoreMode = $true
                        $executionState.TargetStep = $lastStep
                    }
                    elseif ($response -eq 'S' -or $response -eq 's') {
                        Write-Host "Starting fresh..." -ForegroundColor Yellow
                        Remove-StepperState -StatePath $statePath
                    }
                    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 "Resuming from step $nextStepNumber..." -ForegroundColor Green
                        $executionState.RestoreMode = $true
                        $executionState.TargetStep = $lastStep
                    }
                }
                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
            Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Skipping step: $displayStepId" -ForegroundColor DarkGray
            $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
            Write-StepperState -StatePath $statePath -ScriptHash $currentHash -LastCompletedStep $stepId -StepperData $stepperData
            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 @()