public/Invoke-OctaCategory.ps1

function Invoke-OctaCategory {
    <#
        .SYNOPSIS
        Dry-run preview and (optionally) apply of one or more Octa categories.

        FR-002 (dry-run preview), FR-003 (confirmation), FR-004/FR-005 (restore point),
        FR-006 (prior-state snapshot), FR-007 (irreversible actions surfaced), FR-024 (live
        activity panel). Returns a status object bin/octa.ps1 maps to an exit code.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$CategoryId,
        [switch]$Apply,
        [switch]$NoRestorePoint,
        [switch]$Yes,
        [switch]$Quiet,
        [switch]$ExcludeRisky
    )

    $categories = @($CategoryId | ForEach-Object { Get-OctaCategory -Id $_ } | Where-Object { $_ })
    $missing = $CategoryId | Where-Object { $_ -notin $categories.Id }
    if ($missing) {
        return [pscustomobject]@{ Status = 'UnknownCategory'; Message = "Unknown category: $($missing -join ', ')" }
    }

    $strings = Get-OctaStrings

    # Edge case: surface (never block on) a prior interrupted run before starting a new one.
    $lastRun = Get-OctaRunRecord -RunId 'latest'
    if ($lastRun -and $lastRun.Status -eq 'PartiallyApplied') {
        Write-Host "Warning: the previous run ($($lastRun.RunId)) was interrupted mid-apply. Run 'octa --undo $($lastRun.RunId)' to clean it up, or re-run the same category to finish/repair it."
    }

    # FR: elevation declared per category before any scan/apply.
    $needElevation = Assert-OctaElevation -Categories $categories
    if ($needElevation) {
        $names = ($needElevation | ForEach-Object { $_.DisplayName }) -join ', '
        return [pscustomobject]@{ Status = 'ElevationRequired'; Message = ($strings.elevationRequired -f $names) }
    }

    # --- Scan phase: build the combined, de-duplicated action list -----------------------
    Initialize-OctaActivityPanel
    $byCategory = @{}
    $seenKeys = @{}
    $combinedActions = @()

    foreach ($category in $categories) {
        Write-OctaActivity ($strings.activityScanning -f $category.DisplayName)
        $categoryActions = & $category.GetActionsFunction
        $byCategory[$category.Id] = $categoryActions

        foreach ($action in $categoryActions) {
            $dedupeKey = '{0}|{1}' -f $action.TargetType, $action.TargetIdentifier
            if ($seenKeys.ContainsKey($dedupeKey)) { continue }
            $seenKeys[$dedupeKey] = $true
            $combinedActions += [pscustomobject]@{ Category = $category; Action = $action }
        }
    }
    Complete-OctaActivityPanel

    # FR-028: bundles that opt in (e.g. Quick Clean) exclude Risky actions entirely, not just
    # gate them behind an extra prompt - filtered here, before preview, so the exclusion is
    # visible to the user rather than a silent behavior difference.
    $excludedRisky = @()
    if ($ExcludeRisky) {
        $excludedRisky = @($combinedActions | Where-Object { $_.Action.RiskLevel -eq 'Risky' })
        $combinedActions = @($combinedActions | Where-Object { $_.Action.RiskLevel -ne 'Risky' })
    }

    # --- Dry-run preview -------------------------------------------------------------------
    if (-not $Quiet) {
        Write-Host ''
        Write-Host $strings.dryRunHeader
        foreach ($entry in $combinedActions) {
            $a = $entry.Action
            $flags = @()
            if ($a.HeuristicMatch) { $flags += 'heuristic' }
            if (-not $a.Reversible) { $flags += 'irreversible' }
            $flags += $a.RiskLevel
            $flagStr = ' [' + ($flags -join ', ') + ']'
            Write-Host (" [{0}] {1}: {2} -> {3}{4}" -f $a.TargetType, $a.TargetIdentifier, $a.CurrentValue, $a.PlannedValue, $flagStr)
        }
        foreach ($category in $categories) {
            if (@($byCategory[$category.Id]).Count -eq 0) {
                Write-Host (" {0}: {1}" -f $category.DisplayName, $strings.nothingToDo)
            }
        }
        if ($excludedRisky.Count -gt 0) {
            Write-Host ''
            Write-Host "Excluded (Risky, not eligible for this bundle - FR-028):"
            foreach ($entry in $excludedRisky) {
                Write-Host (" [{0}] {1}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier)
            }
        }
    }

    if ($combinedActions.Count -eq 0) {
        return [pscustomobject]@{ Status = 'NothingToDo'; Message = $strings.nothingToDo }
    }

    if (-not $Apply) {
        return [pscustomobject]@{ Status = 'DryRun'; Actions = $combinedActions }
    }

    # --- Confirmation (FR-003, FR-026) ------------------------------------------------------
    $groups = Split-OctaActionsByConfirmationGroup -Entries $combinedActions
    $riskyActions = $groups.Risky
    $heuristicActions = $groups.Heuristic
    $normalActions = $groups.Normal

    if ($normalActions.Count -gt 0 -and -not $Yes) {
        $response = Read-Host $strings.confirmPrompt
        if ($response -notin @('y', 'Y', 's', 'S')) {
            return [pscustomobject]@{ Status = 'Cancelled'; Message = 'User declined confirmation.' }
        }
    }

    $applyActions = $normalActions
    if ($groups.Irreversible.Count -gt 0) {
        Write-Host ''
        Write-Host 'Irreversible actions (confirm separately - cannot be undone via --undo):'
        foreach ($entry in $groups.Irreversible) {
            Write-Host (" [{0}] {1}: {2}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier, $entry.Action.IrreversibleReason)
        }
        if ($Yes) {
            $applyActions += $groups.Irreversible
        }
        else {
            $irreversibleResponse = Read-Host $strings.confirmPrompt
            if ($irreversibleResponse -in @('y', 'Y', 's', 'S')) {
                $applyActions += $groups.Irreversible
            }
        }
    }
    if ($heuristicActions.Count -gt 0) {
        Write-Host ''
        Write-Host 'Heuristic OEM-suite matches (confirm separately - FR-017):'
        foreach ($entry in $heuristicActions) {
            Write-Host (" [{0}] {1}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier)
        }
        if ($Yes) {
            $applyActions += $heuristicActions
        }
        else {
            $heuristicResponse = Read-Host $strings.confirmPrompt
            if ($heuristicResponse -in @('y', 'Y', 's', 'S')) {
                $applyActions += $heuristicActions
            }
        }
    }

    if ($riskyActions.Count -gt 0) {
        Write-Host ''
        Write-Host 'Risky actions (confirm separately - FR-026):'
        foreach ($entry in $riskyActions) {
            Write-Host (" [{0}] {1}" -f $entry.Action.TargetType, $entry.Action.TargetIdentifier)
        }
        if ($Yes) {
            $applyActions += $riskyActions
        }
        else {
            $riskyResponse = Read-Host $strings.confirmPrompt
            if ($riskyResponse -in @('y', 'Y', 's', 'S')) {
                $applyActions += $riskyActions
            }
        }
    }

    if ($applyActions.Count -eq 0) {
        return [pscustomobject]@{ Status = 'Cancelled'; Message = 'User declined confirmation.' }
    }

    # --- Restore point (FR-004/FR-005) ------------------------------------------------------
    $restorePoint = New-OctaRestorePoint -NoRestorePoint:$NoRestorePoint -Description 'Octa debloat run'

    if ($restorePoint.Status -eq 'SkippedProtectionDisabled') {
        Write-Host $strings.restorePointSkippedProtectionDisabled
        $shouldEnable = $Yes
        if (-not $Yes) {
            $enableResponse = Read-Host 'Enable System Protection and create a restore point now? (y/N)'
            $shouldEnable = $enableResponse -in @('y', 'Y', 's', 'S')
        }
        if ($shouldEnable) {
            try {
                Enable-ComputerRestore -Drive "$env:SystemDrive\" -ErrorAction Stop
                $restorePoint = New-OctaRestorePoint -NoRestorePoint:$NoRestorePoint -Description 'Octa debloat run'
            }
            catch {
                Write-Host "Could not enable System Protection automatically: $($_.Exception.Message)"
            }
        }
    }

    if ($restorePoint.Status -in @('SkippedThrottled', 'SkippedProtectionDisabled')) {
        $warningKey = if ($restorePoint.Status -eq 'SkippedThrottled') { 'restorePointSkippedThrottled' } else { 'restorePointSkippedProtectionDisabled' }
        Write-Host $strings.$warningKey
        if (-not $Yes) {
            $proceedResponse = Read-Host $strings.confirmPrompt
            if ($proceedResponse -notin @('y', 'Y', 's', 'S')) {
                return [pscustomobject]@{ Status = 'Cancelled'; Message = 'User declined to proceed without a restore point.' }
            }
        }
    }

    # --- Apply, snapshotting prior state first (FR-006) -------------------------------------
    $run = New-OctaRunFolder
    $snapshots = @()
    $irreversibleRefs = @()
    $appliedCategoryIds = @()
    $runStatus = 'Completed'

    Initialize-OctaActivityPanel
    try {
        foreach ($entry in $applyActions) {
            $action = $entry.Action
            $snapshot = Save-OctaActionSnapshot -Action $action -RunFolder $run.Path
            if ($snapshot) {
                $snapshots += $snapshot
                Write-OctaActivity ($strings.activitySaved -f $action.TargetIdentifier)
            }
            else {
                $irreversibleRefs += $action.TargetIdentifier
            }

            Write-OctaActivity ($strings.activityApplying -f $action.TargetIdentifier)
            & $entry.Category.ApplyActionFunction -Action $action

            if ($entry.Category.Id -notin $appliedCategoryIds) {
                $appliedCategoryIds += $entry.Category.Id
            }
        }
    }
    catch {
        $runStatus = 'PartiallyApplied'
        Complete-OctaActivityPanel
        Save-OctaRunRecord -RunFolder $run.Path -RunRecord ([pscustomobject]@{
                RunId                      = $run.RunId
                Timestamp                  = (Get-Date).ToString('o')
                CategoriesApplied          = $appliedCategoryIds
                RestorePointStatus         = $restorePoint.Status
                RestorePointSequenceNumber = $restorePoint.SequenceNumber
                ActionSnapshots            = $snapshots
                IrreversibleActionRefs     = $irreversibleRefs
                Status                     = $runStatus
            })
        return [pscustomobject]@{ Status = 'Error'; Message = $_.Exception.Message; RunId = $run.RunId }
    }
    Complete-OctaActivityPanel

    Save-OctaRunRecord -RunFolder $run.Path -RunRecord ([pscustomobject]@{
            RunId                      = $run.RunId
            Timestamp                  = (Get-Date).ToString('o')
            CategoriesApplied          = $appliedCategoryIds
            RestorePointStatus         = $restorePoint.Status
            RestorePointSequenceNumber = $restorePoint.SequenceNumber
            ActionSnapshots            = $snapshots
            IrreversibleActionRefs     = $irreversibleRefs
            Status                     = $runStatus
        })

    return [pscustomobject]@{ Status = 'Success'; RunId = $run.RunId; RestorePointStatus = $restorePoint.Status }
}